Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/web/src/components/BranchToolbar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
resolveDraftEnvModeAfterBranchChange,
resolveEffectiveEnvMode,
resolveBranchToolbarPrBranch,
resolveConfirmedSelectedRef,
resolveDefaultWorktreeBaseBranch,
resolveLocalCheckoutBranchMismatch,
resolveLockedWorkspaceLabel,
Expand Down Expand Up @@ -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,
);
});
});
14 changes: 14 additions & 0 deletions apps/web/src/components/BranchToolbar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<VcsRef> } | 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;
Expand Down
43 changes: 34 additions & 9 deletions apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
useEffectEvent,
useImperativeHandle,
useOptimistic,
useRef,
useState,
useTransition,
type MouseEvent as ReactMouseEvent,
Expand Down Expand Up @@ -53,6 +54,7 @@ import {
resolveBranchToolbarPrBranch,
resolveBranchSelectionTarget,
resolveBranchToolbarValue,
resolveConfirmedSelectedRef,
resolveDefaultWorktreeBaseBranch,
resolveDraftEnvModeAfterBranchChange,
resolveEffectiveEnvMode,
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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<VcsRef> => {
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
Comment thread
Yuruzuu marked this conversation as resolved.
Resolved
? 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,
Expand All @@ -455,18 +483,13 @@ export function BranchToolbarBranchSelector({
!(selectionTarget.staysInActiveCheckout && canWriteSourceControl)
) {
setThreadBranch(refName.name, selectionTarget.nextWorktreePath);
setIsBranchMenuOpen(false);
onComposerFocusRequest?.();
return;
}

const selectedBranchName = refName.isRemote
? deriveLocalBranchNameFromRemoteRef(refName.name)
: refName.name;

setIsBranchMenuOpen(false);
onComposerFocusRequest?.();

runBranchAction(async () => {
const previousBranch = resolvedActiveBranch;
setOptimisticBranch(selectedBranchName);
Expand Down Expand Up @@ -531,6 +554,7 @@ export function BranchToolbarBranchSelector({
if (!canChangeThreadBranch) return;
const name = sanitizeNewRefName(rawName);
if (!branchCwd || !name || isBranchActionPending) return;
latestSelectionIdRef.current++;

setIsBranchMenuOpen(false);
onComposerFocusRequest?.();
Expand Down Expand Up @@ -667,14 +691,15 @@ export function BranchToolbarBranchSelector({

function selectPickerItem(itemValue: string) {
if (itemValue === checkoutPullRequestItemValue && prReference && onCheckoutPullRequestRequest) {
latestSelectionIdRef.current++;
handleOpenChange(false);
onComposerFocusRequest?.();
onCheckoutPullRequestRequest(prReference);
} else if (itemValue === createBranchItemValue) {
createRef(trimmedBranchQuery);
} else {
const refName = branchByName.get(itemValue);
if (refName) selectBranch(refName);
if (refName) void selectBranch(refName);
}
}

Expand Down
Loading