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) => 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?.();