Repository navigation
[Bug]: Git dir followed unconditionally #12204
Description
Activity
- addedbugSomething is broken or behaving incorrectly.Something is broken or behaving incorrectly.needs-triageIssue needs maintainer review and initial categorization.Issue needs maintainer review and initial categorization.
on Sep 17, 2026 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 whengit-dirhas nocore.worktreeback-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/v1and$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-dirand then trusts Git’s idea of the main worktree. Withoutcore.worktree, Git names the git directory itself as that worktree.What the code does
1. Repo detection uses
--show-topleveland--git-common-dir, then treats the common dir as a cwd when it is not named.git.detectRepositoryrecordsrootPathfrom--show-toplevelandmetadataPathfrom--git-common-dir:t3code/apps/server/src/vcs/GitVcsDriver.ts
Lines 535 to 558 in 6d1d549
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(), }; resolveRepositoryPathsdoes the same split (worktreeRootvsgitCommonDir):t3code/apps/server/src/vcs/GitVcsDriverCore.ts
Lines 1099 to 1178 in 6d1d549
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
t3code/apps/server/src/vcs/GitVcsDriverCore.ts
Lines 1082 to 1087 in 6d1d549
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 namedv1is used as cwd. That is the unconditional follow.2.
git worktree listreports the git-dir as the main worktree for this layout.listRefsmaps each local branch to a path fromgit --git-dir <common> worktree list --porcelain. Git does not reconstruct the real checkout from agitdir:pointer. For--git-dir=$HOME/.obsidian/git/v1it prints:worktree /Users/…/.obsidian/git/v1even when you ran the command from
$HOME/obsidian/V1. T3 then keeps that path ifstatsucceeds (the git dir exists):t3code/apps/server/src/vcs/GitVcsDriverCore.ts
Lines 271 to 299 in 6d1d549
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;
t3code/apps/server/src/vcs/GitVcsDriverCore.ts
Lines 2736 to 2781 in 6d1d549
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.
preparePullRequestThreadreuses a branch worktree whenever its listed path is not the project cwd. Vault cwd ≠ git-dir, so it will treat$HOME/.obsidian/git/v1as an existing worktree and hand the thread that path:t3code/apps/server/src/git/GitManager.ts
Lines 2322 to 2507 in 6d1d549
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.worktreePathover 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.:t3code/apps/server/src/provider/Drivers/CursorSkills.ts
Lines 218 to 230 in 6d1d549
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.worktreeis the missing back-link, and T3 never reads it.git rev-parse --show-toplevelfrom the vault is often already the vault when a.gitfile (gitdir: …) is present.--show-toplevel/--is-inside-work-treefrom the git-dir fail unlesscore.worktreeis set. T3 never consultscore.worktree. There is no test for--separate-git-dir/ agitdir:pointer that does not point at…/worktrees/….AgentSessionScanner.readGitIdentityfollowsgitdir:only to classify worktrees vs main repos. A pointer at~/.obsidian/git/v1is treated as a normal repository, not as “stay in the folder that had the pointer”:t3code/apps/server/src/project/AgentSessionScanner.ts
Lines 688 to 728 in 6d1d549
/** * 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.worktreeis Git’s back-pointer from the external git-dir to the checkout you opened. After it is set,--show-topleveland work-tree operations resolve to$HOME/obsidian/V1even when T3 runs git with--git-dir. T3 should do the equivalent itself: if that back-link is missing, keep the requested folder. Followinggit-dirfor 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 : gitCommonDiras a project or thread cwd. That is only valid as a process cwd for--git-dirmetadata commands. - Ignore
git worktree listentries 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.worktreeand 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+ unsetcore.worktree. Opening the vault must keep that cwd for listing, skills, and worktree reuse.
Reporter workaround stands. Safe to keep using until this ships.
- Resolve the working tree with
- addedacceptedfeature request acceptedfeature request acceptedvia-triageFiled through npx t3 triageFiled through npx t3 triageand removedneeds-triageIssue needs maintainer review and initial categorization.Issue needs maintainer review and initial categorization.
on Sep 17, 2026 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.
- added a commit that references this issue
on Oct 6, 2026
Before submitting
Area
apps/desktop
Steps to reproduce
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: