Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 23 additions & 0 deletions apps/web/src/components/BranchToolbar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,29 @@ describe("resolveBranchToolbarValue", () => {
}),
).toBe("main");
});

it("falls back to the thread branch while local status is still loading", () => {
expect(
resolveBranchToolbarValue({
envMode: "local",
activeWorktreePath: null,
activeThreadBranch: "feature/base",
currentGitBranch: null,
}),
).toBe("feature/base");
});

it("does not cling to a deleted thread branch when the checkout is detached", () => {
expect(
resolveBranchToolbarValue({
envMode: "local",
activeWorktreePath: null,
activeThreadBranch: "throwaway/repro-error",
currentGitBranch: null,
isDetachedCheckout: true,
}),
).toBeNull();
});
});

describe("resolveEnvironmentOptionLabel", () => {
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/components/BranchToolbar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,22 @@ export function resolveBranchToolbarValue(input: {
activeWorktreePath: string | null;
activeThreadBranch: string | null;
currentGitBranch: string | null;
/**
* True when git authoritatively reports the checkout is on no branch
* (detached HEAD) — e.g. the thread's branch was deleted out from under us.
* In that case the local-mode trigger must reflect reality instead of
* clinging to the now-dangling thread branch.
*/
isDetachedCheckout?: boolean;
}): string | null {
const { envMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input;
const { envMode, activeWorktreePath, activeThreadBranch, currentGitBranch, isDetachedCheckout } =
input;
if (envMode === "worktree" && !activeWorktreePath) {
return activeThreadBranch ?? currentGitBranch;
}
if (isDetachedCheckout) {
return currentGitBranch;
}
return currentGitBranch ?? activeThreadBranch;
}

Expand Down
15 changes: 13 additions & 2 deletions apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {

import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
import { readEnvironmentApi } from "../environmentApi";
import { useVcsStatus } from "../lib/vcsStatusState";
import { refreshVcsStatus, useVcsStatus } from "../lib/vcsStatusState";
import { useVcsRefs, vcsRefManager } from "../lib/vcsRefState";
import { newCommandId } from "../lib/utils";
import { cn } from "../lib/utils";
Expand Down Expand Up @@ -220,6 +220,11 @@ export function BranchToolbarBranchSelector({
const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null;
const currentGitBranch =
branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null;
// Git reports an in-repo checkout with no branch (detached HEAD) — e.g. the
// thread's branch was deleted externally. Only trust this once status data has
// loaded, so we don't flash "Select ref" while the initial status is pending.
const isDetachedCheckout =
branchStatusQuery.data?.isRepo === true && (branchStatusQuery.data?.refName ?? null) === null;
const sourceControlPresentation = useMemo(
() => getSourceControlPresentation(branchStatusQuery.data?.sourceControlProvider),
[branchStatusQuery.data?.sourceControlProvider],
Expand All @@ -230,6 +235,7 @@ export function BranchToolbarBranchSelector({
activeWorktreePath,
activeThreadBranch,
currentGitBranch,
isDetachedCheckout,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale refs defeat detached toolbar label

Medium Severity

Detached checkout is detected from loaded VCS status (refName is null), but currentGitBranch still substitutes the refs list when status returns that null via ??. The detached branch of resolveBranchToolbarValue then returns that ref-derived name, so the toolbar can keep showing a deleted or stale branch instead of Select ref.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit edec0b7. Configure here.

});
const branchNames = useMemo(() => refs.map((refName) => refName.name), [refs]);
const branchByName = useMemo(
Expand Down Expand Up @@ -300,6 +306,7 @@ export function BranchToolbarBranchSelector({
await vcsRefManager
.load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true })
.catch(() => undefined);
void refreshVcsStatus({ environmentId, cwd: branchCwd });
});
};

Expand Down Expand Up @@ -414,11 +421,15 @@ export function BranchToolbarBranchSelector({
setBranchQuery("");
return;
}
// Reload refs AND status together: opening the picker is the moment to
// reconcile with reality, including branch changes made outside the app
// (the status stream has no .git watcher, so it won't push those on its own).
void vcsRefManager
.load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true })
.catch(() => undefined);
void refreshVcsStatus({ environmentId, cwd: branchCwd });
},
[branchRefTarget],
[branchCwd, branchRefTarget, environmentId],
);

const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);
Expand Down
Loading