Fix/local branch worktree base (#2328)

* Add automatic plus branch maintenance workflow

* Build plus artifacts for desktop and CLI/server

* Document and summarize plus artifact workflow

* Document Apple signing and pin plus workflow actions

* Disable Linux plus desktop artifact

* Publish plus CLI packages to GitHub Packages

* Fix GitHub Packages publish paths

* Fix plus mise install summary package name

* Disable macOS x64 plus desktop artifact

* Tag plus npm packages as latest for mise

* fix(worktrees): respect selected local base ref

* chore(ci): remove fork-only plus workflow

* fix(worktrees): preserve exact branch picker refs

---------

Co-authored-by: Matt Cowger <1929548+mcowger@users.noreply.github.com>
This commit is contained in:
Matt Cowger
2026-08-01 16:06:56 +00:00
committed by GitHub
co-authored by Matt Cowger
parent b0a5c09ace
commit 76abc50f82
12 changed files with 481 additions and 58 deletions
+8
View File
@@ -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` `-- --scope=top-level` for the unaffiliated `/mcp/agents` shape and `-- --json`
for machine-readable output. 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 ## paseo.json service scripts
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array `worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
+4 -2
View File
@@ -244,6 +244,7 @@ export interface ComboboxItemProps {
selected?: boolean; selected?: boolean;
active?: boolean; active?: boolean;
disabled?: boolean; disabled?: boolean;
accessibilityLabel?: string;
/** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */ /** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */
elevated?: boolean; elevated?: boolean;
onPress: () => void; onPress: () => void;
@@ -259,6 +260,7 @@ export function ComboboxItem({
selected, selected,
active, active,
disabled, disabled,
accessibilityLabel,
elevated, elevated,
onPress, onPress,
testID, testID,
@@ -301,11 +303,11 @@ export function ComboboxItem({
return ( return (
<Pressable <Pressable
testID={testID} testID={testID}
accessibilityRole="button"
accessibilityLabel={label}
disabled={disabled} disabled={disabled}
onPress={onPress} onPress={onPress}
style={itemPressableStyle} style={itemPressableStyle}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? label}
> >
{leadingContent} {leadingContent}
<View style={itemContentStyle}> <View style={itemContentStyle}>
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import type { CheckoutStatusPayload } from "@/git/checkout-status-cache"; import type { CheckoutStatusPayload } from "@/git/checkout-status-cache";
import { import {
branchPickerOptionId,
buildBranchPickerItems,
pickerItemToCheckoutRequest, pickerItemToCheckoutRequest,
resolveCheckoutRequest, resolveCheckoutRequest,
type PickerItem, type PickerItem,
@@ -19,16 +21,29 @@ const prItem: ForgeSearchItem = {
headRefName: "feature/picker", 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", () => { describe("pickerItemToCheckoutRequest", () => {
it("returns undefined for no selection (null)", () => { it("returns undefined for no selection (null)", () => {
expect(pickerItemToCheckoutRequest(null)).toBeUndefined(); expect(pickerItemToCheckoutRequest(null)).toBeUndefined();
}); });
it("maps a branch row to branch-off with the branch name", () => { it("maps a branch row to branch-off with its exact ref", () => {
const item: PickerItem = { kind: "branch", name: "dev" }; const item: PickerItem = {
kind: "branch",
name: "dev",
refName: "refs/heads/dev",
accessibilityLabel: "dev, local branch",
};
expect(pickerItemToCheckoutRequest(item)).toEqual({ expect(pickerItemToCheckoutRequest(item)).toEqual({
action: "branch-off", 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", () => { describe("resolveCheckoutRequest", () => {
const checkoutStatus = { const checkoutStatus = {
currentBranch: "feature/current", currentBranch: "feature/current",
@@ -102,10 +235,18 @@ describe("resolveCheckoutRequest", () => {
it("prefers an explicit picker selection", () => { it("prefers an explicit picker selection", () => {
expect( 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({ ).toEqual({
action: "branch-off", action: "branch-off",
refName: "feature/picked", refName: "refs/heads/feature/picked",
}); });
}); });
@@ -2,8 +2,24 @@ import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-
import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import type { CheckoutStatusPayload } from "@/git/checkout-status-cache"; 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 = export type PickerItem =
| { kind: "branch"; name: string } | {
kind: "branch";
name: string;
refName: string;
accessibilityLabel: string;
divergenceLabel?: string;
committerDate?: number;
}
| { | {
kind: "github-pr"; kind: "github-pr";
item: ForgeSearchItem; item: ForgeSearchItem;
@@ -14,13 +30,111 @@ export type PickerCheckoutRequest = Pick<
"action" | "refName" | "checkoutSource" | "githubPrNumber" "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<PickerItem, { kind: "branch" }> = {
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<PickerItem, { kind: "branch" }> = {
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( export function pickerItemToCheckoutRequest(
item: PickerItem | null, item: PickerItem | null,
): PickerCheckoutRequest | undefined { ): PickerCheckoutRequest | undefined {
if (!item) return undefined; if (!item) return undefined;
switch (item.kind) { switch (item.kind) {
case "branch": case "branch":
return { action: "branch-off", refName: item.name }; return { action: "branch-off", refName: item.refName };
case "github-pr": { case "github-pr": {
const headRefName = item.item.headRefName?.trim(); const headRefName = item.item.headRefName?.trim();
const forge = item.item.forge ?? "github"; const forge = item.item.forge ?? "github";
@@ -65,7 +65,12 @@ describe("syncPickerPrAttachment", () => {
const issue = issueAttachment(44); const issue = issueAttachment(44);
const result = syncPickerPrAttachment({ const result = syncPickerPrAttachment({
attachments: [issue], attachments: [issue],
item: { kind: "branch", name: "dev" }, item: {
kind: "branch",
name: "dev",
refName: "refs/heads/dev",
accessibilityLabel: "dev, local branch",
},
}); });
expect(result).toEqual([issue]); expect(result).toEqual([issue]);
}); });
@@ -85,7 +90,12 @@ describe("syncPickerPrAttachment", () => {
const issue = issueAttachment(44); const issue = issueAttachment(44);
const result = syncPickerPrAttachment({ const result = syncPickerPrAttachment({
attachments: [issue, prAttachment(pr, "new-workspace-picker")], 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]); expect(result).toEqual([issue]);
}); });
@@ -182,7 +192,12 @@ describe("reducePickerSelection", () => {
const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" }); const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" });
const branchSelected = reducePickerSelection(detected, { const branchSelected = reducePickerSelection(detected, {
type: "picker-selected", type: "picker-selected",
item: { kind: "branch", name: "main" }, item: {
kind: "branch",
name: "main",
refName: "refs/heads/main",
accessibilityLabel: "main, local branch",
},
}); });
expect( expect(
@@ -205,7 +220,12 @@ describe("reducePickerSelection", () => {
it("lets a newly detected PR replace an earlier explicit branch", () => { it("lets a newly detected PR replace an earlier explicit branch", () => {
const branchSelected = reducePickerSelection(initialPickerSelectionState, { const branchSelected = reducePickerSelection(initialPickerSelectionState, {
type: "picker-selected", 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 detected = reducePickerSelection(branchSelected, { type: "pr-detected" });
const pr = { kind: "github-pr" as const, item: makePrItem(101, "A") }; const pr = { kind: "github-pr" as const, item: makePrItem(101, "A") };
@@ -90,7 +90,10 @@ import {
remapDraftCwdToWorkspace, remapDraftCwdToWorkspace,
} from "./new-workspace-fork-context"; } from "./new-workspace-fork-context";
import { import {
branchPickerOptionId,
buildBranchPickerItems,
resolveCheckoutRequest, resolveCheckoutRequest,
type BranchPickerDetail,
type PickerCheckoutRequest, type PickerCheckoutRequest,
type PickerItem, type PickerItem,
} from "./new-workspace-picker-item"; } from "./new-workspace-picker-item";
@@ -171,7 +174,6 @@ interface PickerOptionData {
itemById: Map<string, PickerItem>; itemById: Map<string, PickerItem>;
} }
const BRANCH_OPTION_PREFIX = "branch:";
const PR_OPTION_PREFIX = "github-pr:"; const PR_OPTION_PREFIX = "github-pr:";
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10; const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
// Stable reference so the keyboard-action handler doesn't re-register each render. // Stable reference so the keyboard-action handler doesn't re-register each render.
@@ -327,6 +329,8 @@ function PickerOptionItem({
disabled, disabled,
onPress, onPress,
isBranch, isBranch,
trailingLabel,
accessibilityLabel,
iconColor, iconColor,
iconSize, iconSize,
}: { }: {
@@ -338,6 +342,8 @@ function PickerOptionItem({
disabled: boolean; disabled: boolean;
onPress: () => void; onPress: () => void;
isBranch: boolean; isBranch: boolean;
trailingLabel?: string;
accessibilityLabel?: string;
iconColor: string; iconColor: string;
iconSize: number; iconSize: number;
}) { }) {
@@ -353,6 +359,11 @@ function PickerOptionItem({
), ),
[isBranch, iconSize, iconColor], [isBranch, iconSize, iconColor],
); );
const trailingSlot = useMemo(
() =>
trailingLabel ? <Text style={styles.refDivergenceLabel}>{trailingLabel}</Text> : undefined,
[trailingLabel],
);
return ( return (
<ComboboxItem <ComboboxItem
testID={testID} testID={testID}
@@ -363,6 +374,8 @@ function PickerOptionItem({
disabled={disabled} disabled={disabled}
onPress={onPress} onPress={onPress}
leadingSlot={leadingSlot} leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
accessibilityLabel={accessibilityLabel}
/> />
); );
} }
@@ -464,10 +477,6 @@ function ProjectOptionItem({
); );
} }
function branchOptionId(name: string): string {
return `${BRANCH_OPTION_PREFIX}${name}`;
}
function prOptionId(number: number): string { function prOptionId(number: number): string {
return `${PR_OPTION_PREFIX}${number}`; return `${PR_OPTION_PREFIX}${number}`;
} }
@@ -511,6 +520,8 @@ function NewWorkspacePickerOption({
disabled={isPending} disabled={isPending}
onPress={onPress} onPress={onPress}
isBranch={isBranch} isBranch={isBranch}
trailingLabel={isBranch ? item.divergenceLabel : undefined}
accessibilityLabel={isBranch ? item.accessibilityLabel : undefined}
iconColor={theme.colors.foregroundMuted} iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm} iconSize={theme.iconSize.sm}
/> />
@@ -599,7 +610,7 @@ function newWorkspaceHostOptionTestID(serverId: string): string {
} }
function computePickerOptionData( function computePickerOptionData(
branchDetails: ReadonlyArray<{ name: string; committerDate: number }>, branchDetails: readonly BranchPickerDetail[],
prItems: ReadonlyArray<ForgeSearchItem>, prItems: ReadonlyArray<ForgeSearchItem>,
): PickerOptionData { ): PickerOptionData {
const idMap = new Map<string, PickerItem>(); const idMap = new Map<string, PickerItem>();
@@ -610,11 +621,12 @@ function computePickerOptionData(
} }
const timedOptions: TimedOption[] = []; const timedOptions: TimedOption[] = [];
for (const branch of branchDetails) { for (const branch of buildBranchPickerItems(branchDetails)) {
const id = branchOptionId(branch.name); if (branch.kind !== "branch") continue;
const id = branchPickerOptionId(branch.refName);
const option = { id, label: branch.name }; const option = { id, label: branch.name };
idMap.set(id, { kind: "branch", name: branch.name }); idMap.set(id, branch);
timedOptions.push({ option, timestamp: branch.committerDate }); timedOptions.push({ option, timestamp: branch.committerDate ?? 0 });
} }
for (const pr of prItems) { for (const pr of prItems) {
@@ -735,10 +747,8 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) {
} }
function normalizeBranchDetails( function normalizeBranchDetails(
data: data: { branchDetails?: BranchPickerDetail[]; branches?: string[] } | undefined,
| { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] } ): BranchPickerDetail[] {
| undefined,
): Array<{ name: string; committerDate: number }> {
const details = data?.branchDetails; const details = data?.branchDetails;
if (details && details.length > 0) return details; if (details && details.length > 0) return details;
const names = data?.branches ?? []; const names = data?.branches ?? [];
@@ -1711,11 +1721,15 @@ export function NewWorkspaceScreen({
}, [currentBranch, selectedItem]); }, [currentBranch, selectedItem]);
const selectedOptionId = useMemo(() => { 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" return selectedItem.kind === "branch"
? branchOptionId(selectedItem.name) ? branchPickerOptionId(selectedItem.refName)
: prOptionId(selectedItem.item.number); : prOptionId(selectedItem.item.number);
}, [selectedItem]); }, [currentBranch, itemById, selectedItem]);
const selectPickerItem = useCallback( const selectPickerItem = useCallback(
(item: PickerItem) => { (item: PickerItem) => {
const nextAttachments = syncPickerPrAttachment({ const nextAttachments = syncPickerPrAttachment({
@@ -2280,6 +2294,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground, color: theme.colors.popoverForeground,
}, },
refDivergenceLabel: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
fontVariant: ["tabular-nums"],
},
badgeIconBox: { badgeIconBox: {
width: theme.iconSize.md, width: theme.iconSize.md,
height: theme.iconSize.md, height: theme.iconSize.md,
+2
View File
@@ -4658,6 +4658,8 @@ export const BranchSuggestionsResponseSchema = z.object({
committerDate: z.number(), committerDate: z.number(),
hasLocal: z.boolean().optional(), hasLocal: z.boolean().optional(),
hasRemote: z.boolean().optional(), hasRemote: z.boolean().optional(),
localAhead: z.number().int().nonnegative().optional(),
localBehind: z.number().int().nonnegative().optional(),
}), }),
) )
.optional(), .optional(),
@@ -2169,6 +2169,45 @@ const x = 1;
expect(branches.find((branch) => branch.name === "feature/shared")).toMatchObject({ expect(branches.find((branch) => branch.name === "feature/shared")).toMatchObject({
hasLocal: true, hasLocal: true,
hasRemote: 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,
}); });
}); });
+55 -14
View File
@@ -231,6 +231,7 @@ function normalizeBranchSuggestionName(raw: string): string | null {
interface GitRef { interface GitRef {
name: string; name: string;
committerDate: number; committerDate: number;
oid: string;
} }
export interface BranchSuggestion { export interface BranchSuggestion {
@@ -238,6 +239,8 @@ export interface BranchSuggestion {
committerDate: number; committerDate: number;
hasLocal: boolean; hasLocal: boolean;
hasRemote: boolean; hasRemote: boolean;
localAhead?: number;
localBehind?: number;
} }
async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> { async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
@@ -245,7 +248,7 @@ async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
[ [
"for-each-ref", "for-each-ref",
"--sort=-committerdate", "--sort=-committerdate",
"--format=%(refname)%09%(committerdate:unix)", "--format=%(refname)%09%(committerdate:unix)%09%(objectname)",
refPrefix, refPrefix,
], ],
{ cwd, envOverlay: READ_ONLY_GIT_ENV }, { cwd, envOverlay: READ_ONLY_GIT_ENV },
@@ -255,9 +258,9 @@ async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
.map((line) => { .map((line) => {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) return null; if (!trimmed) return null;
const [name, dateStr] = trimmed.split("\t"); const [name, dateStr, oid] = trimmed.split("\t");
if (!name) return null; if (!name || !oid) return null;
return { name, committerDate: Number(dateStr) || 0 }; return { name, committerDate: Number(dateStr) || 0, oid };
}) })
.filter((ref): ref is GitRef => ref !== null); .filter((ref): ref is GitRef => ref !== null);
} }
@@ -266,6 +269,8 @@ interface BranchSuggestionMeta {
committerDate: number; committerDate: number;
hasLocal: boolean; hasLocal: boolean;
hasRemote: boolean; hasRemote: boolean;
localOid?: string;
remoteOid?: string;
} }
function sortBranchSuggestions( function sortBranchSuggestions(
@@ -304,7 +309,8 @@ export async function listBranchSuggestions(
const requestedLimit = options?.limit ?? 50; const requestedLimit = options?.limit ?? 50;
const limit = Math.max(1, Math.min(200, requestedLimit)); 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([ const [localRefs, remoteRefs] = await Promise.all([
listGitRefs(cwd, "refs/heads"), listGitRefs(cwd, "refs/heads"),
@@ -320,6 +326,8 @@ export async function listBranchSuggestions(
branchMeta.set(normalized, { branchMeta.set(normalized, {
hasLocal: true, hasLocal: true,
hasRemote: existing?.hasRemote ?? false, hasRemote: existing?.hasRemote ?? false,
localOid: ref.oid,
...(existing?.remoteOid ? { remoteOid: existing.remoteOid } : {}),
committerDate: Math.max(ref.committerDate, existing?.committerDate ?? 0), committerDate: Math.max(ref.committerDate, existing?.committerDate ?? 0),
}); });
} }
@@ -332,12 +340,14 @@ export async function listBranchSuggestions(
branchMeta.set(normalized, { branchMeta.set(normalized, {
hasLocal: false, hasLocal: false,
hasRemote: true, hasRemote: true,
remoteOid: ref.oid,
committerDate: ref.committerDate, committerDate: ref.committerDate,
}); });
} else { } else {
branchMeta.set(normalized, { branchMeta.set(normalized, {
...existing, ...existing,
hasRemote: true, hasRemote: true,
remoteOid: ref.oid,
committerDate: Math.max(ref.committerDate, existing.committerDate), committerDate: Math.max(ref.committerDate, existing.committerDate),
}); });
} }
@@ -351,15 +361,46 @@ export async function listBranchSuggestions(
} }
const ordered = sortBranchSuggestions(filteredNames, branchMeta, query); const ordered = sortBranchSuggestions(filteredNames, branchMeta, query);
return ordered.slice(0, limit).map((name) => { return Promise.all(
const meta = branchMeta.get(name); ordered.slice(0, limit).map(async (name): Promise<BranchSuggestion> => {
return { const meta = branchMeta.get(name);
name, const suggestion: BranchSuggestion = {
committerDate: meta?.committerDate ?? 0, name,
hasLocal: meta?.hasLocal ?? false, committerDate: meta?.committerDate ?? 0,
hasRemote: meta?.hasRemote ?? false, 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 { export interface LocalBranchCheckoutResolution {
@@ -138,6 +138,12 @@ export function normalizeBaseRefName(input: string): string {
if (!trimmed) { if (!trimmed) {
throw new Error("Base branch is required"); 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/")) { if (trimmed.startsWith("origin/")) {
return trimmed.slice("origin/".length); return trimmed.slice("origin/".length);
} }
@@ -407,7 +407,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
expect(currentBranch).toBe("Feature.X"); 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 remoteDir = join(tempDir, "remote.git");
const remoteCloneDir = join(tempDir, "remote-clone"); const remoteCloneDir = join(tempDir, "remote-clone");
execFileSync("git", ["init", "--bare", remoteDir]); execFileSync("git", ["init", "--bare", remoteDir]);
@@ -433,19 +433,33 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir });
const result = await createLegacyWorktreeForTest({ const localResult = await createLegacyWorktreeForTest({
branchName: "prefer-origin-feature", branchName: "prefer-local-feature",
cwd: repoDir, cwd: repoDir,
baseBranch: "main", 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", worktreeSlug: "prefer-origin-feature",
runSetup: false, runSetup: false,
paseoHome, 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"); writeFileSync(join(repoDir, "file.txt"), "from-local-only\n");
execFileSync("git", ["add", "file.txt"], { cwd: repoDir }); execFileSync("git", ["add", "file.txt"], { cwd: repoDir });
execFileSync( execFileSync(
+26 -9
View File
@@ -1325,7 +1325,7 @@ async function resolveWorktreeSourcePlan({
const branchName = source.branchName; const branchName = source.branchName;
validateWorktreeBranchName(branchName); validateWorktreeBranchName(branchName);
const normalizedBaseBranch = normalizeRequiredBaseBranch(source.baseBranch); 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 branchExists = await localBranchExists(cwd, branchName);
const base = branchExists ? branchName : resolvedBaseBranch; const base = branchExists ? branchName : resolvedBaseBranch;
const candidateBranch = branchExists ? desiredSlug : branchName; const candidateBranch = branchExists ? desiredSlug : branchName;
@@ -1615,19 +1615,36 @@ function normalizeRequiredBaseBranch(baseBranch: string): string {
async function resolveBaseBranchForWorktree( async function resolveBaseBranchForWorktree(
cwd: string, cwd: string,
normalizedBaseBranch: string, requestedBaseBranch: string,
): Promise<string> { ): Promise<string> {
try { const requested = requestedBaseBranch.trim();
await runGitCommand(["rev-parse", "--verify", `origin/${normalizedBaseBranch}`], { cwd }); const normalized = normalizeRequiredBaseBranch(requested);
return `origin/${normalizedBaseBranch}`; let exactRef: string | null = null;
} catch { if (requested.startsWith("refs/")) {
exactRef = requested;
} else if (requested.startsWith("origin/")) {
exactRef = `refs/remotes/${requested}`;
}
if (exactRef) {
try { try {
await runGitCommand(["rev-parse", "--verify", normalizedBaseBranch], { cwd }); await runGitCommand(["rev-parse", "--verify", exactRef], { cwd });
return normalizedBaseBranch; return exactRef;
} catch { } 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<boolean> { async function localBranchExists(cwd: string, branchName: string): Promise<boolean> {