diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 143b0a59b7143..99ae0bda81bba 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -8,6 +8,7 @@ import { resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, resolveBranchToolbarPrBranch, + resolveConfirmedSelectedRef, resolveDefaultWorktreeBaseBranch, resolveLocalCheckoutBranchMismatch, resolveLockedWorkspaceLabel, @@ -406,3 +407,28 @@ describe("sanitizeNewRefName", () => { expect(sanitizeNewRefName("foo--bar")).toBe("foo--bar"); }); }); + +describe("resolveConfirmedSelectedRef", () => { + const listed: VcsRef = { + name: "previous", + isRemote: false, + current: false, + isDefault: false, + worktreePath: "/worktree-b", + }; + + it.each([ + [ + "the claimed worktree still has it checked out", + [{ ...listed, current: true }], + "/worktree-b", + ], + ["the claimed worktree has moved to another branch", [], null], + ["the claimed worktree lists it but not as current", [listed], null], + ["the read failed", null, null], + ] as const)("%s", (_label, refs, expected) => { + expect(resolveConfirmedSelectedRef(listed, refs === null ? null : { refs }).worktreePath).toBe( + expected, + ); + }); +}); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 78b1bff4aff4d..f47ddd63348b2 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -288,6 +288,20 @@ export function resolveLocalCheckoutBranchMismatch(input: { return { threadBranch: activeThreadBranch, currentBranch: currentGitBranch }; } +/** + * Keeps a picked ref's worktree claim only when a read in that worktree finds it checked out. + * `freshRead` is `null` when the read failed. + */ +export function resolveConfirmedSelectedRef( + listedRef: VcsRef, + freshRead: { readonly refs: ReadonlyArray } | null, +): VcsRef { + const checkedOut = + freshRead?.refs.some((ref) => ref.name === listedRef.name && !ref.isRemote && ref.current) ?? + false; + return checkedOut ? listedRef : { ...listedRef, worktreePath: null }; +} + export function resolveBranchSelectionTarget(input: { activeProjectCwd: string; activeProjectCheckoutRoot?: string | undefined; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 02fa9de2e716c..535a075202019 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -20,6 +20,7 @@ import { useEffectEvent, useImperativeHandle, useOptimistic, + useRef, useState, useTransition, type MouseEvent as ReactMouseEvent, @@ -53,6 +54,7 @@ import { resolveBranchToolbarPrBranch, resolveBranchSelectionTarget, resolveBranchToolbarValue, + resolveConfirmedSelectedRef, resolveDefaultWorktreeBaseBranch, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, @@ -130,6 +132,9 @@ export function BranchToolbarBranchSelector({ const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false, }); + const readRefs = useAtomCommand(vcsEnvironment.readRefs, { + reportFailure: false, + }); const refreshLocalStatusForPicker = useAtomCommand(vcsEnvironment.refreshLocalStatusForPicker, { reportFailure: false, }); @@ -423,17 +428,40 @@ export function BranchToolbarBranchSelector({ }); }; - const selectBranch = (refName: VcsRef) => { + // The picker's list, and the server's coalesced ref listing, can predate a checkout an agent + // ran itself. Only the claimed worktree's live HEAD confirms the claim; an unconfirmed one + // falls back to a real checkout, which Git refuses if the branch is checked out elsewhere. + const latestSelectionIdRef = useRef(0); + const readSelectedRef = async (worktreePath: string, listedRef: VcsRef): Promise => { + const result = await readRefs({ + environmentId, + input: { cwd: worktreePath, query: listedRef.name, refKind: "local", limit: 1 }, + }); + return resolveConfirmedSelectedRef(listedRef, result._tag === "Success" ? result.value : null); + }; + + const selectBranch = async (listedRef: VcsRef) => { if (!canUpdateThreadBranch || !branchCwd || !activeProjectCwd || isBranchActionPending) return; + const selectionId = ++latestSelectionIdRef.current; - const latest = readLatestThreadState(); + let latest = readLatestThreadState(); if (isSelectingWorktreeBase) { if (latest.worktreePath) return; - setThreadBranch(refName.name, null); + setThreadBranch(listedRef.name, null); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; } + + setIsBranchMenuOpen(false); + onComposerFocusRequest?.(); + + const refName = listedRef.worktreePath + ? await readSelectedRef(listedRef.worktreePath, listedRef) + : listedRef; + // A later pick made while this one was confirming its worktree wins. + if (selectionId !== latestSelectionIdRef.current) return; + latest = readLatestThreadState(); const selectionTarget = resolveBranchSelectionTarget({ activeProjectCwd, activeProjectCheckoutRoot, @@ -455,8 +483,6 @@ export function BranchToolbarBranchSelector({ !(selectionTarget.staysInActiveCheckout && canWriteSourceControl) ) { setThreadBranch(refName.name, selectionTarget.nextWorktreePath); - setIsBranchMenuOpen(false); - onComposerFocusRequest?.(); return; } @@ -464,9 +490,6 @@ export function BranchToolbarBranchSelector({ ? deriveLocalBranchNameFromRemoteRef(refName.name) : refName.name; - setIsBranchMenuOpen(false); - onComposerFocusRequest?.(); - runBranchAction(async () => { const previousBranch = resolvedActiveBranch; setOptimisticBranch(selectedBranchName); @@ -531,6 +554,7 @@ export function BranchToolbarBranchSelector({ if (!canChangeThreadBranch) return; const name = sanitizeNewRefName(rawName); if (!branchCwd || !name || isBranchActionPending) return; + latestSelectionIdRef.current++; setIsBranchMenuOpen(false); onComposerFocusRequest?.(); @@ -667,6 +691,7 @@ export function BranchToolbarBranchSelector({ function selectPickerItem(itemValue: string) { if (itemValue === checkoutPullRequestItemValue && prReference && onCheckoutPullRequestRequest) { + latestSelectionIdRef.current++; handleOpenChange(false); onComposerFocusRequest?.(); onCheckoutPullRequestRequest(prReference); @@ -674,7 +699,7 @@ export function BranchToolbarBranchSelector({ createRef(trimmedBranchQuery); } else { const refName = branchByName.get(itemValue); - if (refName) selectBranch(refName); + if (refName) void selectBranch(refName); } }