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) {