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:
co-authored by
Matt Cowger
parent
b0a5c09ace
commit
76abc50f82
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user