Skip to content

[Bug]: Git dir followed unconditionally #12204

Description

@scriptease

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/desktop

Steps to reproduce

  1. Create a folder in iCloud
  2. Use --git-dir to move the git metadata and files out outside the project
  3. Open the original folder in t3code as a project
  4. t3 opens the root project in the git folder instead and can't find anything like skills

Expected behavior

The follow to the git-dir and it's git-dir is fine, but if the back link is missing it should stay in the requested folder not a random git folder.

Actual behavior

Follows git-dir blindly and doesn't respect the folder the user requested.

Impact

Minor bug or occasional failure

Version or commit

Nightly latest

Environment

MacOS

Logs or stack traces

Screenshots, recordings, or supporting files

No response

Workaround

Fixed by setting the backlink:

git --git-dir=$HOME/.obsidian/git/v1 config core.worktree $HOME/obsidian/V1

Activity

  1. added
    bugSomething is broken or behaving incorrectly.
    needs-triageIssue needs maintainer review and initial categorization.
    on Sep 17, 2026
  2. juliusmarminge commented on Sep 17, 2026

    @juliusmarminge
    Member

    Triage

    Confirmed on current main (6d1d54944). This is a real server VCS / project-root bug, not a desktop-only picker glitch and not an iCloud-only filesystem quirk. Desktop is just the surface that opened the folder. Not already fixed. No later commit teaches T3 to keep the user-requested folder when git-dir has no core.worktree back-link. No duplicate found for separate-git-dir / core.worktree / iCloud+Obsidian layouts.

    What you reported. Vault (or other project) lives in iCloud. Git metadata was moved out with --git-dir (your paths: $HOME/.obsidian/git/v1 and $HOME/obsidian/V1). Opening the original folder in T3 relocates the “root project” into that git directory, so project-local things such as skills disappear. Setting the back-link fixes it:

    git --git-dir=$HOME/.obsidian/git/v1 config core.worktree $HOME/obsidian/V1

    That matches the code. T3 follows git-dir and then trusts Git’s idea of the main worktree. Without core.worktree, Git names the git directory itself as that worktree.

    What the code does

    1. Repo detection uses --show-toplevel and --git-common-dir, then treats the common dir as a cwd when it is not named .git.

    detectRepository records rootPath from --show-toplevel and metadataPath from --git-common-dir:

    const detectRepository: VcsDriver.VcsDriver["Service"]["detectRepository"] = Effect.fn(
    "detectRepository",
    )(function* (cwd) {
    if (!(yield* isInsideWorkTree(cwd))) {
    return null;
    }
    const root = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository.root", cwd, [
    "rev-parse",
    "--show-toplevel",
    ]);
    const gitCommonDir = yield* gitCommand(
    vcsProcess,
    "GitVcsDriver.detectRepository.commonDir",
    cwd,
    ["rev-parse", "--git-common-dir"],
    ).pipe(Effect.orElseSucceed(() => null));
    return {
    kind: "git" as const,
    rootPath: root.stdout.trim(),
    metadataPath: gitCommonDir?.stdout.trim() || null,
    freshness: yield* nowFreshness(),
    };

    resolveRepositoryPaths does the same split (worktreeRoot vs gitCommonDir):

    const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* (
    cwd: string,
    ) {
    const commonDirResult = yield* executeGitWithStableDiagnostics(
    "GitVcsDriver.resolveRepositoryPaths.commonDir",
    cwd,
    ["rev-parse", "--git-common-dir"],
    {
    timeoutMs: 5_000,
    allowNonZeroExit: true,
    },
    );
    if (commonDirResult.exitCode !== 0) {
    const stderr = commonDirResult.stderr.trim();
    if (isNonRepositoryGitStderr(stderr)) {
    return null;
    }
    return yield* new GitCommandError({
    ...gitCommandContext({
    operation: "GitVcsDriver.resolveRepositoryPaths.commonDir",
    cwd,
    args: ["rev-parse", "--git-common-dir"],
    }),
    detail: "Failed to resolve the Git common directory.",
    exitCode: commonDirResult.exitCode,
    stdoutLength: commonDirResult.stdout.length,
    stderrLength: commonDirResult.stderr.length,
    });
    }
    const commonDirOutput = commonDirResult.stdout.trim();
    const resolvedGitCommonDir = path.isAbsolute(commonDirOutput)
    ? path.normalize(commonDirOutput)
    : path.resolve(cwd, commonDirOutput);
    const gitCommonDir = yield* fileSystem
    .realPath(resolvedGitCommonDir)
    .pipe(Effect.orElseSucceed(() => resolvedGitCommonDir));
    const [worktreeRootResult, currentBranchResult] = yield* Effect.all(
    [
    executeGit(
    "GitVcsDriver.resolveRepositoryPaths.worktreeRoot",
    cwd,
    ["rev-parse", "--show-toplevel"],
    {
    timeoutMs: 5_000,
    allowNonZeroExit: true,
    },
    ),
    executeGit(
    "GitVcsDriver.resolveRepositoryPaths.currentBranch",
    cwd,
    ["symbolic-ref", "--quiet", "--short", "HEAD"],
    {
    timeoutMs: 5_000,
    allowNonZeroExit: true,
    },
    ),
    ],
    { concurrency: 2 },
    );
    const worktreeRootOutput = worktreeRootResult.stdout.trim();
    const worktreeRoot =
    worktreeRootResult.exitCode === 0 && worktreeRootOutput.length > 0
    ? path.normalize(
    path.isAbsolute(worktreeRootOutput)
    ? worktreeRootOutput
    : path.resolve(cwd, worktreeRootOutput),
    )
    : null;
    const currentBranchOutput = currentBranchResult.stdout.trim();
    const currentBranch =
    currentBranchResult.exitCode === 0 && currentBranchOutput.length > 0
    ? currentBranchOutput
    : null;
    return {
    gitCommonDir,
    worktreeRoot,
    currentBranch,
    } satisfies GitRepositoryPaths;

    For fetches, default-branch lookups, remotes, and worktree list, cwd is:

    path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir

    gitCommonDir: string,
    remoteName: string,
    ): Effect.Effect<void, GitCommandError> => {
    const fetchCwd =
    path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
    return executeGit(

    A normal repo (…/project/.git) stays in the project. A separate git-dir named v1 is used as cwd. That is the unconditional follow.

    2. git worktree list reports the git-dir as the main worktree for this layout.

    listRefs maps each local branch to a path from git --git-dir <common> worktree list --porcelain. Git does not reconstruct the real checkout from a gitdir: pointer. For --git-dir=$HOME/.obsidian/git/v1 it prints:

    worktree /Users/…/.obsidian/git/v1
    

    even when you ran the command from $HOME/obsidian/V1. T3 then keeps that path if stat succeeds (the git dir exists):

    function parseWorktreeBranchPaths(stdout: string): ReadonlyMap<string, string> {
    const worktreePaths = new Map<string, string>();
    let currentPath: string | null = null;
    let currentBranch: string | null = null;
    let currentPrunable = false;
    const flush = () => {
    if (currentPath !== null && currentBranch !== null && !currentPrunable) {
    worktreePaths.set(currentBranch, currentPath);
    }
    currentPath = null;
    currentBranch = null;
    currentPrunable = false;
    };
    for (const field of stdout.split("\0")) {
    if (field === "") {
    flush();
    } else if (field.startsWith("worktree ")) {
    currentPath = field.slice("worktree ".length);
    } else if (field.startsWith("branch refs/heads/")) {
    currentBranch = field.slice("branch refs/heads/".length);
    } else if (field === "prunable" || field.startsWith("prunable ")) {
    currentPrunable = true;
    }
    }
    flush();
    return worktreePaths;

    executeGit(
    "GitVcsDriver.listRefs.worktreeList",
    fetchCwd,
    [...gitDirArgs, "worktree", "list", "--porcelain", "-z"],
    {
    timeoutMs: 30_000,
    allowNonZeroExit: true,
    maxOutputBytes: 16 * 1024 * 1024,
    },
    ),
    executeGit("GitVcsDriver.listRefs.remoteNames", fetchCwd, [...gitDirArgs, "remote"], {
    timeoutMs: 5_000,
    allowNonZeroExit: true,
    }),
    ],
    { concurrency: 2 },
    );
    const remoteNames =
    remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
    if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) {
    yield* Effect.logWarning(
    `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${gitCommonDir}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
    );
    }
    const defaultBranch =
    defaultRefResult.exitCode === 0
    ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
    : null;
    const parsedWorktreeEntries =
    worktreeListResult.exitCode === 0
    ? [...parseWorktreeBranchPaths(worktreeListResult.stdout)].map(
    ([branchName, worktreePath]) =>
    [branchName, path.normalize(path.resolve(worktreePath))] as const,
    )
    : [];
    const existingWorktreeEntries = yield* Effect.filter(
    parsedWorktreeEntries,
    ([, worktreePath]) =>
    fileSystem.stat(worktreePath).pipe(
    Effect.as(true),
    Effect.orElseSucceed(() => false),
    ),
    { concurrency: 16 },
    );
    const worktreeMap = new Map(existingWorktreeEntries);

    3. That fake worktree is eligible for reuse.

    preparePullRequestThread reuses a branch worktree whenever its listed path is not the project cwd. Vault cwd ≠ git-dir, so it will treat $HOME/.obsidian/git/v1 as an existing worktree and hand the thread that path:

    const rootWorktreePath = yield* canonicalizeExistingPath(input.cwd);
    const pullRequestSummary = yield* (yield* sourceControlProvider(input.cwd)).getChangeRequest({
    cwd: input.cwd,
    reference: normalizedReference,
    });
    const pullRequest = toResolvedPullRequest(pullRequestSummary);
    if (input.mode === "local") {
    yield* (yield* sourceControlProvider(input.cwd)).checkoutChangeRequest({
    cwd: input.cwd,
    reference: normalizedReference,
    force: true,
    });
    const details = yield* gitCore.statusDetails(input.cwd);
    yield* configurePullRequestHeadUpstream(
    input.cwd,
    {
    ...pullRequest,
    ...toPullRequestHeadRemoteInfo(pullRequestSummary),
    },
    details.branch ?? pullRequest.headBranch,
    );
    return {
    pullRequest,
    branch: details.branch ?? pullRequest.headBranch,
    worktreePath: null,
    isOnPullRequestHead: true,
    };
    }
    const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* (
    worktreePath: string,
    ) {
    const details = yield* gitCore.statusDetails(worktreePath);
    yield* configurePullRequestHeadUpstream(
    worktreePath,
    {
    ...pullRequest,
    ...toPullRequestHeadRemoteInfo(pullRequestSummary),
    },
    details.branch ?? pullRequest.headBranch,
    );
    });
    const pullRequestWithRemoteInfo = {
    ...pullRequest,
    ...toPullRequestHeadRemoteInfo(pullRequestSummary),
    } as const;
    const localPullRequestBranch =
    resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo);
    // Git refuses to move a branch that is checked out in a worktree, so the
    // reuse paths cannot go through materializePullRequestHeadBranch and instead
    // advance the checkout from inside the worktree. A worktree that cannot be
    // moved (no reachable head, local commits, dirty tree) is still handed
    // back, because stranding the thread is worse than reporting the staleness.
    const reuseExistingWorktree = Effect.fn("reuseExistingWorktree")(function* (
    worktreePath: string,
    checkedOutBranch: string,
    ) {
    if (checkedOutBranch !== localPullRequestBranch) {
    // findLocalHeadBranch also accepts a branch that merely shares the head's bare name —
    // a fork PR opened from "main" matches the user's own local main. That checkout is
    // somebody else's work, so it keeps its tracking config and nothing else.
    yield* ensureExistingWorktreeUpstream(worktreePath);
    return {
    pullRequest,
    branch: localPullRequestBranch,
    worktreePath,
    isOnPullRequestHead: false,
    };
    }
    // Read before ensureExistingWorktreeUpstream: it force-updates the remote-tracking ref,
    // and once that has jumped to a rewritten head there is no way left to tell a checkout
    // that holds nothing of its own from one carrying local commits.
    const upstreamCommitBeforeFetch = yield* gitCore
    .resolveCommit({ cwd: worktreePath, revision: "@{upstream}" })
    .pipe(
    Effect.map((resolved) => resolved.commitSha),
    Effect.orElseSucceed(() => null),
    );
    yield* ensureExistingWorktreeUpstream(worktreePath);
    const refreshed = yield* gitCore
    // The pull request's own ref, because it is the only thing that certainly names its
    // head. The branch's upstream does not: configuring it is best-effort, so a branch cut
    // from `origin/main` whose head branch has since been deleted still resolves — and
    // following it would move the checkout onto main and call that the pull request.
    .fetchPullRequestHeadCommit({ cwd: worktreePath, prNumber: pullRequest.number })
    .pipe(
    // A host that publishes no `refs/pull/<n>/head` leaves the remote-tracking branch,
    // taken only where it is the head branch's own rather than whatever the checkout
    // happened to be cut from.
    Effect.catch(() =>
    Effect.gen(function* () {
    const details = yield* gitCore.statusDetails(worktreePath);
    if (
    details.upstreamRef === null ||
    !details.upstreamRef.endsWith(`/${pullRequest.headBranch}`)
    ) {
    return yield* new GitManagerError({
    operation: "preparePullRequestThread",
    cwd: worktreePath,
    detail: "The pull request head could not be resolved for this checkout.",
    });
    }
    return yield* gitCore.resolveCommit({
    cwd: worktreePath,
    revision: details.upstreamRef,
    });
    }),
    ),
    Effect.flatMap((target) =>
    gitCore.refreshCheckedOutBranch({
    cwd: worktreePath,
    targetCommit: target.commitSha,
    resetWhenHeadCommit: upstreamCommitBeforeFetch,
    }),
    ),
    Effect.catch((error) =>
    Effect.logWarning(
    "GitManager.preparePullRequestThread reused worktree refresh failed",
    {
    worktreePath,
    localBranch: localPullRequestBranch,
    cause: error,
    },
    ).pipe(Effect.as({ moved: false, onTarget: false })),
    ),
    );
    // Only when the checkout actually moved: another thread may be running in this worktree,
    // and re-running the setup script under it buys nothing when the code did not change.
    if (refreshed.moved) {
    yield* maybeRunSetupScript(worktreePath);
    }
    return {
    pullRequest,
    branch: localPullRequestBranch,
    worktreePath,
    isOnPullRequestHead: refreshed.onTarget,
    };
    });
    const findLocalHeadBranch = Effect.fn("findLocalHeadBranch")(function* (cwd: string) {
    const result = yield* gitCore.listRefs({ cwd, refresh: true });
    const localBranch = result.refs.find(
    (branch) => !branch.isRemote && branch.name === localPullRequestBranch,
    );
    if (localBranch) {
    return localBranch;
    }
    if (localPullRequestBranch === pullRequest.headBranch) {
    return null;
    }
    for (const branch of result.refs) {
    if (branch.isRemote || branch.name !== pullRequest.headBranch || !branch.worktreePath) {
    continue;
    }
    const worktreePath = yield* canonicalizeExistingPath(branch.worktreePath);
    if (worktreePath !== rootWorktreePath) {
    return branch;
    }
    }
    return null;
    });
    const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd);
    const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath
    ? yield* canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath)
    : null;
    if (
    existingBranchBeforeFetch?.worktreePath &&
    existingBranchBeforeFetchPath !== rootWorktreePath
    ) {
    return yield* reuseExistingWorktree(
    existingBranchBeforeFetch.worktreePath,
    existingBranchBeforeFetch.name,
    );
    }

    Same class of mistake anywhere else we prefer ref.worktreePath over the folder the user opened.

    4. Skills are scanned from cwd, not from the git-dir.

    Cursor (and the other drivers) look under <cwd>/.agents/skills, <cwd>/.cursor/skills, <cwd>/.claude/skills, etc.:

    const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* (
    cwd?: string,
    environment: NodeJS.ProcessEnv = process.env,
    ) {
    const path = yield* Path.Path;
    const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir();
    const rootsBelow = (base: string, scope: "user" | "project") => [
    { directory: path.join(base, ".cursor", "skills"), scope },
    { directory: path.join(base, ".agents", "skills"), scope },
    { directory: path.join(base, ".codex", "skills"), scope },
    { directory: path.join(base, ".claude", "skills"), scope },
    ];
    const roots = [...(cwd ? rootsBelow(cwd, "project") : []), ...rootsBelow(userHome, "user")];

    A git directory is objects/, refs/, HEAD. There are no skills there. That is the “can’t find anything like skills” report.

    5. core.worktree is the missing back-link, and T3 never reads it.

    git rev-parse --show-toplevel from the vault is often already the vault when a .git file (gitdir: …) is present. --show-toplevel / --is-inside-work-tree from the git-dir fail unless core.worktree is set. T3 never consults core.worktree. There is no test for --separate-git-dir / a gitdir: pointer that does not point at …/worktrees/….

    AgentSessionScanner.readGitIdentity follows gitdir: only to classify worktrees vs main repos. A pointer at ~/.obsidian/git/v1 is treated as a normal repository, not as “stay in the folder that had the pointer”:

    /**
    * Git identity of a directory, or the reason it has none. Reads `.git`
    * directly instead of spawning git so a scan over hundreds of candidates
    * stays cheap. A `.git` file is a `gitdir:` pointer. When it points into a
    * `worktrees/` directory the checkout is a linked worktree, which
    * onboarding skips because its history belongs to the main checkout.
    * Submodules use the same pointer shape but live under `modules/`, and
    * are offered like any other repository.
    */
    const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* (
    directory: string,
    ): Effect.fn.Return<
    | { readonly _tag: "Repository"; readonly git: AgentSessionProjectGit | null }
    | { readonly _tag: "Worktree" }
    | { readonly _tag: "NotGit" }
    > {
    const gitPath = path.join(directory, ".git");
    const gitStats = yield* statOption(gitPath);
    if (Option.isNone(gitStats)) return { _tag: "NotGit" } as const;
    let gitDir = gitPath;
    if (gitStats.value.type !== "Directory") {
    const pointer = yield* fileSystem
    .readFileString(gitPath)
    .pipe(Effect.orElseSucceed(() => ""));
    const target = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim();
    if (target === undefined || target.length === 0) return { _tag: "NotGit" } as const;
    gitDir = path.resolve(directory, target);
    if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) return { _tag: "Worktree" } as const;
    }
    const configText = yield* fileSystem
    .readFileString(path.join(gitDir, "config"))
    .pipe(Effect.orElseSucceed(() => ""));
    const originUrl = parseOriginUrlFromGitConfig(configText);
    return {
    _tag: "Repository",
    git: {
    remoteKey: originUrl === null ? null : normalizeGitRemoteUrl(originUrl),
    repository: parseGitHubRepositoryNameWithOwnerFromRemoteUrl(originUrl),
    },
    } as const;
    });

    Why the workaround works

    core.worktree is Git’s back-pointer from the external git-dir to the checkout you opened. After it is set, --show-toplevel and work-tree operations resolve to $HOME/obsidian/V1 even when T3 runs git with --git-dir. T3 should do the equivalent itself: if that back-link is missing, keep the requested folder. Following git-dir for metadata is fine. Relocating the project/thread cwd to the git-dir is not.

    Fix direction

    • Resolve the working tree with git -C <user-opened-path> rev-parse --show-toplevel. If that fails or returns the git-dir / --git-common-dir, keep the opened path.
    • Do not use basename(gitCommonDir) === ".git" ? dirname : gitCommonDir as a project or thread cwd. That is only valid as a process cwd for --git-dir metadata commands.
    • Ignore git worktree list entries that are the git-dir, fail --is-inside-work-tree, or are not the user-opened folder / a real linked worktree under …/worktrees/….
    • Do not reuse those paths in preparePullRequestThread (or any “branch already has a worktree” path).
    • Optional: read core.worktree and use it when present; never invent a root from the git-dir when it is absent.
    • Cover this with a fixture: vault + echo gitdir: $GITDIR > .git + unset core.worktree. Opening the vault must keep that cwd for listing, skills, and worktree reuse.

    Reporter workaround stands. Safe to keep using until this ships.

  3. added
    acceptedfeature request accepted
    via-triageFiled through npx t3 triage
    and removed
    needs-triageIssue needs maintainer review and initial categorization.
    on Sep 17, 2026
  4. scriptease commented on Sep 20, 2026

    @scriptease
    Author

    Update

    The workaround only works for the T3 Code react app, but it doesn't work for the T3 Code swift code app.

    But if you create a thread in the T3 Code react app or desktop app and then open it in the T3 Code swift app then it works.

    Very weird.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    acceptedfeature request acceptedbugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions