From 60f7a0a381c5c0fe0c26bf0ebf601ba16f399275 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 19:41:20 +0000 Subject: [PATCH 1/2] test(server): picking the previous branch after an outside checkout (repro #32029) listRefs keeps reporting the old branch as current with this checkout's worktreePath after a git switch made outside T3 Code, so the branch picker treats that branch as an existing worktree and skips the checkout. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index d46666cb468d0..e073c704ee138 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -722,6 +722,26 @@ it.effect("reads a branch whose name starts with a parenthesis as attached", () ).pipe(Effect.provide(layerTest)), ); +it.effect("follows a checkout made outside the driver after refs were cached", () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["branch", "feature/outside"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.listRefs({ cwd }); + + yield* git(cwd, ["switch", "feature/outside"]); + + const refs = (yield* driver.listRefs({ cwd })).refs; + const previous = refs.find((ref) => ref.name === initialBranch); + assert.isTrue(refs.find((ref) => ref.name === "feature/outside")?.current); + assert.isFalse(previous?.current); + assert.isNull(previous?.worktreePath ?? null); + }), + ).pipe(Effect.provide(layerTest)), +); + it.effect.each(["on a branch", "detached", "unborn"] as const)( "reads only the repository root for review requests that ignore the branch when %s", (state) => From 9ea9ffdb25ccdcb23d5f5f5b6eb4526ee9d08f41 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 19:57:42 +0000 Subject: [PATCH 2/2] fix(web): picking the previous branch after an outside checkout switches to it After a checkout made outside the picker (an agent running `git switch`), listRefs kept serving the old branch as current with this checkout's worktreePath from its caches, and the picker treated that as "reuse the existing worktree": it recorded the branch on the thread and skipped the checkout, with no error. listRefs now reads the checkout's branch on every call and applies it over the cached snapshot, so the previous branch no longer claims this checkout. The picker also stops trusting a listed claim on the thread's own checkout: it runs the checkout there (a no-op when the list was right) and shows the error if Git refuses, which covers refs fetched before the outside checkout. Fixes pingdotgg/t3code#32029 Co-Authored-By: Claude Fable 5.2 --- apps/server/src/vcs/GitVcsDriverCore.ts | 63 +++++++++++-------- .../components/BranchToolbar.logic.test.ts | 5 ++ .../web/src/components/BranchToolbar.logic.ts | 7 ++- .../BranchToolbarBranchSelector.tsx | 5 +- 4 files changed, 51 insertions(+), 29 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 32042a0ff5e5f..bb84cda7d75a5 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1495,23 +1495,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : null; }); + const readCurrentBranch = (operation: string, cwd: string) => + executeGit(operation, cwd, ["symbolic-ref", "--quiet", "HEAD"], { + timeoutMs: 5_000, + allowNonZeroExit: true, + }).pipe( + Effect.map((result) => (result.exitCode === 0 ? branchNameFromHeadRef(result.stdout) : null)), + ); + const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* ( cwd: string, ) { const gitCommonDir = yield* readGitCommonDir(cwd); if (gitCommonDir === null) return null; - const [worktreeRoot, currentBranchResult] = yield* Effect.all( + const [worktreeRoot, currentBranch] = yield* Effect.all( [ readWorktreeRoot(cwd), - executeGit( - "GitVcsDriver.resolveRepositoryPaths.currentBranch", - cwd, - ["symbolic-ref", "--quiet", "HEAD"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ), + readCurrentBranch("GitVcsDriver.resolveRepositoryPaths.currentBranch", cwd), ], { concurrency: 2 }, ); @@ -1523,8 +1523,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((physicalCwd) => path.relative(worktreeRoot, physicalCwd) === ""), Effect.orElseSucceed(() => undefined), ); - const currentBranch = - currentBranchResult.exitCode === 0 ? branchNameFromHeadRef(currentBranchResult.stdout) : null; return { gitCommonDir, @@ -4448,22 +4446,33 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; } - const snapshot = yield* resolveListRefsSnapshot( - repositoryPaths.gitCommonDir, - input.refresh === true, + // Both caches outlive a checkout made outside T3 Code, so this checkout's branch is read + // on every call and replaces whatever the cached snapshot claims for it. + const [snapshot, currentBranch] = yield* Effect.all( + [ + resolveListRefsSnapshot(repositoryPaths.gitCommonDir, input.refresh === true), + readCurrentBranch("GitVcsDriver.listRefs.currentBranch", input.cwd).pipe( + Effect.orElseSucceed(() => repositoryPaths.currentBranch), + ), + ], + { concurrency: 2 }, ); - const hasCurrentWorktreeBranch = - repositoryPaths.worktreeRoot !== null && - snapshot.localBranches.some((ref) => ref.worktreePath === repositoryPaths.worktreeRoot); - const localBranches = snapshot.localBranches.map((ref) => ({ - ...ref, - worktreePath: - ref.worktreePath ?? - (ref.name === repositoryPaths.currentBranch ? repositoryPaths.worktreeRoot : null), - current: hasCurrentWorktreeBranch - ? ref.worktreePath === repositoryPaths.worktreeRoot - : ref.name === repositoryPaths.currentBranch, - })); + const { worktreeRoot } = repositoryPaths; + const localBranches = snapshot.localBranches.map((ref) => { + const current = ref.name === currentBranch; + return { + ...ref, + worktreePath: + worktreeRoot === null + ? ref.worktreePath + : current + ? worktreeRoot + : ref.worktreePath === worktreeRoot + ? null + : ref.worktreePath, + current, + }; + }); const combinedBranches = input.includeMatchingRemoteRefs ? [...localBranches, ...snapshot.remoteBranches] : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...snapshot.remoteBranches]); diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index d5b74f1cb1168..4049031b57c43 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -192,6 +192,7 @@ describe("resolveBranchSelectionTarget", () => { checkoutCwd: "/repo", nextWorktreePath: "/repo", reuseExistingWorktree: true, + staysInActiveCheckout: false, }); expect( resolveBranchSelectionTarget({ @@ -206,6 +207,7 @@ describe("resolveBranchSelectionTarget", () => { checkoutCwd: "/repo/.t3/worktrees/feature-b", nextWorktreePath: "/repo/.t3/worktrees/feature-b", reuseExistingWorktree: true, + staysInActiveCheckout: false, }); }); @@ -233,6 +235,8 @@ describe("resolveBranchSelectionTarget", () => { checkoutCwd: "/repo", nextWorktreePath: null, reuseExistingWorktree: true, + // A ref listed in the thread's own checkout may be stale, so it is checked out, not assumed. + staysInActiveCheckout: activeWorktreePath === null, }); } }); @@ -251,6 +255,7 @@ describe("resolveBranchSelectionTarget", () => { checkoutCwd: "/repo/.t3/worktrees/feature-a", nextWorktreePath: "/repo/.t3/worktrees/feature-a", reuseExistingWorktree: false, + staysInActiveCheckout: true, }); }); }); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 5dec87e3d73d7..274ec098f2f11 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -257,6 +257,7 @@ export function resolveBranchSelectionTarget(input: { checkoutCwd: string; nextWorktreePath: string | null; reuseExistingWorktree: boolean; + staysInActiveCheckout: boolean; } { const { activeProjectCwd, activeWorktreePath, refName } = input; @@ -266,10 +267,13 @@ export function resolveBranchSelectionTarget(input: { input.activeProjectCheckoutRoot === undefined ? workspaceRelativeFilePath(activeProjectCwd, refName.worktreePath) !== null : workspaceRelativeFilePath(input.activeProjectCheckoutRoot, refName.worktreePath) === "."; + const nextWorktreePath = isProjectCheckout ? null : refName.worktreePath; return { checkoutCwd: refName.worktreePath, - nextWorktreePath: isProjectCheckout ? null : refName.worktreePath, + nextWorktreePath, reuseExistingWorktree: true, + // Listed refs can predate a checkout made outside the picker, so only Git can confirm this one. + staysInActiveCheckout: nextWorktreePath === activeWorktreePath, }; } @@ -280,6 +284,7 @@ export function resolveBranchSelectionTarget(input: { checkoutCwd: nextWorktreePath ?? activeProjectCwd, nextWorktreePath, reuseExistingWorktree: false, + staysInActiveCheckout: nextWorktreePath === activeWorktreePath, }; } diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 2b59c126e2a6b..7bb3ceb08eae7 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -445,7 +445,10 @@ export function BranchToolbarBranchSelector({ ) return; - if (selectionTarget.reuseExistingWorktree) { + if ( + selectionTarget.reuseExistingWorktree && + !(selectionTarget.staysInActiveCheckout && canWriteSourceControl) + ) { setThreadBranch(refName.name, selectionTarget.nextWorktreePath); setIsBranchMenuOpen(false); onComposerFocusRequest?.();