From ca8280c0a81e91ec7be9b4bddb495c54ce1c8104 Mon Sep 17 00:00:00 2001 From: matheustimbo Date: Mon, 26 Oct 2026 22:53:06 -0300 Subject: [PATCH] fix(web): don't seed the worktree base from an unborn HEAD While a clone is still running, the repository has an unborn HEAD that points at init.defaultBranch and no origin/HEAD yet. The branch picker took that name as the worktree base and kept it after the clone finished, so the first thread failed with ref_not_found. Only fall back to the current branch when it is a listed ref. Fixes #25719 Co-Authored-By: Claude Opus 5.5 --- .../components/BranchToolbar.logic.test.ts | 51 +++++++++++++++++++ .../web/src/components/BranchToolbar.logic.ts | 17 +++++++ .../BranchToolbarBranchSelector.tsx | 10 ++-- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index d1ca6d01e4c3..7e7e225e67c7 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -8,6 +8,7 @@ import { resolveEffectiveEnvMode, resolveBranchToolbarPrBranch, resolveLocalCheckoutBranchMismatch, + resolveWorktreeBaseBranchCandidate, sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; @@ -254,3 +255,53 @@ describe("sanitizeNewRefName", () => { expect(sanitizeNewRefName("foo--bar")).toBe("foo--bar"); }); }); + +describe("resolveWorktreeBaseBranchCandidate", () => { + const ref = (name: string, overrides: Partial = {}): VcsRef => ({ + name, + current: false, + isDefault: false, + worktreePath: null, + ...overrides, + }); + + it("waits for the initial ref list", () => { + expect( + resolveWorktreeBaseBranchCandidate({ + isInitialBranchesLoadPending: true, + refs: [], + currentGitBranch: "staging", + }), + ).toBeNull(); + }); + + it("prefers the default branch", () => { + expect( + resolveWorktreeBaseBranchCandidate({ + isInitialBranchesLoadPending: false, + refs: [ref("feature", { current: true }), ref("staging", { isDefault: true })], + currentGitBranch: "feature", + }), + ).toBe("staging"); + }); + + it("falls back to the current branch when it is a listed ref", () => { + expect( + resolveWorktreeBaseBranchCandidate({ + isInitialBranchesLoadPending: false, + refs: [ref("develop", { current: true })], + currentGitBranch: "develop", + }), + ).toBe("develop"); + }); + + it("ignores an unborn HEAD while a clone is still running", () => { + expect( + resolveWorktreeBaseBranchCandidate({ + isInitialBranchesLoadPending: false, + refs: [], + currentGitBranch: "main", + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 01b9155617a4..560be9fe4b9e 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -200,6 +200,23 @@ export function resolveBranchToolbarValue(input: { return currentGitBranch ?? activeThreadBranch; } +// A repository whose HEAD is unborn (e.g. while a clone is still running) reports its +// init.defaultBranch as the current branch even though no such ref exists yet. +export function resolveWorktreeBaseBranchCandidate(input: { + isInitialBranchesLoadPending: boolean; + refs: ReadonlyArray; + currentGitBranch: string | null; +}): string | null { + const { isInitialBranchesLoadPending, refs, currentGitBranch } = input; + if (isInitialBranchesLoadPending) return null; + const defaultBranchName = refs.find((refName) => refName.isDefault)?.name ?? null; + if (defaultBranchName) return defaultBranchName; + if (currentGitBranch && refs.some((refName) => refName.name === currentGitBranch)) { + return currentGitBranch; + } + return null; +} + export function resolveBranchTriggerLabel(input: { activeWorktreePath: string | null; effectiveEnvMode: EnvMode; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 60eef31b1b63..19328d3967fd 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -53,6 +53,7 @@ import { resolveBranchToolbarValue, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, + resolveWorktreeBaseBranchCandidate, sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; @@ -252,7 +253,6 @@ export function BranchToolbarBranchSelector({ const createBranchItemValue = canCreateBranch ? `__create_new_branch__:${trimmedBranchQuery}` : null; - const defaultBranchName = refs.find((refName) => refName.isDefault)?.name ?? null; const pickerCreateBranchItemValue = hasExactBranchMatch ? null : createBranchItemValue; const branchPickerItems = [ ...(checkoutPullRequestItemValue ? [checkoutPullRequestItemValue] : []), @@ -478,9 +478,11 @@ export function BranchToolbarBranchSelector({ }); }; - const worktreeBaseBranchCandidate = isInitialBranchesLoadPending - ? null - : (defaultBranchName ?? currentGitBranch); + const worktreeBaseBranchCandidate = resolveWorktreeBaseBranchCandidate({ + isInitialBranchesLoadPending, + refs, + currentGitBranch, + }); // The server sets started threads' branches; defaulting here would detach the agent from its worktree. const threadStarted = serverSession !== null;