diff --git a/docs/development.md b/docs/development.md index c3a42b332..90984441d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -257,6 +257,14 @@ tools, and the browser-tools delta. It defaults to the agent-scoped catalog; use `-- --scope=top-level` for the unaffiliated `/mcp/agents` shape and `-- --json` for machine-readable output. +## Worktree starting refs + +The New workspace branch picker treats local and `origin/*` refs as distinct starting points when +they differ. Local `main` is sent as `refs/heads/main`; remote `origin/main` is sent as +`refs/remotes/origin/main`. Matching local/remote refs collapse to one local row. Unqualified refs +from older clients resolve local-first, then fall back to `origin`. Worktrees inherit committed Git +state only; uncommitted source-checkout changes are not copied. + ## paseo.json service scripts `worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 6b98b9335..4940b27bb 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -244,6 +244,7 @@ export interface ComboboxItemProps { selected?: boolean; active?: boolean; disabled?: boolean; + accessibilityLabel?: string; /** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */ elevated?: boolean; onPress: () => void; @@ -259,6 +260,7 @@ export function ComboboxItem({ selected, active, disabled, + accessibilityLabel, elevated, onPress, testID, @@ -301,11 +303,11 @@ export function ComboboxItem({ return ( {leadingContent} diff --git a/packages/app/src/screens/new-workspace-picker-item.test.ts b/packages/app/src/screens/new-workspace-picker-item.test.ts index 0d223dd32..12c343dc8 100644 --- a/packages/app/src/screens/new-workspace-picker-item.test.ts +++ b/packages/app/src/screens/new-workspace-picker-item.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { CheckoutStatusPayload } from "@/git/checkout-status-cache"; import { + branchPickerOptionId, + buildBranchPickerItems, pickerItemToCheckoutRequest, resolveCheckoutRequest, type PickerItem, @@ -19,16 +21,29 @@ const prItem: ForgeSearchItem = { headRefName: "feature/picker", }; +describe("branchPickerOptionId", () => { + it("distinguishes a local origin-prefixed branch from the origin ref", () => { + expect(branchPickerOptionId("refs/heads/origin/main")).not.toBe( + branchPickerOptionId("refs/remotes/origin/main"), + ); + }); +}); + describe("pickerItemToCheckoutRequest", () => { it("returns undefined for no selection (null)", () => { expect(pickerItemToCheckoutRequest(null)).toBeUndefined(); }); - it("maps a branch row to branch-off with the branch name", () => { - const item: PickerItem = { kind: "branch", name: "dev" }; + it("maps a branch row to branch-off with its exact ref", () => { + const item: PickerItem = { + kind: "branch", + name: "dev", + refName: "refs/heads/dev", + accessibilityLabel: "dev, local branch", + }; expect(pickerItemToCheckoutRequest(item)).toEqual({ action: "branch-off", - refName: "dev", + refName: "refs/heads/dev", }); }); @@ -88,6 +103,124 @@ describe("pickerItemToCheckoutRequest", () => { }); }); +describe("buildBranchPickerItems", () => { + it("hides the origin duplicate when local and origin match", () => { + expect( + buildBranchPickerItems([ + { + name: "main", + committerDate: 10, + hasLocal: true, + hasRemote: true, + localAhead: 0, + localBehind: 0, + }, + ]), + ).toEqual([ + { + kind: "branch", + name: "main", + refName: "refs/heads/main", + accessibilityLabel: "main, local branch, up to date with origin main", + committerDate: 10, + }, + ]); + }); + + it("creates compact local and origin rows when the refs differ", () => { + expect( + buildBranchPickerItems([ + { + name: "main", + committerDate: 10, + hasLocal: true, + hasRemote: true, + localAhead: 3, + localBehind: 2, + }, + ]), + ).toEqual([ + { + kind: "branch", + name: "main", + refName: "refs/heads/main", + divergenceLabel: "+3 −2", + accessibilityLabel: "main, local branch, 3 commits ahead and 2 behind origin main", + committerDate: 10, + }, + { + kind: "branch", + name: "origin/main", + refName: "refs/remotes/origin/main", + divergenceLabel: "+2 −3", + accessibilityLabel: "origin main, 2 commits ahead and 3 behind local main", + committerDate: 10, + }, + ]); + }); + + it("uses an exact origin ref for remote-only branches", () => { + expect( + buildBranchPickerItems([ + { + name: "release", + committerDate: 5, + hasLocal: false, + hasRemote: true, + }, + ]), + ).toEqual([ + { + kind: "branch", + name: "origin/release", + refName: "refs/remotes/origin/release", + accessibilityLabel: "origin release, origin branch", + committerDate: 5, + }, + ]); + }); + + it("shows both refs when their divergence is unavailable", () => { + expect( + buildBranchPickerItems([ + { + name: "main", + committerDate: 10, + hasLocal: true, + hasRemote: true, + }, + ]), + ).toEqual([ + { + kind: "branch", + name: "main", + refName: "refs/heads/main", + accessibilityLabel: "main, local branch", + committerDate: 10, + }, + { + kind: "branch", + name: "origin/main", + refName: "refs/remotes/origin/main", + accessibilityLabel: "origin main, origin branch", + committerDate: 10, + }, + ]); + }); + + it("keeps the legacy unqualified row when provenance is unavailable", () => { + expect(buildBranchPickerItems([{ name: "legacy", committerDate: 1 }])).toEqual([ + { + kind: "branch", + name: "legacy", + refName: "legacy", + accessibilityLabel: "legacy, branch", + committerDate: 1, + }, + ]); + }); +}); + describe("resolveCheckoutRequest", () => { const checkoutStatus = { currentBranch: "feature/current", @@ -102,10 +235,18 @@ describe("resolveCheckoutRequest", () => { it("prefers an explicit picker selection", () => { expect( - resolveCheckoutRequest({ kind: "branch", name: "feature/picked" }, checkoutStatus), + resolveCheckoutRequest( + { + kind: "branch", + name: "feature/picked", + refName: "refs/heads/feature/picked", + accessibilityLabel: "feature/picked, local branch", + }, + checkoutStatus, + ), ).toEqual({ action: "branch-off", - refName: "feature/picked", + refName: "refs/heads/feature/picked", }); }); diff --git a/packages/app/src/screens/new-workspace-picker-item.ts b/packages/app/src/screens/new-workspace-picker-item.ts index 32371e942..ded824e3d 100644 --- a/packages/app/src/screens/new-workspace-picker-item.ts +++ b/packages/app/src/screens/new-workspace-picker-item.ts @@ -2,8 +2,24 @@ import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon- import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { CheckoutStatusPayload } from "@/git/checkout-status-cache"; +export interface BranchPickerDetail { + name: string; + committerDate: number; + hasLocal?: boolean; + hasRemote?: boolean; + localAhead?: number; + localBehind?: number; +} + export type PickerItem = - | { kind: "branch"; name: string } + | { + kind: "branch"; + name: string; + refName: string; + accessibilityLabel: string; + divergenceLabel?: string; + committerDate?: number; + } | { kind: "github-pr"; item: ForgeSearchItem; @@ -14,13 +30,111 @@ export type PickerCheckoutRequest = Pick< "action" | "refName" | "checkoutSource" | "githubPrNumber" >; +const BRANCH_OPTION_PREFIX = "branch:"; + +export function branchPickerOptionId(refName: string): string { + return `${BRANCH_OPTION_PREFIX}${refName}`; +} + +function divergenceLabel(ahead: number, behind: number): string | undefined { + const parts = [ahead > 0 ? `+${ahead}` : null, behind > 0 ? `−${behind}` : null].filter( + (part): part is string => part !== null, + ); + return parts.length > 0 ? parts.join(" ") : undefined; +} + +function commitCount(count: number): string { + return `${count} ${count === 1 ? "commit" : "commits"}`; +} + +function divergenceAccessibility(ahead: number, behind: number, counterpart: string): string { + if (ahead === 0 && behind === 0) { + return `up to date with ${counterpart}`; + } + if (ahead > 0 && behind > 0) { + return `${commitCount(ahead)} ahead and ${behind} behind ${counterpart}`; + } + if (ahead > 0) { + return `${commitCount(ahead)} ahead of ${counterpart}`; + } + return `${commitCount(behind)} behind ${counterpart}`; +} + +export function buildBranchPickerItems(details: readonly BranchPickerDetail[]): PickerItem[] { + const items: PickerItem[] = []; + + for (const detail of details) { + const hasKnownProvenance = detail.hasLocal !== undefined || detail.hasRemote !== undefined; + if (!hasKnownProvenance) { + items.push({ + kind: "branch", + name: detail.name, + refName: detail.name, + accessibilityLabel: `${detail.name}, branch`, + committerDate: detail.committerDate, + }); + continue; + } + + const hasLocal = detail.hasLocal === true; + const hasRemote = detail.hasRemote === true; + const localAhead = detail.localAhead; + const localBehind = detail.localBehind; + const hasDivergence = + hasLocal && hasRemote && localAhead !== undefined && localBehind !== undefined; + + if (hasLocal) { + const localItem: Extract = { + kind: "branch", + name: detail.name, + refName: `refs/heads/${detail.name}`, + accessibilityLabel: `${detail.name}, local branch`, + committerDate: detail.committerDate, + }; + if (hasDivergence) { + localItem.divergenceLabel = divergenceLabel(localAhead, localBehind); + localItem.accessibilityLabel += `, ${divergenceAccessibility( + localAhead, + localBehind, + `origin ${detail.name}`, + )}`; + } + items.push(localItem); + } + + const refsDiffer = hasDivergence && (localAhead > 0 || localBehind > 0); + if (hasRemote && (!hasLocal || !hasDivergence || refsDiffer)) { + const remoteAhead = hasDivergence ? localBehind : 0; + const remoteBehind = hasDivergence ? localAhead : 0; + const remoteItem: Extract = { + kind: "branch", + name: `origin/${detail.name}`, + refName: `refs/remotes/origin/${detail.name}`, + accessibilityLabel: `origin ${detail.name}, origin branch`, + committerDate: detail.committerDate, + }; + if (hasDivergence) { + remoteItem.divergenceLabel = divergenceLabel(remoteAhead, remoteBehind); + remoteItem.accessibilityLabel = `origin ${detail.name}, ${divergenceAccessibility( + remoteAhead, + remoteBehind, + `local ${detail.name}`, + )}`; + } + items.push(remoteItem); + } + } + + return items; +} + export function pickerItemToCheckoutRequest( item: PickerItem | null, ): PickerCheckoutRequest | undefined { if (!item) return undefined; switch (item.kind) { case "branch": - return { action: "branch-off", refName: item.name }; + return { action: "branch-off", refName: item.refName }; case "github-pr": { const headRefName = item.item.headRefName?.trim(); const forge = item.item.forge ?? "github"; diff --git a/packages/app/src/screens/new-workspace-picker-state.test.ts b/packages/app/src/screens/new-workspace-picker-state.test.ts index 08f5e8723..4ee79ce5b 100644 --- a/packages/app/src/screens/new-workspace-picker-state.test.ts +++ b/packages/app/src/screens/new-workspace-picker-state.test.ts @@ -65,7 +65,12 @@ describe("syncPickerPrAttachment", () => { const issue = issueAttachment(44); const result = syncPickerPrAttachment({ attachments: [issue], - item: { kind: "branch", name: "dev" }, + item: { + kind: "branch", + name: "dev", + refName: "refs/heads/dev", + accessibilityLabel: "dev, local branch", + }, }); expect(result).toEqual([issue]); }); @@ -85,7 +90,12 @@ describe("syncPickerPrAttachment", () => { const issue = issueAttachment(44); const result = syncPickerPrAttachment({ attachments: [issue, prAttachment(pr, "new-workspace-picker")], - item: { kind: "branch", name: "dev" }, + item: { + kind: "branch", + name: "dev", + refName: "refs/heads/dev", + accessibilityLabel: "dev, local branch", + }, }); expect(result).toEqual([issue]); }); @@ -182,7 +192,12 @@ describe("reducePickerSelection", () => { const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" }); const branchSelected = reducePickerSelection(detected, { type: "picker-selected", - item: { kind: "branch", name: "main" }, + item: { + kind: "branch", + name: "main", + refName: "refs/heads/main", + accessibilityLabel: "main, local branch", + }, }); expect( @@ -205,7 +220,12 @@ describe("reducePickerSelection", () => { it("lets a newly detected PR replace an earlier explicit branch", () => { const branchSelected = reducePickerSelection(initialPickerSelectionState, { type: "picker-selected", - item: { kind: "branch", name: "main" }, + item: { + kind: "branch", + name: "main", + refName: "refs/heads/main", + accessibilityLabel: "main, local branch", + }, }); const detected = reducePickerSelection(branchSelected, { type: "pr-detected" }); const pr = { kind: "github-pr" as const, item: makePrItem(101, "A") }; diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 7326f43c8..c62c5f163 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -90,7 +90,10 @@ import { remapDraftCwdToWorkspace, } from "./new-workspace-fork-context"; import { + branchPickerOptionId, + buildBranchPickerItems, resolveCheckoutRequest, + type BranchPickerDetail, type PickerCheckoutRequest, type PickerItem, } from "./new-workspace-picker-item"; @@ -171,7 +174,6 @@ interface PickerOptionData { itemById: Map; } -const BRANCH_OPTION_PREFIX = "branch:"; const PR_OPTION_PREFIX = "github-pr:"; const PROJECT_ICON_FALLBACK_FONT_SIZE = 10; // Stable reference so the keyboard-action handler doesn't re-register each render. @@ -327,6 +329,8 @@ function PickerOptionItem({ disabled, onPress, isBranch, + trailingLabel, + accessibilityLabel, iconColor, iconSize, }: { @@ -338,6 +342,8 @@ function PickerOptionItem({ disabled: boolean; onPress: () => void; isBranch: boolean; + trailingLabel?: string; + accessibilityLabel?: string; iconColor: string; iconSize: number; }) { @@ -353,6 +359,11 @@ function PickerOptionItem({ ), [isBranch, iconSize, iconColor], ); + const trailingSlot = useMemo( + () => + trailingLabel ? {trailingLabel} : undefined, + [trailingLabel], + ); return ( ); } @@ -464,10 +477,6 @@ function ProjectOptionItem({ ); } -function branchOptionId(name: string): string { - return `${BRANCH_OPTION_PREFIX}${name}`; -} - function prOptionId(number: number): string { return `${PR_OPTION_PREFIX}${number}`; } @@ -511,6 +520,8 @@ function NewWorkspacePickerOption({ disabled={isPending} onPress={onPress} isBranch={isBranch} + trailingLabel={isBranch ? item.divergenceLabel : undefined} + accessibilityLabel={isBranch ? item.accessibilityLabel : undefined} iconColor={theme.colors.foregroundMuted} iconSize={theme.iconSize.sm} /> @@ -599,7 +610,7 @@ function newWorkspaceHostOptionTestID(serverId: string): string { } function computePickerOptionData( - branchDetails: ReadonlyArray<{ name: string; committerDate: number }>, + branchDetails: readonly BranchPickerDetail[], prItems: ReadonlyArray, ): PickerOptionData { const idMap = new Map(); @@ -610,11 +621,12 @@ function computePickerOptionData( } const timedOptions: TimedOption[] = []; - for (const branch of branchDetails) { - const id = branchOptionId(branch.name); + for (const branch of buildBranchPickerItems(branchDetails)) { + if (branch.kind !== "branch") continue; + const id = branchPickerOptionId(branch.refName); const option = { id, label: branch.name }; - idMap.set(id, { kind: "branch", name: branch.name }); - timedOptions.push({ option, timestamp: branch.committerDate }); + idMap.set(id, branch); + timedOptions.push({ option, timestamp: branch.committerDate ?? 0 }); } for (const pr of prItems) { @@ -735,10 +747,8 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) { } function normalizeBranchDetails( - data: - | { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] } - | undefined, -): Array<{ name: string; committerDate: number }> { + data: { branchDetails?: BranchPickerDetail[]; branches?: string[] } | undefined, +): BranchPickerDetail[] { const details = data?.branchDetails; if (details && details.length > 0) return details; const names = data?.branches ?? []; @@ -1711,11 +1721,15 @@ export function NewWorkspaceScreen({ }, [currentBranch, selectedItem]); const selectedOptionId = useMemo(() => { - if (!selectedItem) return ""; + if (!selectedItem) { + if (!currentBranch) return ""; + const exactLocalId = branchPickerOptionId(`refs/heads/${currentBranch}`); + return itemById.has(exactLocalId) ? exactLocalId : branchPickerOptionId(currentBranch); + } return selectedItem.kind === "branch" - ? branchOptionId(selectedItem.name) + ? branchPickerOptionId(selectedItem.refName) : prOptionId(selectedItem.item.number); - }, [selectedItem]); + }, [currentBranch, itemById, selectedItem]); const selectPickerItem = useCallback( (item: PickerItem) => { const nextAttachments = syncPickerPrAttachment({ @@ -2280,6 +2294,11 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, color: theme.colors.popoverForeground, }, + refDivergenceLabel: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + fontVariant: ["tabular-nums"], + }, badgeIconBox: { width: theme.iconSize.md, height: theme.iconSize.md, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 7c51205fc..bc7e7a2b2 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -4658,6 +4658,8 @@ export const BranchSuggestionsResponseSchema = z.object({ committerDate: z.number(), hasLocal: z.boolean().optional(), hasRemote: z.boolean().optional(), + localAhead: z.number().int().nonnegative().optional(), + localBehind: z.number().int().nonnegative().optional(), }), ) .optional(), diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 8a2f879e1..c5f8db167 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -2169,6 +2169,45 @@ const x = 1; expect(branches.find((branch) => branch.name === "feature/shared")).toMatchObject({ hasLocal: true, hasRemote: true, + localAhead: 0, + localBehind: 0, + }); + await expect(listBranchSuggestions(repoDir, { query: "origin/main" })).resolves.toEqual([ + expect.objectContaining({ name: "main", hasLocal: true, hasRemote: true }), + ]); + }); + + it("reports local and origin divergence for branch suggestions", async () => { + const remoteDir = join(tempDir, "remote.git"); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); + + writeFileSync(join(repoDir, "local.txt"), "local\n"); + execFileSync("git", ["add", "local.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "local commit"], { + cwd: repoDir, + }); + + const otherClone = join(tempDir, "diverged-clone"); + execFileSync("git", ["clone", remoteDir, otherClone]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); + writeFileSync(join(otherClone, "remote.txt"), "remote\n"); + execFileSync("git", ["add", "remote.txt"], { cwd: otherClone }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote commit"], { + cwd: otherClone, + }); + execFileSync("git", ["push", "origin", "main"], { cwd: otherClone }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); + + const branches = await listBranchSuggestions(repoDir, { limit: 50 }); + + expect(branches.find((branch) => branch.name === "main")).toMatchObject({ + hasLocal: true, + hasRemote: true, + localAhead: 1, + localBehind: 1, }); }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 77763f921..5180817f1 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -231,6 +231,7 @@ function normalizeBranchSuggestionName(raw: string): string | null { interface GitRef { name: string; committerDate: number; + oid: string; } export interface BranchSuggestion { @@ -238,6 +239,8 @@ export interface BranchSuggestion { committerDate: number; hasLocal: boolean; hasRemote: boolean; + localAhead?: number; + localBehind?: number; } async function listGitRefs(cwd: string, refPrefix: string): Promise { @@ -245,7 +248,7 @@ async function listGitRefs(cwd: string, refPrefix: string): Promise { [ "for-each-ref", "--sort=-committerdate", - "--format=%(refname)%09%(committerdate:unix)", + "--format=%(refname)%09%(committerdate:unix)%09%(objectname)", refPrefix, ], { cwd, envOverlay: READ_ONLY_GIT_ENV }, @@ -255,9 +258,9 @@ async function listGitRefs(cwd: string, refPrefix: string): Promise { .map((line) => { const trimmed = line.trim(); if (!trimmed) return null; - const [name, dateStr] = trimmed.split("\t"); - if (!name) return null; - return { name, committerDate: Number(dateStr) || 0 }; + const [name, dateStr, oid] = trimmed.split("\t"); + if (!name || !oid) return null; + return { name, committerDate: Number(dateStr) || 0, oid }; }) .filter((ref): ref is GitRef => ref !== null); } @@ -266,6 +269,8 @@ interface BranchSuggestionMeta { committerDate: number; hasLocal: boolean; hasRemote: boolean; + localOid?: string; + remoteOid?: string; } function sortBranchSuggestions( @@ -304,7 +309,8 @@ export async function listBranchSuggestions( const requestedLimit = options?.limit ?? 50; const limit = Math.max(1, Math.min(200, requestedLimit)); - const query = options?.query?.trim().toLowerCase() ?? ""; + const rawQuery = options?.query?.trim().toLowerCase() ?? ""; + const query = normalizeBranchSuggestionName(rawQuery) ?? rawQuery; const [localRefs, remoteRefs] = await Promise.all([ listGitRefs(cwd, "refs/heads"), @@ -320,6 +326,8 @@ export async function listBranchSuggestions( branchMeta.set(normalized, { hasLocal: true, hasRemote: existing?.hasRemote ?? false, + localOid: ref.oid, + ...(existing?.remoteOid ? { remoteOid: existing.remoteOid } : {}), committerDate: Math.max(ref.committerDate, existing?.committerDate ?? 0), }); } @@ -332,12 +340,14 @@ export async function listBranchSuggestions( branchMeta.set(normalized, { hasLocal: false, hasRemote: true, + remoteOid: ref.oid, committerDate: ref.committerDate, }); } else { branchMeta.set(normalized, { ...existing, hasRemote: true, + remoteOid: ref.oid, committerDate: Math.max(ref.committerDate, existing.committerDate), }); } @@ -351,15 +361,46 @@ export async function listBranchSuggestions( } const ordered = sortBranchSuggestions(filteredNames, branchMeta, query); - return ordered.slice(0, limit).map((name) => { - const meta = branchMeta.get(name); - return { - name, - committerDate: meta?.committerDate ?? 0, - hasLocal: meta?.hasLocal ?? false, - hasRemote: meta?.hasRemote ?? false, - }; - }); + return Promise.all( + ordered.slice(0, limit).map(async (name): Promise => { + const meta = branchMeta.get(name); + const suggestion: BranchSuggestion = { + name, + committerDate: meta?.committerDate ?? 0, + hasLocal: meta?.hasLocal ?? false, + hasRemote: meta?.hasRemote ?? false, + }; + if (!suggestion.hasLocal || !suggestion.hasRemote) { + return suggestion; + } + if (meta?.localOid && meta.localOid === meta.remoteOid) { + suggestion.localAhead = 0; + suggestion.localBehind = 0; + return suggestion; + } + + try { + const { stdout } = await runGitCommand( + [ + "rev-list", + "--left-right", + "--count", + `refs/heads/${name}...refs/remotes/origin/${name}`, + ], + { cwd, envOverlay: READ_ONLY_GIT_ENV }, + ); + const [localAhead, localBehind] = stdout.trim().split(/\s+/).map(Number); + if (Number.isFinite(localAhead) && Number.isFinite(localBehind)) { + suggestion.localAhead = localAhead; + suggestion.localBehind = localBehind; + } + } catch { + // A ref may disappear between listing and comparison. Keep the branch + // available without divergence metadata and let creation re-resolve it. + } + return suggestion; + }), + ); } export interface LocalBranchCheckoutResolution { diff --git a/packages/server/src/utils/worktree-metadata.ts b/packages/server/src/utils/worktree-metadata.ts index 931a29966..7261ea3b2 100644 --- a/packages/server/src/utils/worktree-metadata.ts +++ b/packages/server/src/utils/worktree-metadata.ts @@ -138,6 +138,12 @@ export function normalizeBaseRefName(input: string): string { if (!trimmed) { throw new Error("Base branch is required"); } + if (trimmed.startsWith("refs/heads/")) { + return trimmed.slice("refs/heads/".length); + } + if (trimmed.startsWith("refs/remotes/origin/")) { + return trimmed.slice("refs/remotes/origin/".length); + } if (trimmed.startsWith("origin/")) { return trimmed.slice("origin/".length); } diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index d895df539..0cbbb9a04 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -407,7 +407,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(currentBranch).toBe("Feature.X"); }); - it("prefers origin/{branch} over local {branch} when both exist", async () => { + it("uses the selected local or origin ref when both exist", async () => { const remoteDir = join(tempDir, "remote.git"); const remoteCloneDir = join(tempDir, "remote-clone"); execFileSync("git", ["init", "--bare", remoteDir]); @@ -433,19 +433,33 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); - const result = await createLegacyWorktreeForTest({ - branchName: "prefer-origin-feature", + const localResult = await createLegacyWorktreeForTest({ + branchName: "prefer-local-feature", cwd: repoDir, baseBranch: "main", + worktreeSlug: "prefer-local-feature", + runSetup: false, + paseoHome, + }); + const originResult = await createLegacyWorktreeForTest({ + branchName: "prefer-origin-feature", + cwd: repoDir, + baseBranch: "refs/remotes/origin/main", worktreeSlug: "prefer-origin-feature", runSetup: false, paseoHome, }); - expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-origin\n"); + expect(readFileSync(join(localResult.worktreePath, "file.txt"), "utf8")).toBe("from-local\n"); + expect(readFileSync(join(originResult.worktreePath, "file.txt"), "utf8")).toBe( + "from-origin\n", + ); + expect( + JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8")), + ).toMatchObject({ baseRefName: "main" }); }); - it("falls back to local {branch} when origin/{branch} does not exist", async () => { + it("uses a local branch when origin/{branch} does not exist", async () => { writeFileSync(join(repoDir, "file.txt"), "from-local-only\n"); execFileSync("git", ["add", "file.txt"], { cwd: repoDir }); execFileSync( diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 45fe7fa10..7b2cf4560 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1325,7 +1325,7 @@ async function resolveWorktreeSourcePlan({ const branchName = source.branchName; validateWorktreeBranchName(branchName); const normalizedBaseBranch = normalizeRequiredBaseBranch(source.baseBranch); - const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, normalizedBaseBranch); + const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, source.baseBranch); const branchExists = await localBranchExists(cwd, branchName); const base = branchExists ? branchName : resolvedBaseBranch; const candidateBranch = branchExists ? desiredSlug : branchName; @@ -1615,19 +1615,36 @@ function normalizeRequiredBaseBranch(baseBranch: string): string { async function resolveBaseBranchForWorktree( cwd: string, - normalizedBaseBranch: string, + requestedBaseBranch: string, ): Promise { - try { - await runGitCommand(["rev-parse", "--verify", `origin/${normalizedBaseBranch}`], { cwd }); - return `origin/${normalizedBaseBranch}`; - } catch { + const requested = requestedBaseBranch.trim(); + const normalized = normalizeRequiredBaseBranch(requested); + let exactRef: string | null = null; + if (requested.startsWith("refs/")) { + exactRef = requested; + } else if (requested.startsWith("origin/")) { + exactRef = `refs/remotes/${requested}`; + } + + if (exactRef) { try { - await runGitCommand(["rev-parse", "--verify", normalizedBaseBranch], { cwd }); - return normalizedBaseBranch; + await runGitCommand(["rev-parse", "--verify", exactRef], { cwd }); + return exactRef; } catch { - throw new Error(`Base branch not found: ${normalizedBaseBranch}`); + throw new Error(`Base branch not found: ${normalized}`); } } + + const candidates = [`refs/heads/${requested}`, `refs/remotes/origin/${requested}`, requested]; + for (const candidate of candidates) { + try { + await runGitCommand(["rev-parse", "--verify", candidate], { cwd }); + return candidate; + } catch { + // Try the next unambiguous local, remote, or legacy ref candidate. + } + } + throw new Error(`Base branch not found: ${normalized}`); } async function localBranchExists(cwd: string, branchName: string): Promise {