diff --git a/src/renderer/components/common/BranchSelector/parts/BranchListBox.test.tsx b/src/renderer/components/common/BranchSelector/parts/BranchListBox.test.tsx index d04a1e589..e08d75244 100644 --- a/src/renderer/components/common/BranchSelector/parts/BranchListBox.test.tsx +++ b/src/renderer/components/common/BranchSelector/parts/BranchListBox.test.tsx @@ -116,6 +116,33 @@ describe("BranchListBox", () => { expect(props.onSelect).not.toHaveBeenCalled(); }); + it("highlights the local row when the worktree base is the origin ref", () => { + const props = baseProps(); + const branch = { + name: "main", + current: true, + commit: "abc123", + isRemote: false, + }; + + render( + , + ); + + expect(screen.getByRole("option", { name: "main" })).toHaveAttribute("aria-selected", "true"); + }); + it("marks worktree branches with a fork icon and a thread-count badge", () => { const props = baseProps(); const branch = { diff --git a/src/renderer/components/common/BranchSelector/parts/BranchListBox.tsx b/src/renderer/components/common/BranchSelector/parts/BranchListBox.tsx index c624dd35c..795537951 100644 --- a/src/renderer/components/common/BranchSelector/parts/BranchListBox.tsx +++ b/src/renderer/components/common/BranchSelector/parts/BranchListBox.tsx @@ -29,6 +29,7 @@ import { import { PixelLoader } from "../../PixelLoader"; import { useResponsiveMenu } from "../../ResponsiveMenuSurface"; import type { BranchListItem } from "./useBranchList"; +import { localBranchNameFromRef } from "./worktreeBaseRef"; const STATUS_DOT_CLASS: Record = { inactive: "bg-muted/40", @@ -101,7 +102,11 @@ export function BranchListBox(props: { ); } - const selectedKey = isWorktree || worktreeMode ? (baseBranch ?? value) : value; + const selectedRef = isWorktree || worktreeMode ? (baseBranch ?? value) : value; + const listedBranches = items.flatMap((item) => (item.type === "branch" ? [item.branch] : [])); + const selectedKey = items.some((item) => item.type === "branch" && item.id === selectedRef) + ? selectedRef + : localBranchNameFromRef(selectedRef, listedBranches); return ( @@ -109,9 +114,7 @@ export function BranchListBox(props: { aria-label={t`Branches`} className={`poracode-menu max-h-60 overflow-y-auto ${mobile ? "" : VIRTUALIZED_COMPACT_DROPDOWN_ITEM_CLASS}`} items={items} - selectedKeys={ - isWorktree || worktreeMode ? new Set([baseBranch ?? value]) : new Set([value]) - } + selectedKeys={new Set([selectedKey])} selectionMode="single" disallowEmptySelection onSelectionChange={(keys) => { diff --git a/src/renderer/components/common/BranchSelector/parts/useBranchList.ts b/src/renderer/components/common/BranchSelector/parts/useBranchList.ts index 7bc658538..a3039dc2b 100644 --- a/src/renderer/components/common/BranchSelector/parts/useBranchList.ts +++ b/src/renderer/components/common/BranchSelector/parts/useBranchList.ts @@ -69,12 +69,27 @@ export function useBranchList(params: { projectId: string; search: string }) { } } + const remoteQualifiedNames = new Set( + allBranches + .filter((branch) => branch.isRemote && branch.remote) + .map((branch) => `${branch.remote}/${branch.name}`), + ); const normalizedSearch = search.trim().toLowerCase(); const allLocal: GitBranchInfo[] = []; const allRemote: GitBranchInfo[] = []; for (const branch of deduped) { - if (normalizedSearch && !branch.name.toLowerCase().includes(normalizedSearch)) { - continue; + if (normalizedSearch) { + const qualified = + branch.isRemote && branch.remote ? `${branch.remote}/${branch.name}` : undefined; + const originAlias = + !branch.isRemote && remoteQualifiedNames.has(`origin/${branch.name}`) + ? `origin/${branch.name}` + : undefined; + const haystack = [branch.name, qualified, originAlias] + .filter(Boolean) + .join(" ") + .toLowerCase(); + if (!haystack.includes(normalizedSearch)) continue; } if (branch.isRemote) { allRemote.push(branch); diff --git a/src/renderer/components/common/BranchSelector/parts/worktreeBaseRef.test.ts b/src/renderer/components/common/BranchSelector/parts/worktreeBaseRef.test.ts new file mode 100644 index 000000000..bf6cce29b --- /dev/null +++ b/src/renderer/components/common/BranchSelector/parts/worktreeBaseRef.test.ts @@ -0,0 +1,76 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import type { GitBranchInfo } from "@/shared/contracts"; +import { + isCurrentCheckoutRef, + localBranchNameFromRef, + resolveWorktreeOriginRef, +} from "./worktreeBaseRef"; + +function local(name: string): GitBranchInfo { + return { name, current: name === "master", commit: "abc", isRemote: false }; +} + +function remote(name: string, remoteName = "origin"): GitBranchInfo { + return { name, current: false, commit: "def", isRemote: true, remote: remoteName }; +} + +describe("localBranchNameFromRef", () => { + it("strips a known origin prefix even without a branch list", () => { + expect(localBranchNameFromRef("origin/master")).toBe("master"); + expect(localBranchNameFromRef("origin/feature/x")).toBe("feature/x"); + }); + + it("uses the remote field when the listed remote is not origin", () => { + expect(localBranchNameFromRef("upstream/release", [remote("release", "upstream")])).toBe( + "release", + ); + }); + + it("leaves a local name unchanged", () => { + expect(localBranchNameFromRef("master", [local("master")])).toBe("master"); + }); +}); + +describe("resolveWorktreeOriginRef", () => { + it("keeps an already-qualified remote ref", () => { + expect(resolveWorktreeOriginRef("origin/master", [local("master"), remote("master")])).toBe( + "origin/master", + ); + }); + + it("maps a local name to its tracking ref", () => { + expect( + resolveWorktreeOriginRef("master", [local("master"), remote("master")], "origin/master"), + ).toBe("origin/master"); + }); + + it("maps a local name to origin even without status.tracking when the remote exists", () => { + expect(resolveWorktreeOriginRef("feature/x", [local("feature/x"), remote("feature/x")])).toBe( + "origin/feature/x", + ); + }); + + it("uses tracking when the branch list has not loaded yet", () => { + expect(resolveWorktreeOriginRef("master", [], "origin/master")).toBe("origin/master"); + }); + + it("falls back to the local name when no remote counterpart exists", () => { + expect(resolveWorktreeOriginRef("wip", [local("wip")])).toBe("wip"); + }); + + it("prefers a non-origin remote when that is the only match", () => { + expect( + resolveWorktreeOriginRef("release", [local("release"), remote("release", "upstream")]), + ).toBe("upstream/release"); + }); +}); + +describe("isCurrentCheckoutRef", () => { + it("matches the local checkout or its tracking ref", () => { + expect(isCurrentCheckoutRef("master", "master", "origin/master")).toBe(true); + expect(isCurrentCheckoutRef("origin/master", "master", "origin/master")).toBe(true); + expect(isCurrentCheckoutRef("develop", "master", "origin/master")).toBe(false); + }); +}); diff --git a/src/renderer/components/common/BranchSelector/parts/worktreeBaseRef.ts b/src/renderer/components/common/BranchSelector/parts/worktreeBaseRef.ts new file mode 100644 index 000000000..b2388e387 --- /dev/null +++ b/src/renderer/components/common/BranchSelector/parts/worktreeBaseRef.ts @@ -0,0 +1,60 @@ +import type { GitBranchInfo } from "@/shared/contracts"; + +export function qualifiedRemoteName(branch: GitBranchInfo): string | undefined { + if (!branch.isRemote || !branch.remote) return undefined; + return `${branch.remote}/${branch.name}`; +} + +/** Map `origin/main` (or another remote-qualified ref) back to the local short name. */ +export function localBranchNameFromRef( + ref: string, + branches: readonly GitBranchInfo[] = [], +): string { + const remoteMatch = branches.find((branch) => qualifiedRemoteName(branch) === ref); + if (remoteMatch) return remoteMatch.name; + if (ref.startsWith("origin/")) return ref.slice("origin/".length); + return ref; +} + +/** + * Worktree (no changes) should fork from the origin-tracking ref when one + * exists — same as T3's start-from-origin default. Selecting "main" in the + * picker must stay on `origin/main`, not flip to the local checkout. + */ +export function resolveWorktreeOriginRef( + branchName: string, + branches: readonly GitBranchInfo[] = [], + tracking?: string | null, +): string { + if (branches.some((branch) => qualifiedRemoteName(branch) === branchName)) { + return branchName; + } + + const localName = localBranchNameFromRef(branchName, branches); + + if (tracking) { + const trackingLocal = localBranchNameFromRef(tracking, branches); + if (trackingLocal === localName) return tracking; + } + + const origin = branches.find( + (branch) => + branch.isRemote && (branch.remote ?? "origin") === "origin" && branch.name === localName, + ); + const originName = origin ? qualifiedRemoteName(origin) : undefined; + if (originName) return originName; + + const anyRemote = branches.find( + (branch) => branch.isRemote && branch.name === localName && branch.remote, + ); + return (anyRemote ? qualifiedRemoteName(anyRemote) : undefined) ?? localName; +} + +export function isCurrentCheckoutRef( + ref: string, + currentBranch: string | undefined, + tracking?: string | null, +): boolean { + if (!currentBranch) return false; + return ref === currentBranch || (tracking != null && ref === tracking); +} diff --git a/src/renderer/components/thread/ThreadDraftComposerArea.tsx b/src/renderer/components/thread/ThreadDraftComposerArea.tsx index cba8387b0..c8f92818e 100644 --- a/src/renderer/components/thread/ThreadDraftComposerArea.tsx +++ b/src/renderer/components/thread/ThreadDraftComposerArea.tsx @@ -5,6 +5,7 @@ import { Trans, useLingui } from "@lingui/react/macro"; import type { AgentHookPluginStatus, AgentStatus, + GitBranchInfo, Project, PromptSegment, ThreadConfig, @@ -92,6 +93,13 @@ import { import { useKeybindingStore } from "@/renderer/commands/keybindingStore"; import { handleComposerControlShortcut } from "./threadComposerShortcuts"; import { WorktreeModeSelect, type WorktreeMode } from "./WorktreeModeSelect"; +import { + isCurrentCheckoutRef, + localBranchNameFromRef, + resolveWorktreeOriginRef, +} from "@/renderer/components/common/BranchSelector/parts/worktreeBaseRef"; + +const EMPTY_BRANCHES: GitBranchInfo[] = []; // Optional fields admit explicit `undefined` so wire shapes with // `prop?: T | undefined` (e.g. the zod-parsed quick-composer submission) @@ -463,14 +471,15 @@ export function ThreadDraftComposerArea(props: { // changes" affordance only appears when the new worktree forks from the // current (dirty) checkout — the only case where transferring is meaningful. const projectStatus = useGitStore((s) => s.statuses[props.project.id]); + const projectBranches = useGitStore( + (s) => s.branches[props.project.id]?.branches ?? EMPTY_BRANCHES, + ); const hasUncommittedChanges = !!projectStatus && projectStatus.staged.length + projectStatus.unstaged.length > 0; const trackingWorktreeBase = projectStatus && props.gitBranch && projectStatus.branch === props.gitBranch && - projectStatus.behind > 0 && - projectStatus.ahead === 0 && projectStatus.tracking ? projectStatus.tracking : undefined; @@ -497,6 +506,10 @@ export function ThreadDraftComposerArea(props: { ? "new-with-changes" : "new"; + function resolveOriginBase(branchName: string): string { + return resolveWorktreeOriginRef(branchName, projectBranches, projectStatus?.tracking); + } + function selectNewWorktree(overrides?: Partial) { const base = overrides?.baseBranch ?? worktreeBase ?? props.gitBranch ?? ""; setBranchSelection({ branch: base, baseBranch: base, isWorktree: true, ...overrides }); @@ -512,13 +525,44 @@ export function ThreadDraftComposerArea(props: { // Keep an existing worktree selection (e.g. a worktreePath from "New thread // in worktree") intact rather than rebuilding it into a brand-new branch. if (branchSelection?.worktreePath) return; - const baseBranch = mode === "new-with-changes" ? props.gitBranch : defaultWorktreeBase; + // Worktree + changes must fork from the local checkout so uncommitted + // files can be copied. Plain worktree uses the origin ref (T3-style). + const localBase = props.gitBranch; + const originBase = defaultWorktreeBase ? resolveOriginBase(defaultWorktreeBase) : localBase; + const baseBranch = mode === "new-with-changes" ? localBase : originBase; selectNewWorktree({ ...(baseBranch ? { baseBranch } : {}), transferUncommitted: mode === "new-with-changes", }); } + function handleBranchSelect(selection: BranchSelection) { + if (selection.worktreePath || !selection.isWorktree) { + setBranchSelection(selection); + return; + } + const selected = selection.baseBranch ?? selection.branch; + const keepChanges = + (shouldTransferUncommitted || selection.transferUncommitted === true) && + isCurrentCheckoutRef(selected, props.gitBranch, projectStatus?.tracking); + if (keepChanges) { + const localName = localBranchNameFromRef(selected, projectBranches); + setBranchSelection({ + ...selection, + branch: localName, + baseBranch: localName, + transferUncommitted: true, + }); + return; + } + const originBase = resolveOriginBase(selected); + setBranchSelection({ + ...selection, + branch: originBase, + baseBranch: originBase, + }); + } + const computerUseScope = disabledBuiltInMcpServers[COMPUTER_USE_MCP_ID] === true ? "none" @@ -1150,12 +1194,11 @@ export function ThreadDraftComposerArea(props: { onToggle: (next: boolean) => { setExperimentMode(next); if (next) { - setExperimentBaseBranch( + const rawBase = branchSelection?.baseBranch ?? - branchSelection?.branch ?? - defaultWorktreeBase ?? - null, - ); + branchSelection?.branch ?? + defaultWorktreeBase; + setExperimentBaseBranch(rawBase ? resolveOriginBase(rawBase) : null); } else { setExperimentCandidates([]); setExperimentBaseBranch(null); @@ -1205,8 +1248,11 @@ export function ThreadDraftComposerArea(props: { {...(!experimentMode ? { onWorktreeModeChange: props.onWorktreeModeChange } : {})} onSelect={ experimentMode - ? (selection) => setExperimentBaseBranch(selection.baseBranch ?? selection.branch) - : setBranchSelection + ? (selection) => + setExperimentBaseBranch( + resolveOriginBase(selection.baseBranch ?? selection.branch), + ) + : handleBranchSelect } onSwitchBranch={props.onSwitchBranch} hideWorktreeToggle diff --git a/src/renderer/components/thread/ThreadDraftView.test.tsx b/src/renderer/components/thread/ThreadDraftView.test.tsx index 91d28580c..917788fa9 100644 --- a/src/renderer/components/thread/ThreadDraftView.test.tsx +++ b/src/renderer/components/thread/ThreadDraftView.test.tsx @@ -531,6 +531,88 @@ describe("ThreadDraftView", () => { expect(container.querySelector("[data-draft-worktree-row]")).toBeInTheDocument(); }); + it("defaults a new worktree to the tracking branch when local is in sync", () => { + useGitStore.setState({ + statuses: { + [project.id]: { + isRepo: true, + branch: "main", + tracking: "origin/main", + hasRemote: true, + remoteInfo: null, + ahead: 0, + behind: 0, + staged: [], + unstaged: [], + totalInsertions: 0, + totalDeletions: 0, + }, + }, + }); + + render( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "Worktree mode" })); + expect(screen.getByRole("button", { name: "Select branch" })).toHaveTextContent("origin/main"); + }); + + it("keeps the origin worktree base after selecting the matching local branch", async () => { + const onStart = vi.fn<(input: unknown) => void>(); + useGitStore.setState({ + statuses: { + [project.id]: { + isRepo: true, + branch: "main", + tracking: "origin/main", + hasRemote: true, + remoteInfo: null, + ahead: 0, + behind: 4, + staged: [], + unstaged: [], + totalInsertions: 0, + totalDeletions: 0, + }, + }, + branches: { + [project.id]: { + current: "main", + branches: [ + { name: "main", current: true, commit: "abc", isRemote: false }, + { name: "develop", current: false, commit: "ghi", isRemote: false }, + { name: "main", current: false, commit: "def", isRemote: true, remote: "origin" }, + { name: "develop", current: false, commit: "jkl", isRemote: true, remote: "origin" }, + ], + }, + }, + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Worktree mode" })); + expect(screen.getByRole("button", { name: "Select branch" })).toHaveTextContent("origin/main"); + + fireEvent.click(screen.getByRole("button", { name: "Select branch" })); + fireEvent.click(await screen.findByRole("option", { name: "develop" })); + expect(screen.getByRole("button", { name: "Select branch" })).toHaveTextContent( + "origin/develop", + ); + + fireEvent.click(screen.getByRole("button", { name: "Select branch" })); + fireEvent.click(await screen.findByRole("option", { name: "main" })); + expect(screen.getByRole("button", { name: "Select branch" })).toHaveTextContent("origin/main"); + + fireEvent.click(screen.getByText("set-prompt")); + fireEvent.click(screen.getByText("submit")); + + expect(onStart).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeBaseBranch: "origin/main", + worktreeIsNewBranch: true, + }), + ); + }); + it("defaults a new worktree to the tracking branch when the local branch is behind", () => { const onStart = vi.fn<(input: unknown) => void>(); useGitStore.setState({ @@ -611,6 +693,62 @@ describe("ThreadDraftView", () => { ); }); + it("keeps the local checkout after selecting the branch in worktree + changes", async () => { + const onStart = vi.fn<(input: unknown) => void>(); + useGitStore.setState({ + statuses: { + [project.id]: { + isRepo: true, + branch: "main", + tracking: "origin/main", + hasRemote: true, + remoteInfo: null, + ahead: 0, + behind: 4, + staged: [], + unstaged: [ + { path: "src/file.ts", status: "M", staged: false, insertions: 1, deletions: 0 }, + ], + totalInsertions: 1, + totalDeletions: 0, + }, + }, + branches: { + [project.id]: { + current: "main", + branches: [ + { name: "main", current: true, commit: "abc", isRemote: false }, + { name: "main", current: false, commit: "def", isRemote: true, remote: "origin" }, + ], + }, + }, + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Worktree mode" })); + fireEvent.click(await screen.findByRole("option", { name: /Worktree \+ changes/ })); + expect(screen.getByRole("button", { name: "Select branch" })).toHaveTextContent("main"); + + fireEvent.click(screen.getByRole("button", { name: "Select branch" })); + const localMain = await screen.findByRole("option", { name: "main" }); + expect(localMain).toHaveAttribute("aria-selected", "true"); + fireEvent.click(localMain); + fireEvent.keyDown(screen.getByPlaceholderText("Search branches..."), { key: "Escape" }); + expect(screen.getByRole("button", { name: "Select branch" })).toHaveTextContent("main"); + + fireEvent.click(screen.getByText("set-prompt")); + fireEvent.click(screen.getByText("submit")); + + expect(onStart).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeBaseBranch: "main", + worktreeIsNewBranch: true, + worktreeTransferUncommitted: true, + }), + ); + }); + it("defaults experiment worktrees to the tracking branch when the local branch is behind", async () => { useGitStore.setState({ statuses: { diff --git a/src/supervisor/git.test.ts b/src/supervisor/git.test.ts index 0351d57c8..b195f0c40 100644 --- a/src/supervisor/git.test.ts +++ b/src/supervisor/git.test.ts @@ -463,13 +463,17 @@ describe("GitService.addWorktree", () => { expect( commands.some((c) => c.includes( - "worktree add -b poracode/brave-heron " + + "worktree add --no-track -b poracode/brave-heron " + "C:\\Users\\demo\\.poracode\\worktrees\\poracode-12345678\\poracode-brave-heron " + "origin/poracode/silver-meadow-abcd", ), ), ).toBe(true); + expect(commands.some((c) => c.includes("branch --unset-upstream poracode/brave-heron"))).toBe( + true, + ); + // The recorded source branch is the qualified ref, so diff bases line up. const configCall = execFileMock.mock.calls.find( (call: unknown[]) => @@ -483,6 +487,36 @@ describe("GitService.addWorktree", () => { expect(configCall![1]).toContain("origin/poracode/silver-meadow-abcd"); }); + it("does not let a remote-tracking start point become the new branch's upstream", async () => { + mockGitCommands((args) => { + if (args[0] === "worktree" && args[1] === "add") return { stdout: "" }; + if (args[0] === "rev-parse") return { stdout: "sha\n" }; + if (args[0] === "config") return { stdout: "" }; + if (args[0] === "branch" && args[1] === "--unset-upstream") return { stdout: "" }; + return { stdout: "" }; + }); + + await new GitService().addWorktree( + location, + "C:\\Users\\demo\\.poracode\\worktrees\\poracode-12345678\\poracode-brave-heron", + "poracode/brave-heron", + true, + "origin/master", + ); + + const commands = execFileMock.mock.calls.map((c: unknown[]) => + gitSubcommandArgs(c[1] as string[]).join(" "), + ); + expect( + commands.some( + (c) => + c.startsWith("worktree add --no-track -b poracode/brave-heron") && + c.endsWith("origin/master"), + ), + ).toBe(true); + expect(commands).toContain("branch --unset-upstream poracode/brave-heron"); + }); + it("leaves a start point untouched when it resolves locally", async () => { const revParseRefs: string[] = []; mockGitCommands((args) => { @@ -508,7 +542,7 @@ describe("GitService.addWorktree", () => { ); expect( commands.some( - (c) => c.includes("worktree add -b poracode/brave-heron") && c.endsWith("main"), + (c) => c.includes("worktree add --no-track -b poracode/brave-heron") && c.endsWith("main"), ), ).toBe(true); // A resolvable start point short-circuits before any `git remote` lookup. @@ -540,7 +574,7 @@ describe("GitService.addWorktree", () => { const wtAdd = execFileMock.mock.calls .map((c: unknown[]) => gitSubcommandArgs(c[1] as string[]).join(" ")) - .find((c) => c.startsWith("worktree add -b poracode/brave-heron")); + .find((c) => c.startsWith("worktree add --no-track -b poracode/brave-heron")); expect(wtAdd?.endsWith("upstream/feature/x")).toBe(true); }); @@ -571,7 +605,7 @@ describe("GitService.addWorktree", () => { const wtAdd = execFileMock.mock.calls .map((c: unknown[]) => gitSubcommandArgs(c[1] as string[]).join(" ")) - .find((c) => c.startsWith("worktree add -b poracode/brave-heron")); + .find((c) => c.startsWith("worktree add --no-track -b poracode/brave-heron")); expect(wtAdd?.endsWith("origin/feature/x")).toBe(true); }); @@ -596,7 +630,9 @@ describe("GitService.addWorktree", () => { const commands = execFileMock.mock.calls.map((c: unknown[]) => gitSubcommandArgs(c[1] as string[]).join(" "), ); - const wtAdd = commands.find((c) => c.startsWith("worktree add -b poracode/brave-heron")); + const wtAdd = commands.find((c) => + c.startsWith("worktree add --no-track -b poracode/brave-heron"), + ); expect(wtAdd?.endsWith("feature/x")).toBe(true); expect(commands.some((c) => c === "remote")).toBe(false); }); @@ -695,7 +731,7 @@ describe("GitService.addWorktree (transfer uncommitted changes)", () => { gitSubcommandArgs(c[1] as string[]).join(" "), ); expect(commands.some((c) => c.startsWith("stash push -u"))).toBe(true); - expect(commands.some((c) => c.startsWith("worktree add -b feature/x"))).toBe(true); + expect(commands.some((c) => c.startsWith("worktree add --no-track -b feature/x"))).toBe(true); // Never relies on stash@{0}: apply/drop are pinned to the captured SHA. expect(commands).not.toContain("stash pop"); @@ -848,7 +884,7 @@ describe("GitService.addWorktree (transfer uncommitted changes)", () => { ); // The worktree is still created, but the unrelated stash is never applied or // dropped — no data loss. - expect(commands.some((c) => c.startsWith("worktree add -b feature/x"))).toBe(true); + expect(commands.some((c) => c.startsWith("worktree add --no-track -b feature/x"))).toBe(true); expect(commands.some((c) => c.startsWith("stash apply"))).toBe(false); expect(commands.some((c) => c.startsWith("stash drop"))).toBe(false); expect(result.changesTransferred).toBeUndefined(); diff --git a/src/supervisor/git/statusParsing.ts b/src/supervisor/git/statusParsing.ts index 59f275f1a..1dcbcae10 100644 --- a/src/supervisor/git/statusParsing.ts +++ b/src/supervisor/git/statusParsing.ts @@ -1,6 +1,24 @@ import type { GitFileChange, GitRemoteInfo, GitStatusResult } from "@/shared/contracts"; import { parseRemoteUrl, toForwardSlash } from "./exec"; +/** + * True when git auto-wired a new worktree branch's upstream to its fork + * start-point (`origin/master`) instead of a same-named remote branch. + * `poracodeSource` is the recorded fork base; matching tracking with a + * different short name is the inherited-upstream bug, not a real rename. + */ +export function isInheritedStartPointUpstream(input: { + branch: string; + tracking: string; + poracodeSource: string | null; +}): boolean { + const { branch, tracking, poracodeSource } = input; + if (!branch || !tracking || !poracodeSource || tracking !== poracodeSource) return false; + const slash = tracking.indexOf("/"); + if (slash <= 0) return false; + return tracking.slice(slash + 1) !== branch; +} + export interface ParsedPorcelainStatus { branch: string; headSha: string; diff --git a/src/supervisor/git/statusService.parser.test.ts b/src/supervisor/git/statusService.parser.test.ts index 061594c96..11bcf5dec 100644 --- a/src/supervisor/git/statusService.parser.test.ts +++ b/src/supervisor/git/statusService.parser.test.ts @@ -13,6 +13,7 @@ import { buildGitStatusResultFromOutputs, buildGitStatusSummaryFromOutput, expandUntrackedEntries, + isInheritedStartPointUpstream, parseDiffNumstat, parseStatusPorcelainV2, unquoteGitPath, @@ -23,6 +24,38 @@ import { const QUOTED_CYRILLIC = '"\\321\\204\\320\\260\\320\\271\\320\\273.txt"'; const DECODED_CYRILLIC = "файл.txt"; +describe("isInheritedStartPointUpstream", () => { + it("detects a worktree branch that inherited origin/master as upstream", () => { + expect( + isInheritedStartPointUpstream({ + branch: "poracode/clever-falcon-2541f8a0", + tracking: "origin/master", + poracodeSource: "origin/master", + }), + ).toBe(true); + }); + + it("keeps a same-named remote tracking branch", () => { + expect( + isInheritedStartPointUpstream({ + branch: "feature/x", + tracking: "origin/feature/x", + poracodeSource: "origin/master", + }), + ).toBe(false); + }); + + it("ignores tracking that is not the recorded fork base", () => { + expect( + isInheritedStartPointUpstream({ + branch: "poracode/clever-falcon-2541f8a0", + tracking: "origin/master", + poracodeSource: "origin/develop", + }), + ).toBe(false); + }); +}); + describe("parseStatusPorcelainV2", () => { it("captures branch, upstream, ahead/behind from the header lines", () => { const output = [ diff --git a/src/supervisor/git/statusService.ts b/src/supervisor/git/statusService.ts index a82b6ab63..131b2d713 100644 --- a/src/supervisor/git/statusService.ts +++ b/src/supervisor/git/statusService.ts @@ -28,6 +28,7 @@ import { numstatFromBatchResult, parseDiffNumstat, parseRemoteInfo, + isInheritedStartPointUpstream, parseStatusPorcelainV2, sumChangeTotals, type ParsedPorcelainStatus, @@ -128,6 +129,7 @@ export class GitStatusService { ]); const parsed = parseStatusPorcelainV2(statusOutput); + await this.clearInheritedStartPointUpstream(location, parsed); const { hasRemote, remoteInfo } = parseRemoteInfo(remoteOutput); applyNumstatCounts(parsed, stagedNumstat, unstagedNumstat); @@ -163,9 +165,13 @@ export class GitStatusService { ); const result = results[0]; const untracked = results[1]; - return result?.ok - ? buildGitStatusSummaryFromOutput(result.stdout, untracked?.ok ? untracked.stdout : "") - : nonRepoSummaryStatus(); + if (!result?.ok) return nonRepoSummaryStatus(); + const summary = buildGitStatusSummaryFromOutput( + result.stdout, + untracked?.ok ? untracked.stdout : "", + ); + await this.clearInheritedStartPointUpstream(location, summary); + return summary; } try { @@ -178,7 +184,9 @@ export class GitStatusService { }, ), ]); - return buildGitStatusSummaryFromOutput(statusOutput, untrackedOutput); + const summary = buildGitStatusSummaryFromOutput(statusOutput, untrackedOutput); + await this.clearInheritedStartPointUpstream(location, summary); + return summary; } catch (error) { console.warn("[git] status summary failed, treating as non-repo:", error); return nonRepoSummaryStatus(); @@ -210,6 +218,44 @@ export class GitStatusService { return this.applyParsedMergeState(location, parseStatusPorcelainV2(statusOutput), base); } + private async clearInheritedStartPointUpstream( + location: ProjectLocation, + status: { branch: string; tracking: string; ahead: number; behind: number }, + ): Promise { + if (!status.branch || !status.tracking) return; + const slash = status.tracking.indexOf("/"); + if (slash <= 0 || status.tracking.slice(slash + 1) === status.branch) return; + let poracodeSource: string | null = null; + try { + const result = await execGit(location, [ + "config", + "--get", + `branch.${status.branch}.poracodeSource`, + ]); + poracodeSource = result.trim() || null; + } catch { + return; + } + if ( + !isInheritedStartPointUpstream({ + branch: status.branch, + tracking: status.tracking, + poracodeSource, + }) + ) { + return; + } + try { + await execGit(location, ["branch", "--unset-upstream", status.branch]); + } catch (error) { + console.warn("[git] failed to unset inherited worktree upstream:", error); + return; + } + status.tracking = ""; + status.ahead = 0; + status.behind = 0; + } + private async applyParsedMergeState( location: ProjectLocation, parsed: ParsedPorcelainStatus, @@ -398,10 +444,14 @@ export class GitStatusService { conflictFiles: [], mergeInProgress: false, }; + await this.clearInheritedStartPointUpstream(location, parsed); await this.replaceUntrackedEntries(location, parsed); const { totalInsertions, totalDeletions } = sumChangeTotals(parsed); return { ...base, + tracking: parsed.tracking, + ahead: parsed.ahead, + behind: parsed.behind, staged: parsed.staged, unstaged: parsed.unstaged, totalInsertions, diff --git a/src/supervisor/git/worktreeService.ts b/src/supervisor/git/worktreeService.ts index 1669234f7..558cbf100 100644 --- a/src/supervisor/git/worktreeService.ts +++ b/src/supervisor/git/worktreeService.ts @@ -588,7 +588,19 @@ export class GitWorktreeService { const args = ["worktree", "add"]; if (createBranch && branch && !ownedBranchPrepared) { - args.push("-b", branch, resolvedPath, ...(resolvedStartPoint ? [resolvedStartPoint] : [])); + // `--no-track` is required when the start-point is a remote-tracking + // ref (`origin/master`). Without it git's default autoSetupMerge wires + // the new branch's upstream to that start-point, so the worktree + // `poracode/clever-falcon-…` reports as tracking origin/master and + // shows "behind" whenever master moves. The fork base is recorded + // separately in `branch..poracodeSource`. + args.push( + "--no-track", + "-b", + branch, + resolvedPath, + ...(resolvedStartPoint ? [resolvedStartPoint] : []), + ); } else { args.push(resolvedPath, ...(branch ? [branch] : [])); } @@ -678,6 +690,9 @@ export class GitWorktreeService { await this.writeWorktreeSourceBranch(location, branch, sourceBranch); } } + // `--no-track` covers current git; unset in case an older git still + // wired the remote-tracking start-point as upstream. + await execGit(location, ["branch", "--unset-upstream", branch]).catch(() => undefined); } if (copyIgnoredPatterns?.length) {