Default new worktrees to the upstream branch (#2848)
* fix(workspaces): default new worktrees to upstream refs Use the daemon-resolved upstream as the untouched starting ref so unpushed local commits do not leak into new workspaces. Preserve exact base identity for fork remotes while keeping local-first behavior across version drift. * fix(workspaces): preserve exact base refs in git actions Resolve accepted display aliases back to the stored ref before comparisons and updates. Fail closed when a non-origin remote base has no explicit mutable merge target. * fix(workspaces): reject conflicting qualified base refs Display aliases remain accepted for compatibility, but a qualified ref carries remote identity and must not be canonicalized to a different stored upstream. * test(server): make upstream fixtures portable Keep the checkout-status fixture aligned with the optional upstream field, and select main explicitly when cloning a bare test remote whose default HEAD varies by platform. * fix(workspaces): preserve exact upstream refs Qualified remote refs now fail closed when pruned instead of switching lineage. Metadata accepts Git-valid upstream names rather than applying a narrower character whitelist. * fix(workspaces): preserve qualified local base refs Local tracking branches are exact upstream identities too. Keep heuristics only for legacy unqualified metadata so origin cannot replace a stored refs/heads base.
This commit is contained in:
+12
-5
@@ -280,11 +280,18 @@ for machine-readable output.
|
|||||||
|
|
||||||
## Worktree starting refs
|
## Worktree starting refs
|
||||||
|
|
||||||
The New workspace branch picker treats local and `origin/*` refs as distinct starting points when
|
A new worktree starts from the current branch's upstream, or the local branch when it has no
|
||||||
they differ. Local `main` is sent as `refs/heads/main`; remote `origin/main` is sent as
|
upstream. This keeps unpushed local commits out of new workspaces by default. The picker collapses
|
||||||
`refs/remotes/origin/main`. Matching local/remote refs collapse to one local row. Unqualified refs
|
identical refs; divergent local or non-origin refs remain explicit, qualified choices.
|
||||||
from older clients resolve local-first, then fall back to `origin`. Worktrees inherit committed Git
|
|
||||||
state only; uncommitted source-checkout changes are not copied.
|
The daemon sends the exact upstream ref because the remote and branch names cannot be inferred.
|
||||||
|
Worktrees retain that ref for comparisons and updates from base while exposing its branch name to
|
||||||
|
the UI. Merging into base requires a mutable local target: `origin/main` maps to local `main`, while
|
||||||
|
another remote fails closed until the worktree records an explicit local target. Older daemons omit
|
||||||
|
the optional field and retain the previous local-first behavior; older worktree metadata without the
|
||||||
|
exact ref also resolves through its stored branch name.
|
||||||
|
|
||||||
|
Worktrees inherit committed Git state only; uncommitted source-checkout changes are not copied.
|
||||||
|
|
||||||
## paseo.json service scripts
|
## paseo.json service scripts
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import {
|
|||||||
openProjectViaDaemon,
|
openProjectViaDaemon,
|
||||||
openStartingRefPicker,
|
openStartingRefPicker,
|
||||||
pasteGithubPrUrl,
|
pasteGithubPrUrl,
|
||||||
|
captureStartingRefPicker,
|
||||||
|
expectStartingRefRows,
|
||||||
|
startingRefRow,
|
||||||
|
submitNewWorkspaceEmpty,
|
||||||
searchAndSelectBranchInPicker,
|
searchAndSelectBranchInPicker,
|
||||||
selectBranchInPicker,
|
selectBranchInPicker,
|
||||||
selectGitHubPrInPicker,
|
selectGitHubPrInPicker,
|
||||||
@@ -31,7 +35,14 @@ import {
|
|||||||
selectWorkspaceIsolation,
|
selectWorkspaceIsolation,
|
||||||
submitNewWorkspacePrompt,
|
submitNewWorkspacePrompt,
|
||||||
} from "../support/helpers/new-workspace";
|
} from "../support/helpers/new-workspace";
|
||||||
import { createTempGitRepo, readWorktreeBranchInfo } from "../support/helpers/workspace";
|
import {
|
||||||
|
commitLocalOnly,
|
||||||
|
createTempGitRepo,
|
||||||
|
readRepoRef,
|
||||||
|
readWorktreeBaseMetadata,
|
||||||
|
readWorktreeBranchInfo,
|
||||||
|
trackForkUpstream,
|
||||||
|
} from "../support/helpers/workspace";
|
||||||
import {
|
import {
|
||||||
createLocalGithubPrFixture,
|
createLocalGithubPrFixture,
|
||||||
cloneGithubRepoDefaultBranchOnly,
|
cloneGithubRepoDefaultBranchOnly,
|
||||||
@@ -743,6 +754,142 @@ test.describe("New workspace flow", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The starting ref the daemon actually cuts from is the thing that broke: the picker said
|
||||||
|
// one ref and the worktree was created from another. Every assertion here reads the
|
||||||
|
// created worktree's commits or its recorded base, never the trigger text alone.
|
||||||
|
test.describe("default starting ref", () => {
|
||||||
|
async function openWorktreeComposerForRepo(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
repoPath: string,
|
||||||
|
) {
|
||||||
|
const openedProject = await openProjectViaDaemon(client, repoPath);
|
||||||
|
localWorkspaceIds.add(openedProject.workspaceId);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await openNewWorkspaceComposer(page, {
|
||||||
|
projectKey: openedProject.projectKey,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
await selectWorkspaceIsolation(page, "worktree");
|
||||||
|
return openedProject;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWorktreeAndRead(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
openedProject: Awaited<ReturnType<typeof openProjectViaDaemon>>,
|
||||||
|
) {
|
||||||
|
await submitNewWorkspaceEmpty(page);
|
||||||
|
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||||
|
serverId: getServerId(),
|
||||||
|
client,
|
||||||
|
previousWorkspaceId: openedProject.workspaceId,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
|
||||||
|
return {
|
||||||
|
...createdWorkspace,
|
||||||
|
branchInfo: await readWorktreeBranchInfo({
|
||||||
|
worktreePath: createdWorkspace.workspaceDirectory,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("branches off the upstream when the local branch is ahead and the picker is untouched", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const tempRepo = await createTempGitRepo("ref-default-ahead-", { withRemote: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const originHead = readRepoRef(tempRepo.path, "refs/remotes/origin/main");
|
||||||
|
commitLocalOnly(tempRepo.path, "one");
|
||||||
|
const localHead = commitLocalOnly(tempRepo.path, "two");
|
||||||
|
|
||||||
|
const openedProject = await openWorktreeComposerForRepo(page, tempRepo.path);
|
||||||
|
const created = await createWorktreeAndRead(page, openedProject);
|
||||||
|
|
||||||
|
expect(created.branchInfo.hasAncestor(originHead)).toBe(true);
|
||||||
|
expect(created.branchInfo.hasAncestor(localHead)).toBe(false);
|
||||||
|
} finally {
|
||||||
|
await tempRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("branches off the local ref when the local row is chosen explicitly", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const tempRepo = await createTempGitRepo("ref-default-local-pick-", { withRemote: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
commitLocalOnly(tempRepo.path, "one");
|
||||||
|
const localHead = commitLocalOnly(tempRepo.path, "two");
|
||||||
|
|
||||||
|
const openedProject = await openWorktreeComposerForRepo(page, tempRepo.path);
|
||||||
|
|
||||||
|
await openStartingRefPicker(page);
|
||||||
|
await expectStartingRefRows(page, [
|
||||||
|
"main, origin branch",
|
||||||
|
"main, local branch, 2 commits ahead of origin main",
|
||||||
|
]);
|
||||||
|
const screenshotPath = testInfo.outputPath("ref-picker-local-ahead.png");
|
||||||
|
await captureStartingRefPicker(page, screenshotPath);
|
||||||
|
await testInfo.attach("Ref picker: local ahead of upstream", {
|
||||||
|
path: screenshotPath,
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
await startingRefRow(page, "main, local branch, 2 commits ahead of origin main").click();
|
||||||
|
await expectPickerSelected(page, "main (local)");
|
||||||
|
|
||||||
|
const created = await createWorktreeAndRead(page, openedProject);
|
||||||
|
expect(created.branchInfo.hasAncestor(localHead)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await tempRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("branches off a fork's upstream remote and records the branch name", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const tempRepo = await createTempGitRepo("ref-default-fork-", { withRemote: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const upstreamHead = await trackForkUpstream(tempRepo.path);
|
||||||
|
const originHead = readRepoRef(tempRepo.path, "refs/remotes/origin/main");
|
||||||
|
|
||||||
|
const openedProject = await openWorktreeComposerForRepo(page, tempRepo.path);
|
||||||
|
|
||||||
|
await openStartingRefPicker(page);
|
||||||
|
// Branch suggestions only know about origin, so the upstream the fork actually
|
||||||
|
// tracks gets its own row rather than silently sharing origin's. Two rows reading
|
||||||
|
// "main" is the ambiguity this whole change exists to remove.
|
||||||
|
await expectStartingRefRows(page, [
|
||||||
|
"main (upstream), upstream branch",
|
||||||
|
"main, origin branch",
|
||||||
|
]);
|
||||||
|
await expectPickerSelected(page, "main (upstream)");
|
||||||
|
const screenshotPath = testInfo.outputPath("ref-picker-fork.png");
|
||||||
|
await captureStartingRefPicker(page, screenshotPath);
|
||||||
|
await testInfo.attach("Ref picker: fork tracking upstream/main", {
|
||||||
|
path: screenshotPath,
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
await closeBranchPicker(page);
|
||||||
|
|
||||||
|
const created = await createWorktreeAndRead(page, openedProject);
|
||||||
|
|
||||||
|
expect(created.branchInfo.hasAncestor(upstreamHead)).toBe(true);
|
||||||
|
expect(upstreamHead).not.toBe(originHead);
|
||||||
|
// The name is what the UI shows; the ref is what resolves back to this commit.
|
||||||
|
expect(await readWorktreeBaseMetadata(created.workspaceDirectory)).toEqual({
|
||||||
|
baseRefName: "main",
|
||||||
|
baseRef: "refs/remotes/upstream/main",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await tempRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("branch picker opens via keyboard and selects the filtered option on Enter", async ({
|
test("branch picker opens via keyboard and selects the filtered option on Enter", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -348,6 +348,23 @@ export async function searchAndSelectBranchInPicker(page: Page, name: string): P
|
|||||||
await selectBranchInPicker(page, name);
|
await selectBranchInPicker(page, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ref picker rows are named for a user: "main, origin branch" is the upstream copy and
|
||||||
|
// "main, local branch, 2 commits ahead of origin main" is the local one.
|
||||||
|
export function startingRefRow(page: Page, accessibleName: string) {
|
||||||
|
return page.getByRole("button", { name: accessibleName, exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectStartingRefRows(page: Page, accessibleNames: string[]): Promise<void> {
|
||||||
|
for (const name of accessibleNames) {
|
||||||
|
await expect(startingRefRow(page, name)).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureStartingRefPicker(page: Page, screenshotPath: string): Promise<void> {
|
||||||
|
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByTestId("combobox-desktop-container").screenshot({ path: screenshotPath });
|
||||||
|
}
|
||||||
|
|
||||||
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
|
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
|
||||||
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
|
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
|
||||||
await expect(prRow).toBeVisible({ timeout: 30_000 });
|
await expect(prRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises";
|
import { mkdtemp, readFile, writeFile, rm, mkdir, realpath } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@@ -159,6 +159,80 @@ export async function createTempDirectory(prefix = "paseo-e2e-dir-"): Promise<Te
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A commit that exists only in the local checkout. The whole point of defaulting a new
|
||||||
|
* worktree to the upstream is that these commits do not leak into it.
|
||||||
|
*/
|
||||||
|
export function commitLocalOnly(repoPath: string, marker: string): string {
|
||||||
|
execSync(`git commit --allow-empty -m ${JSON.stringify(`local only ${marker}`)}`, {
|
||||||
|
cwd: repoPath,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
return execSync("git rev-parse HEAD", { cwd: repoPath, stdio: "pipe" }).toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fork checkout: `origin` is the fork, `upstream` is the source repo, and the current
|
||||||
|
* branch tracks `upstream/main`. Upstream carries a commit origin does not, so a worktree
|
||||||
|
* based on the wrong remote is provable rather than coincidentally identical.
|
||||||
|
*/
|
||||||
|
export async function trackForkUpstream(repoPath: string): Promise<string> {
|
||||||
|
const upstreamDir = path.join(repoPath, "upstream.git");
|
||||||
|
const upstreamClone = await mkdtemp(path.join(await resolveTempRoot(), "paseo-e2e-upstream-"));
|
||||||
|
await mkdir(upstreamDir, { recursive: true });
|
||||||
|
execSync(`git init --bare -b main ${upstreamDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||||
|
execSync(`git remote add upstream ${upstreamDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||||
|
execSync("git push upstream main", { cwd: repoPath, stdio: "ignore" });
|
||||||
|
|
||||||
|
execSync(`git clone ${upstreamDir} ${upstreamClone}`, { stdio: "ignore" });
|
||||||
|
execSync('git config user.email "e2e@paseo.test"', { cwd: upstreamClone, stdio: "ignore" });
|
||||||
|
execSync('git config user.name "Paseo E2E"', { cwd: upstreamClone, stdio: "ignore" });
|
||||||
|
execSync("git config commit.gpgsign false", { cwd: upstreamClone, stdio: "ignore" });
|
||||||
|
execSync('git commit --allow-empty -m "upstream only"', {
|
||||||
|
cwd: upstreamClone,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
execSync("git push origin main", { cwd: upstreamClone, stdio: "ignore" });
|
||||||
|
await rm(upstreamClone, { recursive: true, force: true });
|
||||||
|
|
||||||
|
execSync("git fetch upstream", { cwd: repoPath, stdio: "ignore" });
|
||||||
|
execSync("git branch --set-upstream-to=upstream/main main", {
|
||||||
|
cwd: repoPath,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
return execSync("git rev-parse refs/remotes/upstream/main", { cwd: repoPath, stdio: "pipe" })
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readRepoRef(repoPath: string, ref: string): string {
|
||||||
|
return execSync(`git rev-parse ${JSON.stringify(ref)}`, { cwd: repoPath, stdio: "pipe" })
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base identity the daemon recorded in worktree.json: the display name the UI reads back,
|
||||||
|
* and the exact ref every comparison and action resolves through.
|
||||||
|
*/
|
||||||
|
export async function readWorktreeBaseMetadata(
|
||||||
|
worktreePath: string,
|
||||||
|
): Promise<{ baseRefName: string; baseRef?: string }> {
|
||||||
|
const gitDir = execSync("git rev-parse --absolute-git-dir", { cwd: worktreePath, stdio: "pipe" })
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
const metadata = JSON.parse(
|
||||||
|
await readFile(path.join(gitDir, "paseo", "worktree.json"), "utf8"),
|
||||||
|
) as { baseRefName?: string; baseRef?: string };
|
||||||
|
if (!metadata.baseRefName) {
|
||||||
|
throw new Error(`worktree.json has no baseRefName: ${worktreePath}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
baseRefName: metadata.baseRefName,
|
||||||
|
...(metadata.baseRef ? { baseRef: metadata.baseRef } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
|
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
|
||||||
currentBranch: string;
|
currentBranch: string;
|
||||||
hasAncestor: (ref: string) => boolean;
|
hasAncestor: (ref: string) => boolean;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 {
|
import {
|
||||||
|
type BranchPickerDetail,
|
||||||
branchPickerOptionId,
|
branchPickerOptionId,
|
||||||
buildBranchPickerItems,
|
buildBranchPickerItems,
|
||||||
|
buildPickerOptionData,
|
||||||
|
defaultBasePickerItem,
|
||||||
pickerItemToCheckoutRequest,
|
pickerItemToCheckoutRequest,
|
||||||
resolveCheckoutRequest,
|
|
||||||
type PickerItem,
|
type PickerItem,
|
||||||
} from "./new-workspace-picker-item";
|
} from "./new-workspace-picker-item";
|
||||||
|
|
||||||
@@ -104,7 +105,7 @@ describe("pickerItemToCheckoutRequest", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("buildBranchPickerItems", () => {
|
describe("buildBranchPickerItems", () => {
|
||||||
it("hides the origin duplicate when local and origin match", () => {
|
it("keeps a single origin row when local and origin match", () => {
|
||||||
expect(
|
expect(
|
||||||
buildBranchPickerItems([
|
buildBranchPickerItems([
|
||||||
{
|
{
|
||||||
@@ -120,14 +121,14 @@ describe("buildBranchPickerItems", () => {
|
|||||||
{
|
{
|
||||||
kind: "branch",
|
kind: "branch",
|
||||||
name: "main",
|
name: "main",
|
||||||
refName: "refs/heads/main",
|
refName: "refs/remotes/origin/main",
|
||||||
accessibilityLabel: "main, local branch, up to date with origin main",
|
accessibilityLabel: "main, origin branch",
|
||||||
committerDate: 10,
|
committerDate: 10,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates compact local and origin rows when the refs differ", () => {
|
it("puts the origin row first and disambiguates the local row when the refs differ", () => {
|
||||||
expect(
|
expect(
|
||||||
buildBranchPickerItems([
|
buildBranchPickerItems([
|
||||||
{
|
{
|
||||||
@@ -143,17 +144,16 @@ describe("buildBranchPickerItems", () => {
|
|||||||
{
|
{
|
||||||
kind: "branch",
|
kind: "branch",
|
||||||
name: "main",
|
name: "main",
|
||||||
refName: "refs/heads/main",
|
refName: "refs/remotes/origin/main",
|
||||||
divergenceLabel: "+3 −2",
|
accessibilityLabel: "main, origin branch",
|
||||||
accessibilityLabel: "main, local branch, 3 commits ahead and 2 behind origin main",
|
|
||||||
committerDate: 10,
|
committerDate: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "branch",
|
kind: "branch",
|
||||||
name: "origin/main",
|
name: "main (local)",
|
||||||
refName: "refs/remotes/origin/main",
|
refName: "refs/heads/main",
|
||||||
divergenceLabel: "+2 −3",
|
divergenceLabel: "+3 −2",
|
||||||
accessibilityLabel: "origin main, 2 commits ahead and 3 behind local main",
|
accessibilityLabel: "main, local branch, 3 commits ahead and 2 behind origin main",
|
||||||
committerDate: 10,
|
committerDate: 10,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -172,9 +172,30 @@ describe("buildBranchPickerItems", () => {
|
|||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
kind: "branch",
|
kind: "branch",
|
||||||
name: "origin/release",
|
name: "release",
|
||||||
refName: "refs/remotes/origin/release",
|
refName: "refs/remotes/origin/release",
|
||||||
accessibilityLabel: "origin release, origin branch",
|
accessibilityLabel: "release, origin branch",
|
||||||
|
committerDate: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the plain name for a local-only branch", () => {
|
||||||
|
expect(
|
||||||
|
buildBranchPickerItems([
|
||||||
|
{
|
||||||
|
name: "scratch",
|
||||||
|
committerDate: 5,
|
||||||
|
hasLocal: true,
|
||||||
|
hasRemote: false,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
kind: "branch",
|
||||||
|
name: "scratch",
|
||||||
|
refName: "refs/heads/scratch",
|
||||||
|
accessibilityLabel: "scratch, local branch",
|
||||||
committerDate: 5,
|
committerDate: 5,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -194,15 +215,15 @@ describe("buildBranchPickerItems", () => {
|
|||||||
{
|
{
|
||||||
kind: "branch",
|
kind: "branch",
|
||||||
name: "main",
|
name: "main",
|
||||||
refName: "refs/heads/main",
|
refName: "refs/remotes/origin/main",
|
||||||
accessibilityLabel: "main, local branch",
|
accessibilityLabel: "main, origin branch",
|
||||||
committerDate: 10,
|
committerDate: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "branch",
|
kind: "branch",
|
||||||
name: "origin/main",
|
name: "main (local)",
|
||||||
refName: "refs/remotes/origin/main",
|
refName: "refs/heads/main",
|
||||||
accessibilityLabel: "origin main, origin branch",
|
accessibilityLabel: "main, local branch",
|
||||||
committerDate: 10,
|
committerDate: 10,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -221,38 +242,101 @@ describe("buildBranchPickerItems", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveCheckoutRequest", () => {
|
const mainRow: BranchPickerDetail = {
|
||||||
const checkoutStatus = {
|
name: "main",
|
||||||
currentBranch: "feature/current",
|
committerDate: 10,
|
||||||
} as CheckoutStatusPayload;
|
hasLocal: true,
|
||||||
|
hasRemote: true,
|
||||||
|
localAhead: 2,
|
||||||
|
localBehind: 0,
|
||||||
|
};
|
||||||
|
|
||||||
it("branches from the loaded current branch when nothing was picked", () => {
|
describe("buildPickerOptionData", () => {
|
||||||
expect(resolveCheckoutRequest(null, checkoutStatus)).toEqual({
|
it("marks origin and keeps an ahead local main explicit", () => {
|
||||||
action: "branch-off",
|
const baseItem = defaultBasePickerItem({
|
||||||
refName: "feature/current",
|
currentBranch: "main",
|
||||||
|
upstreamRef: "refs/remotes/origin/main",
|
||||||
});
|
});
|
||||||
|
const data = buildPickerOptionData({ branchDetails: [mainRow], prItems: [], baseItem });
|
||||||
|
|
||||||
|
expect(data.options.map((option) => option.label)).toEqual(["main", "main (local)"]);
|
||||||
|
expect(data.selectedOptionId).toBe(branchPickerOptionId("refs/remotes/origin/main"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers an explicit picker selection", () => {
|
it("adds and disambiguates a fork upstream absent from branch suggestions", () => {
|
||||||
expect(
|
const baseItem = defaultBasePickerItem({
|
||||||
resolveCheckoutRequest(
|
currentBranch: "main",
|
||||||
{
|
upstreamRef: "refs/remotes/upstream/main",
|
||||||
kind: "branch",
|
|
||||||
name: "feature/picked",
|
|
||||||
refName: "refs/heads/feature/picked",
|
|
||||||
accessibilityLabel: "feature/picked, local branch",
|
|
||||||
},
|
|
||||||
checkoutStatus,
|
|
||||||
),
|
|
||||||
).toEqual({
|
|
||||||
action: "branch-off",
|
|
||||||
refName: "refs/heads/feature/picked",
|
|
||||||
});
|
});
|
||||||
|
const data = buildPickerOptionData({
|
||||||
|
branchDetails: [{ ...mainRow, localAhead: 0, localBehind: 0 }],
|
||||||
|
prItems: [],
|
||||||
|
baseItem,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(data.options.map((option) => option.label)).toEqual(["main (upstream)", "main"]);
|
||||||
|
expect(data.selectedOptionId).toBe(branchPickerOptionId("refs/remotes/upstream/main"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves the server-default intent for a detached checkout", () => {
|
it("marks a visible row on an old daemon when local and origin are in sync", () => {
|
||||||
|
const baseItem = defaultBasePickerItem({ currentBranch: "main" });
|
||||||
|
const data = buildPickerOptionData({
|
||||||
|
branchDetails: [{ ...mainRow, localAhead: 0, localBehind: 0 }],
|
||||||
|
prItems: [],
|
||||||
|
baseItem,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(data.options.map((option) => option.label)).toEqual(["main (local)", "main"]);
|
||||||
|
expect(data.selectedOptionId).toBe(branchPickerOptionId("refs/heads/main"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an explicit local selection marked", () => {
|
||||||
|
const baseItem: PickerItem = {
|
||||||
|
kind: "branch",
|
||||||
|
name: "main (local)",
|
||||||
|
refName: "refs/heads/main",
|
||||||
|
accessibilityLabel: "main, local branch",
|
||||||
|
};
|
||||||
|
const data = buildPickerOptionData({
|
||||||
|
branchDetails: [mainRow],
|
||||||
|
prItems: [],
|
||||||
|
baseItem,
|
||||||
|
});
|
||||||
|
const selected = data.itemById.get(data.selectedOptionId) ?? null;
|
||||||
|
expect(pickerItemToCheckoutRequest(selected)).toEqual({
|
||||||
|
action: "branch-off",
|
||||||
|
refName: "refs/heads/main",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("defaultBasePickerItem", () => {
|
||||||
|
it("uses origin when the current branch tracks origin", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveCheckoutRequest(null, { ...checkoutStatus, currentBranch: null }),
|
defaultBasePickerItem({
|
||||||
).toBeUndefined();
|
currentBranch: "main",
|
||||||
|
upstreamRef: "refs/remotes/origin/main",
|
||||||
|
}),
|
||||||
|
).toMatchObject({ refName: "refs/remotes/origin/main", name: "main" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the exact non-origin upstream", () => {
|
||||||
|
expect(
|
||||||
|
defaultBasePickerItem({
|
||||||
|
currentBranch: "main",
|
||||||
|
upstreamRef: "refs/remotes/upstream/main",
|
||||||
|
}),
|
||||||
|
).toMatchObject({ refName: "refs/remotes/upstream/main", name: "main" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps old-daemon behavior local", () => {
|
||||||
|
expect(defaultBasePickerItem({ currentBranch: "main" })).toMatchObject({
|
||||||
|
refName: "refs/heads/main",
|
||||||
|
name: "main",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no default for detached HEAD", () => {
|
||||||
|
expect(defaultBasePickerItem({ currentBranch: null })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
|
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||||
import type { CheckoutStatusPayload } from "@/git/checkout-status-cache";
|
import type { ComboboxOptionModel } from "@/components/ui/combobox-options";
|
||||||
|
import { getForgePresentation } from "@/git/forge";
|
||||||
|
|
||||||
export interface BranchPickerDetail {
|
export interface BranchPickerDetail {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -31,6 +32,8 @@ export type PickerCheckoutRequest = Pick<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
const BRANCH_OPTION_PREFIX = "branch:";
|
const BRANCH_OPTION_PREFIX = "branch:";
|
||||||
|
const PR_OPTION_PREFIX = "github-pr:";
|
||||||
|
const REMOTE_TRACKING_PREFIX = "refs/remotes/";
|
||||||
|
|
||||||
export function branchPickerOptionId(refName: string): string {
|
export function branchPickerOptionId(refName: string): string {
|
||||||
return `${BRANCH_OPTION_PREFIX}${refName}`;
|
return `${BRANCH_OPTION_PREFIX}${refName}`;
|
||||||
@@ -82,11 +85,27 @@ export function buildBranchPickerItems(details: readonly BranchPickerDetail[]):
|
|||||||
const localBehind = detail.localBehind;
|
const localBehind = detail.localBehind;
|
||||||
const hasDivergence =
|
const hasDivergence =
|
||||||
hasLocal && hasRemote && localAhead !== undefined && localBehind !== undefined;
|
hasLocal && hasRemote && localAhead !== undefined && localBehind !== undefined;
|
||||||
|
const refsDiffer = hasDivergence && (localAhead > 0 || localBehind > 0);
|
||||||
|
// One row when the two refs point at the same commit, two rows when they differ or
|
||||||
|
// when the divergence is unknown.
|
||||||
|
const showsBothRefs = hasLocal && hasRemote && (!hasDivergence || refsDiffer);
|
||||||
|
|
||||||
if (hasLocal) {
|
// The origin row goes first because it is the default base, and it never states
|
||||||
const localItem: Extract<PickerItem, { kind: "branch" }> = {
|
// divergence: it is the reference point the local row is measured against.
|
||||||
|
if (hasRemote) {
|
||||||
|
items.push({
|
||||||
kind: "branch",
|
kind: "branch",
|
||||||
name: detail.name,
|
name: detail.name,
|
||||||
|
refName: `refs/remotes/origin/${detail.name}`,
|
||||||
|
accessibilityLabel: `${detail.name}, origin branch`,
|
||||||
|
committerDate: detail.committerDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasLocal && (showsBothRefs || !hasRemote)) {
|
||||||
|
const localItem: Extract<PickerItem, { kind: "branch" }> = {
|
||||||
|
kind: "branch",
|
||||||
|
name: showsBothRefs ? `${detail.name} (local)` : detail.name,
|
||||||
refName: `refs/heads/${detail.name}`,
|
refName: `refs/heads/${detail.name}`,
|
||||||
accessibilityLabel: `${detail.name}, local branch`,
|
accessibilityLabel: `${detail.name}, local branch`,
|
||||||
committerDate: detail.committerDate,
|
committerDate: detail.committerDate,
|
||||||
@@ -101,33 +120,64 @@ export function buildBranchPickerItems(details: readonly BranchPickerDetail[]):
|
|||||||
}
|
}
|
||||||
items.push(localItem);
|
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;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BaseRefCheckoutStatus {
|
||||||
|
currentBranch: string | null;
|
||||||
|
upstreamRef?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display only. The exact ref is what every request carries; this is just how a ref reads in
|
||||||
|
// a row label, so "refs/remotes/origin/other-name" shows as "other-name".
|
||||||
|
function branchNameFromRef(refName: string): string {
|
||||||
|
if (refName.startsWith("refs/heads/")) return refName.slice("refs/heads/".length);
|
||||||
|
if (refName.startsWith(REMOTE_TRACKING_PREFIX)) {
|
||||||
|
const remainder = refName.slice(REMOTE_TRACKING_PREFIX.length);
|
||||||
|
const separator = remainder.indexOf("/");
|
||||||
|
return separator === -1 ? remainder : remainder.slice(separator + 1);
|
||||||
|
}
|
||||||
|
return refName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where a ref lives, for disambiguating two rows that would otherwise read the same.
|
||||||
|
function refQualifier(refName: string): string | null {
|
||||||
|
if (refName.startsWith("refs/heads/")) return "local";
|
||||||
|
if (refName.startsWith(REMOTE_TRACKING_PREFIX)) {
|
||||||
|
const remainder = refName.slice(REMOTE_TRACKING_PREFIX.length);
|
||||||
|
const separator = remainder.indexOf("/");
|
||||||
|
return separator === -1 ? null : remainder.slice(0, separator);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one owner of "what do we branch off when the user picked nothing". The checkmarked
|
||||||
|
// row, the trigger label, and the created ref all read this; computing it twice is how the
|
||||||
|
// picker once showed local main while branching off something else.
|
||||||
|
//
|
||||||
|
// The upstream wins when the branch has one, because branching off the local ref silently
|
||||||
|
// carries unpushed commits into the new workspace. The daemon sends the resolved ref rather
|
||||||
|
// than a remote name, so a fork tracking upstream/main branches from upstream/main.
|
||||||
|
export function defaultBasePickerItem(status: BaseRefCheckoutStatus): PickerItem | null {
|
||||||
|
const currentBranch = status.currentBranch;
|
||||||
|
if (!currentBranch) return null;
|
||||||
|
// COMPAT(checkoutUpstreamRef): added in v0.2.6, remove after 2027-02-01 once the daemon
|
||||||
|
// floor sends upstreamRef. Daemons that predate it omit the field, which lands on the
|
||||||
|
// local ref — the base those daemons always used.
|
||||||
|
const refName = status.upstreamRef ?? `refs/heads/${currentBranch}`;
|
||||||
|
// The upstream branch can be named differently from the local one, so the row reads the
|
||||||
|
// ref rather than the branch the user happens to be on.
|
||||||
|
const name = branchNameFromRef(refName);
|
||||||
|
return {
|
||||||
|
kind: "branch",
|
||||||
|
name,
|
||||||
|
refName,
|
||||||
|
accessibilityLabel: status.upstreamRef ? `${name}, upstream branch` : `${name}, local branch`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function pickerItemToCheckoutRequest(
|
export function pickerItemToCheckoutRequest(
|
||||||
item: PickerItem | null,
|
item: PickerItem | null,
|
||||||
): PickerCheckoutRequest | undefined {
|
): PickerCheckoutRequest | undefined {
|
||||||
@@ -159,15 +209,98 @@ export function pickerItemToCheckoutRequest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveCheckoutRequest(
|
export function prPickerOptionId(number: number): string {
|
||||||
selectedItem: PickerItem | null,
|
return `${PR_OPTION_PREFIX}${number}`;
|
||||||
checkoutStatus: CheckoutStatusPayload,
|
}
|
||||||
): PickerCheckoutRequest | undefined {
|
|
||||||
const selectedCheckoutRequest = pickerItemToCheckoutRequest(selectedItem);
|
export function pickerOptionId(item: PickerItem): string {
|
||||||
if (selectedCheckoutRequest) return selectedCheckoutRequest;
|
return item.kind === "branch"
|
||||||
if (!checkoutStatus.currentBranch) return undefined;
|
? branchPickerOptionId(item.refName)
|
||||||
|
: prPickerOptionId(item.item.number);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrLabel(item: Pick<ForgeSearchItem, "forge" | "number" | "title">): string {
|
||||||
|
const presentation = getForgePresentation(item.forge ?? "github");
|
||||||
|
return `${presentation.numberPrefix}${item.number} ${item.title}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickerItemLabel(item: PickerItem): string {
|
||||||
|
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PickerOptionData {
|
||||||
|
options: ComboboxOptionModel[];
|
||||||
|
itemById: Map<string, PickerItem>;
|
||||||
|
selectedOptionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimedOption {
|
||||||
|
option: ComboboxOptionModel;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPickerOptionData(input: {
|
||||||
|
branchDetails: readonly BranchPickerDetail[];
|
||||||
|
prItems: readonly ForgeSearchItem[];
|
||||||
|
baseItem: PickerItem | null;
|
||||||
|
}): PickerOptionData {
|
||||||
|
const itemById = new Map<string, PickerItem>();
|
||||||
|
const timedOptions: TimedOption[] = [];
|
||||||
|
|
||||||
|
for (const branch of buildBranchPickerItems(input.branchDetails)) {
|
||||||
|
if (branch.kind !== "branch") continue;
|
||||||
|
const id = branchPickerOptionId(branch.refName);
|
||||||
|
itemById.set(id, branch);
|
||||||
|
timedOptions.push({
|
||||||
|
option: { id, label: pickerItemLabel(branch) },
|
||||||
|
timestamp: branch.committerDate ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pr of input.prItems) {
|
||||||
|
if (!pr.headRefName) continue;
|
||||||
|
const id = prPickerOptionId(pr.number);
|
||||||
|
itemById.set(id, { kind: "github-pr", item: pr });
|
||||||
|
const updatedAtMs = pr.updatedAt ? Date.parse(pr.updatedAt) : 0;
|
||||||
|
const timestamp = Number.isNaN(updatedAtMs) ? 0 : Math.floor(updatedAtMs / 1000);
|
||||||
|
timedOptions.push({ option: { id, label: formatPrLabel(pr) }, timestamp });
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseItem = input.baseItem;
|
||||||
|
if (!baseItem) {
|
||||||
|
timedOptions.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
return { options: timedOptions.map((t) => t.option), itemById, selectedOptionId: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedOptionId = pickerOptionId(baseItem);
|
||||||
|
if (!itemById.has(selectedOptionId)) {
|
||||||
|
// The label lives on the item, so the row and the trigger read it from the same place.
|
||||||
|
const labeledItem = disambiguate(baseItem, timedOptions);
|
||||||
|
itemById.set(selectedOptionId, labeledItem);
|
||||||
|
timedOptions.push({
|
||||||
|
option: { id: selectedOptionId, label: pickerItemLabel(labeledItem) },
|
||||||
|
// The base sorts first: it is what a workspace is created from unless you pick something.
|
||||||
|
timestamp: Number.POSITIVE_INFINITY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
timedOptions.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
return { options: timedOptions.map((t) => t.option), itemById, selectedOptionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two rows reading "main" would be a coin flip for the user, so the added row says where its
|
||||||
|
// ref lives. Only when it collides — a lone row needs no qualifier.
|
||||||
|
function disambiguate(item: PickerItem, existing: readonly TimedOption[]): PickerItem {
|
||||||
|
if (item.kind !== "branch") return item;
|
||||||
|
if (!existing.some((entry) => entry.option.label === item.name)) return item;
|
||||||
|
const qualifier = refQualifier(item.refName);
|
||||||
|
if (!qualifier) return item;
|
||||||
|
const name = `${item.name} (${qualifier})`;
|
||||||
return {
|
return {
|
||||||
action: "branch-off",
|
...item,
|
||||||
refName: checkoutStatus.currentBranch,
|
name,
|
||||||
|
// Accessibility labels are built as `${name}${suffix}`, so re-prefixing keeps the spoken
|
||||||
|
// label and the visible one the same string.
|
||||||
|
accessibilityLabel: `${name}${item.accessibilityLabel.slice(item.name.length)}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
|||||||
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { getForgePresentation } from "@/git/forge";
|
|
||||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||||
import { generateMessageId } from "@/types/stream";
|
import { generateMessageId } from "@/types/stream";
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
@@ -90,12 +89,14 @@ import {
|
|||||||
remapDraftCwdToWorkspace,
|
remapDraftCwdToWorkspace,
|
||||||
} from "./new-workspace-fork-context";
|
} from "./new-workspace-fork-context";
|
||||||
import {
|
import {
|
||||||
branchPickerOptionId,
|
buildPickerOptionData,
|
||||||
buildBranchPickerItems,
|
defaultBasePickerItem,
|
||||||
resolveCheckoutRequest,
|
pickerItemLabel,
|
||||||
|
pickerItemToCheckoutRequest,
|
||||||
type BranchPickerDetail,
|
type BranchPickerDetail,
|
||||||
type PickerCheckoutRequest,
|
type PickerCheckoutRequest,
|
||||||
type PickerItem,
|
type PickerItem,
|
||||||
|
type PickerOptionData,
|
||||||
} from "./new-workspace-picker-item";
|
} from "./new-workspace-picker-item";
|
||||||
import {
|
import {
|
||||||
clearPickerPrAttachmentForTargetChange,
|
clearPickerPrAttachmentForTargetChange,
|
||||||
@@ -169,12 +170,6 @@ interface NewWorkspaceScreenProps {
|
|||||||
draftId?: string;
|
draftId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PickerOptionData {
|
|
||||||
options: ComboboxOptionType[];
|
|
||||||
itemById: Map<string, PickerItem>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
||||||
const PROJECT_PICK_ACTIONS: readonly KeyboardActionId[] = ["workspace.project.pick"];
|
const PROJECT_PICK_ACTIONS: readonly KeyboardActionId[] = ["workspace.project.pick"];
|
||||||
@@ -475,10 +470,6 @@ function ProjectOptionItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function prOptionId(number: number): string {
|
|
||||||
return `${PR_OPTION_PREFIX}${number}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function NewWorkspacePickerOption({
|
function NewWorkspacePickerOption({
|
||||||
option,
|
option,
|
||||||
selected,
|
selected,
|
||||||
@@ -590,57 +581,10 @@ function AddProjectPickerAction({ onPress }: { onPress: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPrLabel(item: Pick<ForgeSearchItem, "forge" | "number" | "title">): string {
|
|
||||||
const presentation = getForgePresentation(item.forge ?? "github");
|
|
||||||
return `${presentation.numberPrefix}${item.number} ${item.title}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickerItemLabel(item: PickerItem): string {
|
|
||||||
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickerItemTriggerLabel(item: PickerItem): string {
|
|
||||||
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
|
|
||||||
}
|
|
||||||
|
|
||||||
function newWorkspaceHostOptionTestID(serverId: string): string {
|
function newWorkspaceHostOptionTestID(serverId: string): string {
|
||||||
return `new-workspace-host-picker-option-${serverId}`;
|
return `new-workspace-host-picker-option-${serverId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computePickerOptionData(
|
|
||||||
branchDetails: readonly BranchPickerDetail[],
|
|
||||||
prItems: ReadonlyArray<ForgeSearchItem>,
|
|
||||||
): PickerOptionData {
|
|
||||||
const idMap = new Map<string, PickerItem>();
|
|
||||||
|
|
||||||
interface TimedOption {
|
|
||||||
option: ComboboxOptionType;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
const timedOptions: TimedOption[] = [];
|
|
||||||
|
|
||||||
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, branch);
|
|
||||||
timedOptions.push({ option, timestamp: branch.committerDate ?? 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const pr of prItems) {
|
|
||||||
if (!pr.headRefName) continue;
|
|
||||||
const id = prOptionId(pr.number);
|
|
||||||
const option = { id, label: formatPrLabel(pr) };
|
|
||||||
idMap.set(id, { kind: "github-pr", item: pr });
|
|
||||||
const updatedAtMs = pr.updatedAt ? Date.parse(pr.updatedAt) : 0;
|
|
||||||
const timestamp = Number.isNaN(updatedAtMs) ? 0 : Math.floor(updatedAtMs / 1000);
|
|
||||||
timedOptions.push({ option, timestamp });
|
|
||||||
}
|
|
||||||
|
|
||||||
timedOptions.sort((a, b) => b.timestamp - a.timestamp);
|
|
||||||
return { options: timedOptions.map((t) => t.option), itemById: idMap };
|
|
||||||
}
|
|
||||||
|
|
||||||
function IsolationPickerTrigger({
|
function IsolationPickerTrigger({
|
||||||
pickerAnchorRef,
|
pickerAnchorRef,
|
||||||
onPress,
|
onPress,
|
||||||
@@ -1655,7 +1599,6 @@ export function NewWorkspaceScreen({
|
|||||||
cwd: selectedSourceDirectory ?? "",
|
cwd: selectedSourceDirectory ?? "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentBranch = checkoutStatus?.currentBranch ?? null;
|
|
||||||
const worktreeSupport = selectedProject
|
const worktreeSupport = selectedProject
|
||||||
? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId })
|
? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId })
|
||||||
: "unsupported";
|
: "unsupported";
|
||||||
@@ -1709,25 +1652,23 @@ export function NewWorkspaceScreen({
|
|||||||
return githubPrSearchQuery.data?.items ?? [];
|
return githubPrSearchQuery.data?.items ?? [];
|
||||||
}, [forgeSearchAuthenticated, githubPrSearchQuery.data?.items]);
|
}, [forgeSearchAuthenticated, githubPrSearchQuery.data?.items]);
|
||||||
|
|
||||||
const { options, itemById }: PickerOptionData = useMemo(
|
const baseItem = useMemo(
|
||||||
() => computePickerOptionData(branchDetails, prItems),
|
() => selectedItem ?? (checkoutStatus ? defaultBasePickerItem(checkoutStatus) : null),
|
||||||
[branchDetails, prItems],
|
[checkoutStatus, selectedItem],
|
||||||
|
);
|
||||||
|
const { options, itemById, selectedOptionId }: PickerOptionData = useMemo(
|
||||||
|
() =>
|
||||||
|
buildPickerOptionData({
|
||||||
|
branchDetails,
|
||||||
|
prItems,
|
||||||
|
baseItem,
|
||||||
|
}),
|
||||||
|
[baseItem, branchDetails, prItems],
|
||||||
);
|
);
|
||||||
const triggerLabel = useMemo(() => {
|
const triggerLabel = useMemo(() => {
|
||||||
if (selectedItem) return pickerItemTriggerLabel(selectedItem);
|
const displayItem = itemById.get(selectedOptionId);
|
||||||
return currentBranch ?? "main";
|
return displayItem ? pickerItemLabel(displayItem) : "main";
|
||||||
}, [currentBranch, selectedItem]);
|
}, [itemById, selectedOptionId]);
|
||||||
|
|
||||||
const selectedOptionId = useMemo(() => {
|
|
||||||
if (!selectedItem) {
|
|
||||||
if (!currentBranch) return "";
|
|
||||||
const exactLocalId = branchPickerOptionId(`refs/heads/${currentBranch}`);
|
|
||||||
return itemById.has(exactLocalId) ? exactLocalId : branchPickerOptionId(currentBranch);
|
|
||||||
}
|
|
||||||
return selectedItem.kind === "branch"
|
|
||||||
? branchPickerOptionId(selectedItem.refName)
|
|
||||||
: prOptionId(selectedItem.item.number);
|
|
||||||
}, [currentBranch, itemById, selectedItem]);
|
|
||||||
const selectPickerItem = useCallback(
|
const selectPickerItem = useCallback(
|
||||||
(item: PickerItem) => {
|
(item: PickerItem) => {
|
||||||
const nextAttachments = syncPickerPrAttachment({
|
const nextAttachments = syncPickerPrAttachment({
|
||||||
@@ -1929,15 +1870,17 @@ export function NewWorkspaceScreen({
|
|||||||
}
|
}
|
||||||
const connectedClient = withConnectedClient();
|
const connectedClient = withConnectedClient();
|
||||||
const createsWorktree = !supportsWorkspaceMultiplicity || effectiveIsolation === "worktree";
|
const createsWorktree = !supportsWorkspaceMultiplicity || effectiveIsolation === "worktree";
|
||||||
const checkoutRequest = createsWorktree
|
const checkoutStatusForCreate = createsWorktree
|
||||||
? resolveCheckoutRequest(
|
? await ensureCheckoutStatus({
|
||||||
selectedItem,
|
queryClient,
|
||||||
await ensureCheckoutStatus({
|
client: connectedClient,
|
||||||
queryClient,
|
serverId: selectedServerId,
|
||||||
client: connectedClient,
|
cwd: selectedSourceDirectory,
|
||||||
serverId: selectedServerId,
|
})
|
||||||
cwd: selectedSourceDirectory,
|
: null;
|
||||||
}),
|
const checkoutRequest = checkoutStatusForCreate
|
||||||
|
? pickerItemToCheckoutRequest(
|
||||||
|
selectedItem ?? defaultBasePickerItem(checkoutStatusForCreate),
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const normalizedWorkspace = supportsWorkspaceMultiplicity
|
const normalizedWorkspace = supportsWorkspaceMultiplicity
|
||||||
|
|||||||
@@ -4060,6 +4060,13 @@ const CheckoutStatusCommonSchema = z.object({
|
|||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
error: CheckoutErrorSchema.nullable(),
|
error: CheckoutErrorSchema.nullable(),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
|
// The full ref currentBranch tracks, as git resolves `<branch>@{upstream}`:
|
||||||
|
// "refs/remotes/origin/main", "refs/remotes/upstream/main" on a fork, or a
|
||||||
|
// "refs/heads/..." ref for a branch tracking a local branch. Null when there is no
|
||||||
|
// upstream. Clients use it verbatim — the remote is not necessarily origin and the
|
||||||
|
// upstream branch name is not necessarily currentBranch, so composing one is wrong.
|
||||||
|
// aheadOfOrigin/behindOfOrigin are measured against exactly this ref.
|
||||||
|
upstreamRef: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const CheckoutStatusNotGitSchema = CheckoutStatusCommonSchema.extend({
|
const CheckoutStatusNotGitSchema = CheckoutStatusCommonSchema.extend({
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({
|
|||||||
isDirty: null,
|
isDirty: null,
|
||||||
baseRef: null,
|
baseRef: null,
|
||||||
aheadBehind: null,
|
aheadBehind: null,
|
||||||
|
upstreamRef: null,
|
||||||
aheadOfOrigin: null,
|
aheadOfOrigin: null,
|
||||||
behindOfOrigin: null,
|
behindOfOrigin: null,
|
||||||
hasRemote: false,
|
hasRemote: false,
|
||||||
@@ -61,6 +62,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({
|
|||||||
isDirty: snapshot.git.isDirty,
|
isDirty: snapshot.git.isDirty,
|
||||||
baseRef: snapshot.git.baseRef,
|
baseRef: snapshot.git.baseRef,
|
||||||
aheadBehind: snapshot.git.aheadBehind ?? null,
|
aheadBehind: snapshot.git.aheadBehind ?? null,
|
||||||
|
upstreamRef: snapshot.git.upstreamRef ?? null,
|
||||||
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
|
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
|
||||||
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
|
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
|
||||||
hasRemote: snapshot.git.hasRemote,
|
hasRemote: snapshot.git.hasRemote,
|
||||||
@@ -80,6 +82,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({
|
|||||||
isDirty: snapshot.git.isDirty,
|
isDirty: snapshot.git.isDirty,
|
||||||
baseRef: snapshot.git.baseRef ?? null,
|
baseRef: snapshot.git.baseRef ?? null,
|
||||||
aheadBehind: snapshot.git.aheadBehind ?? null,
|
aheadBehind: snapshot.git.aheadBehind ?? null,
|
||||||
|
upstreamRef: snapshot.git.upstreamRef ?? null,
|
||||||
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
|
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
|
||||||
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
|
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
|
||||||
hasRemote: snapshot.git.hasRemote,
|
hasRemote: snapshot.git.hasRemote,
|
||||||
|
|||||||
@@ -3373,6 +3373,7 @@ describe("session checkout status handling", () => {
|
|||||||
aheadBehind: { ahead: 2, behind: 1 },
|
aheadBehind: { ahead: 2, behind: 1 },
|
||||||
aheadOfOrigin: 2,
|
aheadOfOrigin: 2,
|
||||||
behindOfOrigin: 1,
|
behindOfOrigin: 1,
|
||||||
|
upstreamRef: null,
|
||||||
hasRemote: true,
|
hasRemote: true,
|
||||||
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
||||||
isPaseoOwnedWorktree: false,
|
isPaseoOwnedWorktree: false,
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ function createFallbackWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSna
|
|||||||
isDirty: null,
|
isDirty: null,
|
||||||
baseRef: null,
|
baseRef: null,
|
||||||
aheadBehind: null,
|
aheadBehind: null,
|
||||||
|
upstreamRef: null,
|
||||||
aheadOfOrigin: null,
|
aheadOfOrigin: null,
|
||||||
behindOfOrigin: null,
|
behindOfOrigin: null,
|
||||||
hasRemote: false,
|
hasRemote: false,
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export interface WorkspaceGitRuntimeSnapshot {
|
|||||||
isDirty: boolean | null;
|
isDirty: boolean | null;
|
||||||
baseRef: string | null;
|
baseRef: string | null;
|
||||||
aheadBehind: { ahead: number; behind: number } | null;
|
aheadBehind: { ahead: number; behind: number } | null;
|
||||||
|
upstreamRef: string | null;
|
||||||
aheadOfOrigin: number | null;
|
aheadOfOrigin: number | null;
|
||||||
behindOfOrigin: number | null;
|
behindOfOrigin: number | null;
|
||||||
hasRemote: boolean;
|
hasRemote: boolean;
|
||||||
@@ -2182,6 +2183,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
: (target.latestGit?.isDirty ?? checkoutStatus.isDirty),
|
: (target.latestGit?.isDirty ?? checkoutStatus.isDirty),
|
||||||
baseRef: checkoutStatus.baseRef,
|
baseRef: checkoutStatus.baseRef,
|
||||||
aheadBehind: checkoutStatus.aheadBehind,
|
aheadBehind: checkoutStatus.aheadBehind,
|
||||||
|
upstreamRef: checkoutStatus.upstreamRef,
|
||||||
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
|
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
|
||||||
behindOfOrigin: checkoutStatus.behindOfOrigin,
|
behindOfOrigin: checkoutStatus.behindOfOrigin,
|
||||||
hasRemote: checkoutStatus.hasRemote,
|
hasRemote: checkoutStatus.hasRemote,
|
||||||
@@ -2598,6 +2600,7 @@ function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
|||||||
isDirty: null,
|
isDirty: null,
|
||||||
baseRef: null,
|
baseRef: null,
|
||||||
aheadBehind: null,
|
aheadBehind: null,
|
||||||
|
upstreamRef: null,
|
||||||
aheadOfOrigin: null,
|
aheadOfOrigin: null,
|
||||||
behindOfOrigin: null,
|
behindOfOrigin: null,
|
||||||
hasRemote: false,
|
hasRemote: false,
|
||||||
|
|||||||
@@ -737,29 +737,107 @@ const x = 1;
|
|||||||
execFileSync("git", ["push"], { cwd: cloneDir });
|
execFileSync("git", ["push"], { cwd: cloneDir });
|
||||||
execFileSync("git", ["fetch", "origin"], { cwd: repoDir });
|
execFileSync("git", ["fetch", "origin"], { cwd: repoDir });
|
||||||
|
|
||||||
const facts = await getCheckoutSnapshotFacts(repoDir);
|
|
||||||
startGitCommandMetrics();
|
startGitCommandMetrics();
|
||||||
|
const facts = await getCheckoutSnapshotFacts(repoDir);
|
||||||
const divergedStatus = await getCheckoutStatus(repoDir, { facts });
|
const divergedStatus = await getCheckoutStatus(repoDir, { facts });
|
||||||
const metrics = stopGitCommandMetrics();
|
const metrics = stopGitCommandMetrics();
|
||||||
const upstreamCountCommands = metrics.commands.filter(
|
const upstreamCountCommands = metrics.commands.filter(
|
||||||
(command) => command.args[0] === "rev-list" && command.args.join(" ").includes("main"),
|
(command) =>
|
||||||
|
command.args[0] === "for-each-ref" && command.args.join(" ").includes("%(upstream)"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(divergedStatus.isGit).toBe(true);
|
expect(divergedStatus.isGit).toBe(true);
|
||||||
if (!divergedStatus.isGit) {
|
if (!divergedStatus.isGit) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
expect(divergedStatus.upstreamRef).toBe("refs/remotes/origin/main");
|
||||||
expect(divergedStatus.aheadOfOrigin).toBe(3);
|
expect(divergedStatus.aheadOfOrigin).toBe(3);
|
||||||
expect(divergedStatus.behindOfOrigin).toBe(2);
|
expect(divergedStatus.behindOfOrigin).toBe(2);
|
||||||
expect(upstreamCountCommands).toHaveLength(1);
|
expect(upstreamCountCommands).toHaveLength(1);
|
||||||
expect(upstreamCountCommands[0]?.args).toEqual([
|
expect(upstreamCountCommands[0]?.args).toEqual([
|
||||||
"rev-list",
|
"for-each-ref",
|
||||||
"--left-right",
|
"--format=%(upstream)%00%(upstream:track,nobracket)",
|
||||||
"--count",
|
"refs/heads/main",
|
||||||
"main...origin/main",
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports the fork remote, not origin, when the branch tracks a second remote", async () => {
|
||||||
|
setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
|
const forkDir = join(tempDir, "fork.git");
|
||||||
|
execFileSync("git", ["init", "--bare", "-b", "main", forkDir]);
|
||||||
|
execFileSync("git", ["remote", "add", "upstream", forkDir], { cwd: repoDir });
|
||||||
|
execFileSync("git", ["push", "-u", "upstream", "main"], { cwd: repoDir });
|
||||||
|
|
||||||
|
const status = await getCheckoutStatus(repoDir);
|
||||||
|
expect(status.isGit).toBe(true);
|
||||||
|
if (!status.isGit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(status.upstreamRef).toBe("refs/remotes/upstream/main");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the upstream branch name when it differs from the local branch name", async () => {
|
||||||
|
setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||||
|
commitFile(repoDir, "feature.txt", "feature\n", "feature commit");
|
||||||
|
execFileSync("git", ["push", "-u", "origin", "HEAD:other-name"], { cwd: repoDir });
|
||||||
|
|
||||||
|
const status = await getCheckoutStatus(repoDir);
|
||||||
|
expect(status.isGit).toBe(true);
|
||||||
|
if (!status.isGit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(status.upstreamRef).toBe("refs/remotes/origin/other-name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the upstream when the repo's only remote is not origin", async () => {
|
||||||
|
const forkDir = join(tempDir, "fork.git");
|
||||||
|
execFileSync("git", ["init", "--bare", "-b", "main", forkDir]);
|
||||||
|
execFileSync("git", ["remote", "add", "upstream", forkDir], { cwd: repoDir });
|
||||||
|
execFileSync("git", ["push", "-u", "upstream", "main"], { cwd: repoDir });
|
||||||
|
|
||||||
|
const status = await getCheckoutStatus(repoDir);
|
||||||
|
expect(status.isGit).toBe(true);
|
||||||
|
if (!status.isGit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(status.upstreamRef).toBe("refs/remotes/upstream/main");
|
||||||
|
expect(status.aheadOfOrigin).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A legal local branch named "origin/main" shadows the short form "origin/main" in git's
|
||||||
|
// ref resolution. The counts and the reported ref have to describe the same commit.
|
||||||
|
it("is not fooled by a local branch named like the remote-tracking ref", async () => {
|
||||||
|
setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
|
commitFile(repoDir, "shadow.txt", "shadow\n", "shadow commit");
|
||||||
|
// Pointed at HEAD, so comparing against the shadow reports 0 ahead while the real
|
||||||
|
// remote-tracking ref reports 1.
|
||||||
|
execFileSync("git", ["branch", "origin/main", "main"], { cwd: repoDir });
|
||||||
|
|
||||||
|
const status = await getCheckoutStatus(repoDir);
|
||||||
|
expect(status.isGit).toBe(true);
|
||||||
|
if (!status.isGit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(status.upstreamRef).toBe("refs/remotes/origin/main");
|
||||||
|
expect(status.aheadOfOrigin).toBe(1);
|
||||||
|
expect(status.behindOfOrigin).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports no upstream ref for a branch that was never pushed", async () => {
|
||||||
|
setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
|
execFileSync("git", ["checkout", "-b", "local-only"], { cwd: repoDir });
|
||||||
|
commitFile(repoDir, "local-only.txt", "local\n", "local commit");
|
||||||
|
|
||||||
|
const status = await getCheckoutStatus(repoDir);
|
||||||
|
expect(status.isGit).toBe(true);
|
||||||
|
if (!status.isGit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(status.upstreamRef).toBeNull();
|
||||||
|
expect(status.aheadOfOrigin).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("reports a PR worktree as not ahead when its branch is pushed to the configured PR remote", async () => {
|
it("reports a PR worktree as not ahead when its branch is pushed to the configured PR remote", async () => {
|
||||||
setupRemoteTrackingMain(repoDir, tempDir);
|
setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
const prRemoteDir = join(tempDir, "pr-remote.git");
|
const prRemoteDir = join(tempDir, "pr-remote.git");
|
||||||
@@ -3396,7 +3474,7 @@ const x = 1;
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
getCheckoutDiff(worktree.worktreePath, { mode: "base", baseRef: "other" }, { paseoHome }),
|
getCheckoutDiff(worktree.worktreePath, { mode: "base", baseRef: "other" }, { paseoHome }),
|
||||||
).rejects.toThrow("Base ref mismatch: stored main, requested other");
|
).rejects.toThrow("Base ref mismatch: stored refs/heads/main, requested other");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes dirty working tree changes from Paseo worktree base diffs", async () => {
|
it("excludes dirty working tree changes from Paseo worktree base diffs", async () => {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-pa
|
|||||||
import { runGitCommand } from "./run-git-command.js";
|
import { runGitCommand } from "./run-git-command.js";
|
||||||
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
|
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
|
||||||
import {
|
import {
|
||||||
|
branchNameFromRef,
|
||||||
getPaseoWorktreeChangeRequestHintForBranch,
|
getPaseoWorktreeChangeRequestHintForBranch,
|
||||||
type PaseoWorktreeMetadata,
|
type PaseoWorktreeMetadata,
|
||||||
readPaseoWorktreeMetadata,
|
readPaseoWorktreeMetadata,
|
||||||
@@ -786,6 +787,10 @@ export interface CheckoutStatusGitNonPaseo {
|
|||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
baseRef: string | null;
|
baseRef: string | null;
|
||||||
aheadBehind: AheadBehind | null;
|
aheadBehind: AheadBehind | null;
|
||||||
|
// Remote-tracking ref for currentBranch, e.g. "refs/remotes/origin/main". Null when the
|
||||||
|
// branch has no upstream or git could not resolve it. aheadOfOrigin/behindOfOrigin are
|
||||||
|
// measured against exactly this ref.
|
||||||
|
upstreamRef: string | null;
|
||||||
aheadOfOrigin: number | null;
|
aheadOfOrigin: number | null;
|
||||||
behindOfOrigin: number | null;
|
behindOfOrigin: number | null;
|
||||||
hasRemote: boolean;
|
hasRemote: boolean;
|
||||||
@@ -801,6 +806,7 @@ export interface CheckoutStatusGitPaseo {
|
|||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
baseRef: string;
|
baseRef: string;
|
||||||
aheadBehind: AheadBehind | null;
|
aheadBehind: AheadBehind | null;
|
||||||
|
upstreamRef: string | null;
|
||||||
aheadOfOrigin: number | null;
|
aheadOfOrigin: number | null;
|
||||||
behindOfOrigin: number | null;
|
behindOfOrigin: number | null;
|
||||||
hasRemote: boolean;
|
hasRemote: boolean;
|
||||||
@@ -859,6 +865,7 @@ export type CheckoutSnapshotFacts =
|
|||||||
comparisonBaseRef: string | null;
|
comparisonBaseRef: string | null;
|
||||||
branchRemoteName: string | null;
|
branchRemoteName: string | null;
|
||||||
branchMergeRef: string | null;
|
branchMergeRef: string | null;
|
||||||
|
upstreamStatus: UpstreamStatus | null;
|
||||||
pullRequestLookupTarget: PullRequestStatusLookupTarget | null;
|
pullRequestLookupTarget: PullRequestStatusLookupTarget | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1153,8 +1160,14 @@ async function getPaseoWorktreeForCwd(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Worktrees created before baseRef existed only stored the stripped name; it resolves
|
||||||
|
// local-first, which is the base they were actually cut from.
|
||||||
|
function storedBaseRefFromMetadata(metadata: PaseoWorktreeMetadata | null): string | null {
|
||||||
|
return metadata?.baseRef ?? metadata?.baseRefName ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function readPaseoWorktreeBaseRef(worktreeRoot: string): string | null {
|
function readPaseoWorktreeBaseRef(worktreeRoot: string): string | null {
|
||||||
return readPaseoWorktreeMetadata(worktreeRoot)?.baseRefName ?? null;
|
return storedBaseRefFromMetadata(readPaseoWorktreeMetadata(worktreeRoot));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getStoredBaseRefForCwd(
|
async function getStoredBaseRefForCwd(
|
||||||
@@ -1205,12 +1218,37 @@ async function resolveBaseRefForCwd(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The worktree stores the exact ref it was cut from ("refs/remotes/upstream/main") while
|
||||||
|
// callers still send its display name ("main"). A different qualified ref retains its own
|
||||||
|
// identity even when it has the same branch name.
|
||||||
|
function isSameBaseRef(stored: string, requested: string): boolean {
|
||||||
|
return stored === requested || branchNameFromRef(stored) === requested;
|
||||||
|
}
|
||||||
|
|
||||||
// Names both refs rather than labelling either one correct: a caller's ref can be stale, but the
|
// Names both refs rather than labelling either one correct: a caller's ref can be stale, but the
|
||||||
// stored ref can equally be wrong, so the message states the two facts and leaves the diagnosis open.
|
// stored ref can equally be wrong, so the message states the two facts and leaves the diagnosis open.
|
||||||
function baseRefMismatchError(refs: { stored: string; requested: string }): Error {
|
function baseRefMismatchError(refs: { stored: string; requested: string }): Error {
|
||||||
return new Error(`Base ref mismatch: stored ${refs.stored}, requested ${refs.requested}`);
|
return new Error(`Base ref mismatch: stored ${refs.stored}, requested ${refs.requested}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveOperationBaseRef(input: {
|
||||||
|
storedBaseRef: string | null;
|
||||||
|
resolvedBaseRef: string | null;
|
||||||
|
requestedBaseRef?: string;
|
||||||
|
}): string | null {
|
||||||
|
if (
|
||||||
|
input.storedBaseRef &&
|
||||||
|
input.requestedBaseRef &&
|
||||||
|
!isSameBaseRef(input.storedBaseRef, input.requestedBaseRef)
|
||||||
|
) {
|
||||||
|
throw baseRefMismatchError({
|
||||||
|
stored: input.storedBaseRef,
|
||||||
|
requested: input.requestedBaseRef,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return input.storedBaseRef ?? input.requestedBaseRef ?? input.resolvedBaseRef;
|
||||||
|
}
|
||||||
|
|
||||||
async function isWorkingTreeDirty(cwd: string, context?: CheckoutContext): Promise<boolean> {
|
async function isWorkingTreeDirty(cwd: string, context?: CheckoutContext): Promise<boolean> {
|
||||||
const { stdout } = await runGitCommand(["status", "--porcelain"], {
|
const { stdout } = await runGitCommand(["status", "--porcelain"], {
|
||||||
cwd,
|
cwd,
|
||||||
@@ -1450,29 +1488,25 @@ async function resolveBaseRef(repoRoot: string): Promise<string | null> {
|
|||||||
return resolveRepositoryDefaultBranch(repoRoot);
|
return resolveRepositoryDefaultBranch(repoRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLocalBranchRefName(input: string): string {
|
|
||||||
if (input.startsWith("refs/remotes/origin/")) {
|
|
||||||
return input.slice("refs/remotes/origin/".length);
|
|
||||||
}
|
|
||||||
if (input.startsWith("refs/heads/")) {
|
|
||||||
return input.slice("refs/heads/".length);
|
|
||||||
}
|
|
||||||
if (input.startsWith("origin/")) {
|
|
||||||
return input.slice("origin/".length);
|
|
||||||
}
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ComparisonBaseRefName {
|
interface ComparisonBaseRefName {
|
||||||
localName: string;
|
localName: string;
|
||||||
originRef: string;
|
originRef: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeComparisonBaseRefName(input: string): ComparisonBaseRefName {
|
function normalizeComparisonBaseRefName(input: string): ComparisonBaseRefName {
|
||||||
const localName = normalizeLocalBranchRefName(input);
|
const localName = branchNameFromRef(input);
|
||||||
return { localName, originRef: `origin/${localName}` };
|
return { localName, originRef: `origin/${localName}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveMergeTargetBranch(baseRef: string): string {
|
||||||
|
const remotePrefix = "refs/remotes/";
|
||||||
|
const originPrefix = `${remotePrefix}origin/`;
|
||||||
|
if (baseRef.startsWith(remotePrefix) && !baseRef.startsWith(originPrefix)) {
|
||||||
|
throw new Error(`No local merge target is recorded for base ref ${baseRef}`);
|
||||||
|
}
|
||||||
|
return branchNameFromRef(baseRef);
|
||||||
|
}
|
||||||
|
|
||||||
async function doesGitRefExist(
|
async function doesGitRefExist(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
fullRef: string,
|
fullRef: string,
|
||||||
@@ -1487,11 +1521,23 @@ async function doesGitRefExist(
|
|||||||
return result.exitCode === 0;
|
return result.exitCode === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isQualifiedBranchRef(ref: string): boolean {
|
||||||
|
return ref.startsWith("refs/heads/") || ref.startsWith("refs/remotes/");
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveBestComparisonBaseRef(
|
async function resolveBestComparisonBaseRef(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
baseRef: string,
|
baseRef: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
// A fully qualified branch ref names the exact commit stream to compare against. Bare names
|
||||||
|
// keep going through the local-vs-origin heuristic below for legacy worktree metadata.
|
||||||
|
if (isQualifiedBranchRef(baseRef)) {
|
||||||
|
if (await doesGitRefExist(cwd, baseRef, context)) {
|
||||||
|
return baseRef;
|
||||||
|
}
|
||||||
|
throw new Error(`Base ref not found: ${baseRef}`);
|
||||||
|
}
|
||||||
const normalized = normalizeComparisonBaseRefName(baseRef);
|
const normalized = normalizeComparisonBaseRefName(baseRef);
|
||||||
const [hasLocal, hasOrigin] = await Promise.all([
|
const [hasLocal, hasOrigin] = await Promise.all([
|
||||||
doesGitRefExist(cwd, `refs/heads/${normalized.localName}`, context),
|
doesGitRefExist(cwd, `refs/heads/${normalized.localName}`, context),
|
||||||
@@ -1512,7 +1558,14 @@ async function resolveBestComparisonBaseRef(
|
|||||||
throw new Error(`Base branch not found locally or on origin: ${refName}`);
|
throw new Error(`Base branch not found locally or on origin: ${refName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveMostAheadBaseRef(cwd: string, normalizedBaseRef: string): Promise<string> {
|
async function resolveMostAheadBaseRef(cwd: string, baseRef: string): Promise<string> {
|
||||||
|
if (isQualifiedBranchRef(baseRef)) {
|
||||||
|
if (await doesGitRefExist(cwd, baseRef)) {
|
||||||
|
return baseRef;
|
||||||
|
}
|
||||||
|
throw new Error(`Base ref not found: ${baseRef}`);
|
||||||
|
}
|
||||||
|
const normalizedBaseRef = branchNameFromRef(baseRef);
|
||||||
const [hasLocal, hasOrigin] = await Promise.all([
|
const [hasLocal, hasOrigin] = await Promise.all([
|
||||||
doesGitRefExist(cwd, `refs/heads/${normalizedBaseRef}`),
|
doesGitRefExist(cwd, `refs/heads/${normalizedBaseRef}`),
|
||||||
doesGitRefExist(cwd, `refs/remotes/origin/${normalizedBaseRef}`),
|
doesGitRefExist(cwd, `refs/remotes/origin/${normalizedBaseRef}`),
|
||||||
@@ -1551,7 +1604,7 @@ async function getAheadBehind(
|
|||||||
currentBranch: string,
|
currentBranch: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
): Promise<AheadBehind | null> {
|
): Promise<AheadBehind | null> {
|
||||||
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
|
const normalizedBaseRef = branchNameFromRef(baseRef);
|
||||||
if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) {
|
if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1575,48 +1628,32 @@ async function getAheadBehind(
|
|||||||
return { ahead, behind };
|
return { ahead, behind };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getConfiguredUpstreamRef(
|
interface UpstreamStatus {
|
||||||
cwd: string,
|
ref: string;
|
||||||
currentBranch: string,
|
aheadBehind: AheadBehind;
|
||||||
context?: CheckoutContext,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const remoteName =
|
|
||||||
context?.facts?.isGit && context.facts.currentBranch === currentBranch
|
|
||||||
? context.facts.branchRemoteName
|
|
||||||
: await getGitConfigValue(cwd, `branch.${currentBranch}.remote`, context);
|
|
||||||
if (!remoteName) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergeRef =
|
|
||||||
context?.facts?.isGit && context.facts.currentBranch === currentBranch
|
|
||||||
? context.facts.branchMergeRef
|
|
||||||
: await getGitConfigValue(cwd, `branch.${currentBranch}.merge`, context);
|
|
||||||
const upstreamBranch = parseBranchMergeHeadRef(mergeRef);
|
|
||||||
return upstreamBranch ? `${remoteName}/${upstreamBranch}` : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getOriginAheadBehind(
|
async function getUpstreamStatus(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
currentBranch: string,
|
currentBranch: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
): Promise<AheadBehind | null> {
|
): Promise<UpstreamStatus | null> {
|
||||||
if (!currentBranch) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const upstreamRef = await getConfiguredUpstreamRef(cwd, currentBranch, context);
|
|
||||||
if (!upstreamRef) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await runGitCommand(
|
const { stdout } = await runGitCommand(
|
||||||
["rev-list", "--left-right", "--count", `${currentBranch}...${upstreamRef}`],
|
[
|
||||||
|
"for-each-ref",
|
||||||
|
"--format=%(upstream)%00%(upstream:track,nobracket)",
|
||||||
|
`refs/heads/${currentBranch}`,
|
||||||
|
],
|
||||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
|
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
|
||||||
);
|
);
|
||||||
const [aheadRaw, behindRaw] = stdout.trim().split(/\s+/);
|
const [ref = "", track = ""] = stdout.trim().split("\0", 2);
|
||||||
const ahead = Number.parseInt(aheadRaw ?? "", 10);
|
if (!ref || track === "gone") {
|
||||||
const behind = Number.parseInt(behindRaw ?? "", 10);
|
return null;
|
||||||
return Number.isNaN(ahead) || Number.isNaN(behind) ? null : { ahead, behind };
|
}
|
||||||
|
const ahead = Number.parseInt(track.match(/ahead (\d+)/)?.[1] ?? "0", 10);
|
||||||
|
const behind = Number.parseInt(track.match(/behind (\d+)/)?.[1] ?? "0", 10);
|
||||||
|
return { ref, aheadBehind: { ahead, behind } };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1674,9 +1711,7 @@ function buildPullRequestLookupTargetFromBranchConfig(
|
|||||||
const originRepo = parseRepositoryIdentityFromRemote(input.originRemoteUrl);
|
const originRepo = parseRepositoryIdentityFromRemote(input.originRemoteUrl);
|
||||||
const isSameRepo = areSameGitHubRepository(remoteRepo, originRepo);
|
const isSameRepo = areSameGitHubRepository(remoteRepo, originRepo);
|
||||||
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
|
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
|
||||||
const normalizedBaseRef = input.resolvedBaseRef
|
const normalizedBaseRef = input.resolvedBaseRef ? branchNameFromRef(input.resolvedBaseRef) : null;
|
||||||
? normalizeLocalBranchRefName(input.resolvedBaseRef)
|
|
||||||
: null;
|
|
||||||
if (
|
if (
|
||||||
trackedHeadRef === normalizedBaseRef &&
|
trackedHeadRef === normalizedBaseRef &&
|
||||||
!doesLocalBranchNameIdentifyTrackedHead(
|
!doesLocalBranchNameIdentifyTrackedHead(
|
||||||
@@ -1740,9 +1775,7 @@ function buildPullRequestLookupTargetFromPushConfig(
|
|||||||
: null;
|
: null;
|
||||||
const isSameRepo = areSameGitHubRepository(remoteRepo, originRepo);
|
const isSameRepo = areSameGitHubRepository(remoteRepo, originRepo);
|
||||||
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
|
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
|
||||||
const normalizedBaseRef = input.resolvedBaseRef
|
const normalizedBaseRef = input.resolvedBaseRef ? branchNameFromRef(input.resolvedBaseRef) : null;
|
||||||
? normalizeLocalBranchRefName(input.resolvedBaseRef)
|
|
||||||
: null;
|
|
||||||
if (pushedHeadRef === normalizedBaseRef && !headRepositoryOwner) {
|
if (pushedHeadRef === normalizedBaseRef && !headRepositoryOwner) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1855,7 +1888,7 @@ export async function getCheckoutSnapshotFacts(
|
|||||||
const paseoWorktreeMetadata = inspected.paseoWorktree.isPaseoOwnedWorktree
|
const paseoWorktreeMetadata = inspected.paseoWorktree.isPaseoOwnedWorktree
|
||||||
? readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot)
|
? readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot)
|
||||||
: null;
|
: null;
|
||||||
const storedBaseRef = paseoWorktreeMetadata?.baseRefName ?? null;
|
const storedBaseRef = storedBaseRefFromMetadata(paseoWorktreeMetadata);
|
||||||
const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd));
|
const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd));
|
||||||
const mainRepoRoot = await getMainRepoRootFromCommonDir(
|
const mainRepoRoot = await getMainRepoRootFromCommonDir(
|
||||||
cwd,
|
cwd,
|
||||||
@@ -1866,7 +1899,7 @@ export async function getCheckoutSnapshotFacts(
|
|||||||
if (
|
if (
|
||||||
resolvedBaseRef &&
|
resolvedBaseRef &&
|
||||||
inspected.currentBranch &&
|
inspected.currentBranch &&
|
||||||
normalizeLocalBranchRefName(resolvedBaseRef) !== inspected.currentBranch
|
branchNameFromRef(resolvedBaseRef) !== inspected.currentBranch
|
||||||
) {
|
) {
|
||||||
comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef, context).catch(
|
comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef, context).catch(
|
||||||
() => null,
|
() => null,
|
||||||
@@ -1876,6 +1909,9 @@ export async function getCheckoutSnapshotFacts(
|
|||||||
let branchRemoteName: string | null = null;
|
let branchRemoteName: string | null = null;
|
||||||
let branchMergeRef: string | null = null;
|
let branchMergeRef: string | null = null;
|
||||||
let branchRemoteUrl: string | null = null;
|
let branchRemoteUrl: string | null = null;
|
||||||
|
const upstreamStatusPromise = inspected.currentBranch
|
||||||
|
? getUpstreamStatus(cwd, inspected.currentBranch, context)
|
||||||
|
: Promise.resolve(null);
|
||||||
if (inspected.currentBranch) {
|
if (inspected.currentBranch) {
|
||||||
branchRemoteName = await getGitConfigValue(
|
branchRemoteName = await getGitConfigValue(
|
||||||
cwd,
|
cwd,
|
||||||
@@ -1919,6 +1955,7 @@ export async function getCheckoutSnapshotFacts(
|
|||||||
pullRequestLookupTarget,
|
pullRequestLookupTarget,
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
|
const upstreamStatus = await upstreamStatusPromise;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isGit: true,
|
isGit: true,
|
||||||
@@ -1934,6 +1971,7 @@ export async function getCheckoutSnapshotFacts(
|
|||||||
comparisonBaseRef,
|
comparisonBaseRef,
|
||||||
branchRemoteName,
|
branchRemoteName,
|
||||||
branchMergeRef,
|
branchMergeRef,
|
||||||
|
upstreamStatus,
|
||||||
pullRequestLookupTarget,
|
pullRequestLookupTarget,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2097,16 +2135,18 @@ export async function getCheckoutStatus(
|
|||||||
const baseRef = facts.resolvedBaseRef;
|
const baseRef = facts.resolvedBaseRef;
|
||||||
const mainRepoRoot = facts.mainRepoRoot;
|
const mainRepoRoot = facts.mainRepoRoot;
|
||||||
const factsContext = { ...context, facts };
|
const factsContext = { ...context, facts };
|
||||||
const [aheadBehind, originAheadBehind] = await Promise.all([
|
const aheadBehind =
|
||||||
baseRef && currentBranch
|
baseRef && currentBranch
|
||||||
? getAheadBehind(cwd, baseRef, currentBranch, factsContext)
|
? await getAheadBehind(cwd, baseRef, currentBranch, factsContext)
|
||||||
: Promise.resolve(null),
|
: null;
|
||||||
hasRemote && currentBranch
|
const upstreamStatus = facts.upstreamStatus;
|
||||||
? getOriginAheadBehind(cwd, currentBranch, factsContext)
|
// The wire carries the display name: clients label the base with it and send it back to
|
||||||
: Promise.resolve(null),
|
// request diffs and merges. The exact ref stays in worktree.json and in facts, where the
|
||||||
]);
|
// comparisons and actions read it.
|
||||||
const aheadOfOrigin = originAheadBehind?.ahead ?? null;
|
const displayBaseRef = baseRef ? branchNameFromRef(baseRef) : null;
|
||||||
const behindOfOrigin = originAheadBehind?.behind ?? null;
|
const upstreamRef = upstreamStatus?.ref ?? null;
|
||||||
|
const aheadOfOrigin = upstreamStatus?.aheadBehind.ahead ?? null;
|
||||||
|
const behindOfOrigin = upstreamStatus?.aheadBehind.behind ?? null;
|
||||||
|
|
||||||
if (paseoWorktree.isPaseoOwnedWorktree && baseRef) {
|
if (paseoWorktree.isPaseoOwnedWorktree && baseRef) {
|
||||||
return {
|
return {
|
||||||
@@ -2115,8 +2155,9 @@ export async function getCheckoutStatus(
|
|||||||
mainRepoRoot: mainRepoRoot ?? worktreeRoot,
|
mainRepoRoot: mainRepoRoot ?? worktreeRoot,
|
||||||
currentBranch,
|
currentBranch,
|
||||||
isDirty,
|
isDirty,
|
||||||
baseRef,
|
baseRef: displayBaseRef ?? baseRef,
|
||||||
aheadBehind,
|
aheadBehind,
|
||||||
|
upstreamRef,
|
||||||
aheadOfOrigin,
|
aheadOfOrigin,
|
||||||
behindOfOrigin,
|
behindOfOrigin,
|
||||||
hasRemote,
|
hasRemote,
|
||||||
@@ -2132,8 +2173,9 @@ export async function getCheckoutStatus(
|
|||||||
mainRepoRoot && resolve(mainRepoRoot) !== resolve(worktreeRoot) ? mainRepoRoot : null,
|
mainRepoRoot && resolve(mainRepoRoot) !== resolve(worktreeRoot) ? mainRepoRoot : null,
|
||||||
currentBranch,
|
currentBranch,
|
||||||
isDirty,
|
isDirty,
|
||||||
baseRef,
|
baseRef: displayBaseRef,
|
||||||
aheadBehind,
|
aheadBehind,
|
||||||
|
upstreamRef,
|
||||||
aheadOfOrigin,
|
aheadOfOrigin,
|
||||||
behindOfOrigin,
|
behindOfOrigin,
|
||||||
hasRemote,
|
hasRemote,
|
||||||
@@ -2345,11 +2387,18 @@ async function tryResolveCheckoutCommitsBaseRef(
|
|||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
|
const normalizedBaseRef = branchNameFromRef(baseRef);
|
||||||
if (!normalizedBaseRef || normalizedBaseRef === currentBranch) {
|
if (!normalizedBaseRef || normalizedBaseRef === currentBranch) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return resolveMostAheadBaseRef(cwd, normalizedBaseRef).catch(() => null);
|
try {
|
||||||
|
return await resolveMostAheadBaseRef(cwd, baseRef);
|
||||||
|
} catch (error) {
|
||||||
|
if (isQualifiedBranchRef(baseRef)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listCheckoutCommits({
|
export async function listCheckoutCommits({
|
||||||
@@ -2365,7 +2414,7 @@ export async function listCheckoutCommits({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||||
const normalizedBaseRef = resolvedBaseRef ? normalizeLocalBranchRefName(resolvedBaseRef) : null;
|
const normalizedBaseRef = resolvedBaseRef ? branchNameFromRef(resolvedBaseRef) : null;
|
||||||
let comparisonBaseRef = await tryResolveCheckoutCommitsBaseRef(
|
let comparisonBaseRef = await tryResolveCheckoutCommitsBaseRef(
|
||||||
cwd,
|
cwd,
|
||||||
resolvedBaseRef,
|
resolvedBaseRef,
|
||||||
@@ -2991,13 +3040,14 @@ async function resolveCheckoutDiffRefs(
|
|||||||
return { baseRef: "HEAD", includeUntracked: true };
|
return { baseRef: "HEAD", includeUntracked: true };
|
||||||
}
|
}
|
||||||
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||||
const baseRef = compare.baseRef ?? resolvedBaseRef;
|
const baseRef = resolveOperationBaseRef({
|
||||||
|
storedBaseRef,
|
||||||
|
resolvedBaseRef,
|
||||||
|
requestedBaseRef: compare.baseRef,
|
||||||
|
});
|
||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (storedBaseRef && compare.baseRef && compare.baseRef !== storedBaseRef) {
|
|
||||||
throw baseRefMismatchError({ stored: storedBaseRef, requested: compare.baseRef });
|
|
||||||
}
|
|
||||||
const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef);
|
const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef);
|
||||||
return {
|
return {
|
||||||
baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef,
|
baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef,
|
||||||
@@ -3209,18 +3259,18 @@ export async function mergeToBase(
|
|||||||
await requireGitRepo(cwd);
|
await requireGitRepo(cwd);
|
||||||
const currentBranch = await getCurrentBranch(cwd);
|
const currentBranch = await getCurrentBranch(cwd);
|
||||||
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||||
const baseRef = options.baseRef ?? resolvedBaseRef;
|
const baseRef = resolveOperationBaseRef({
|
||||||
|
storedBaseRef,
|
||||||
|
resolvedBaseRef,
|
||||||
|
requestedBaseRef: options.baseRef,
|
||||||
|
});
|
||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
throw new Error("Unable to determine base branch for merge");
|
throw new Error("Unable to determine base branch for merge");
|
||||||
}
|
}
|
||||||
if (storedBaseRef && options.baseRef && options.baseRef !== storedBaseRef) {
|
|
||||||
throw baseRefMismatchError({ stored: storedBaseRef, requested: options.baseRef });
|
|
||||||
}
|
|
||||||
if (!currentBranch) {
|
if (!currentBranch) {
|
||||||
throw new Error("Unable to determine current branch for merge");
|
throw new Error("Unable to determine current branch for merge");
|
||||||
}
|
}
|
||||||
let normalizedBaseRef = baseRef;
|
const normalizedBaseRef = resolveMergeTargetBranch(baseRef);
|
||||||
normalizedBaseRef = normalizeLocalBranchRefName(normalizedBaseRef);
|
|
||||||
const currentWorktreeRoot = (await getWorktreeRoot(cwd, context)) ?? cwd;
|
const currentWorktreeRoot = (await getWorktreeRoot(cwd, context)) ?? cwd;
|
||||||
if (normalizedBaseRef === currentBranch) {
|
if (normalizedBaseRef === currentBranch) {
|
||||||
return currentWorktreeRoot;
|
return currentWorktreeRoot;
|
||||||
@@ -3285,13 +3335,14 @@ export async function mergeFromBase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||||
const baseRef = options.baseRef ?? resolvedBaseRef;
|
const baseRef = resolveOperationBaseRef({
|
||||||
|
storedBaseRef,
|
||||||
|
resolvedBaseRef,
|
||||||
|
requestedBaseRef: options.baseRef,
|
||||||
|
});
|
||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
throw new Error("Unable to determine base branch for merge");
|
throw new Error("Unable to determine base branch for merge");
|
||||||
}
|
}
|
||||||
if (storedBaseRef && options.baseRef && options.baseRef !== storedBaseRef) {
|
|
||||||
throw baseRefMismatchError({ stored: storedBaseRef, requested: options.baseRef });
|
|
||||||
}
|
|
||||||
|
|
||||||
const requireCleanTarget = options.requireCleanTarget ?? true;
|
const requireCleanTarget = options.requireCleanTarget ?? true;
|
||||||
if (requireCleanTarget) {
|
if (requireCleanTarget) {
|
||||||
@@ -3304,8 +3355,7 @@ export async function mergeFromBase(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
|
const bestBaseRef = await resolveMostAheadBaseRef(cwd, baseRef);
|
||||||
const bestBaseRef = await resolveMostAheadBaseRef(cwd, normalizedBaseRef);
|
|
||||||
if (bestBaseRef === currentBranch) {
|
if (bestBaseRef === currentBranch) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3619,17 +3669,18 @@ export async function createPullRequest(
|
|||||||
|
|
||||||
const head = options.head ?? (await getCurrentBranch(cwd));
|
const head = options.head ?? (await getCurrentBranch(cwd));
|
||||||
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||||
const base = options.base ?? resolvedBaseRef;
|
const base = resolveOperationBaseRef({
|
||||||
|
storedBaseRef,
|
||||||
|
resolvedBaseRef,
|
||||||
|
requestedBaseRef: options.base,
|
||||||
|
});
|
||||||
if (!head) {
|
if (!head) {
|
||||||
throw new Error("Unable to determine head branch for PR");
|
throw new Error("Unable to determine head branch for PR");
|
||||||
}
|
}
|
||||||
if (!base) {
|
if (!base) {
|
||||||
throw new Error("Unable to determine base branch for PR");
|
throw new Error("Unable to determine base branch for PR");
|
||||||
}
|
}
|
||||||
const normalizedBase = normalizeLocalBranchRefName(base);
|
const normalizedBase = branchNameFromRef(base);
|
||||||
if (storedBaseRef && options.base && options.base !== storedBaseRef) {
|
|
||||||
throw baseRefMismatchError({ stored: storedBaseRef, requested: options.base });
|
|
||||||
}
|
|
||||||
|
|
||||||
// The push deliberately happens before the adapter resolves the target
|
// The push deliberately happens before the adapter resolves the target
|
||||||
// repository: slug resolution is adapter-internal (e.g. `gh repo view`, which
|
// repository: slug resolution is adapter-internal (e.g. `gh repo view`, which
|
||||||
|
|||||||
@@ -9,15 +9,20 @@ const ChangeRequestLookupTargetSchema = z.object({
|
|||||||
localBranchName: z.string().min(1).optional(),
|
localBranchName: z.string().min(1).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// baseRefName is the display name; baseRef is the exact ref the worktree was cut from
|
||||||
|
// ("refs/remotes/upstream/main"). baseRef is optional because worktrees written before it
|
||||||
|
// existed only have the name — there are no migrations, so readers fall back.
|
||||||
const PaseoWorktreeMetadataV1Schema = z.object({
|
const PaseoWorktreeMetadataV1Schema = z.object({
|
||||||
version: z.literal(1),
|
version: z.literal(1),
|
||||||
baseRefName: z.string().min(1),
|
baseRefName: z.string().min(1),
|
||||||
|
baseRef: z.string().min(1).optional(),
|
||||||
changeRequestLookupTarget: ChangeRequestLookupTargetSchema.optional(),
|
changeRequestLookupTarget: ChangeRequestLookupTargetSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const PaseoWorktreeMetadataV2Schema = z.object({
|
const PaseoWorktreeMetadataV2Schema = z.object({
|
||||||
version: z.literal(2),
|
version: z.literal(2),
|
||||||
baseRefName: z.string().min(1),
|
baseRefName: z.string().min(1),
|
||||||
|
baseRef: z.string().min(1).optional(),
|
||||||
changeRequestLookupTarget: ChangeRequestLookupTargetSchema.optional(),
|
changeRequestLookupTarget: ChangeRequestLookupTargetSchema.optional(),
|
||||||
firstAgentBranchAutoName: z
|
firstAgentBranchAutoName: z
|
||||||
.discriminatedUnion("status", [
|
.discriminatedUnion("status", [
|
||||||
@@ -133,44 +138,72 @@ export function getPaseoWorktreeMetadataPath(worktreeRoot: string): string {
|
|||||||
return join(gitDir, "paseo", "worktree.json");
|
return join(gitDir, "paseo", "worktree.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeBaseRefName(input: string): string {
|
const REMOTE_TRACKING_PREFIX = "refs/remotes/";
|
||||||
const trimmed = input.trim();
|
|
||||||
if (!trimmed) {
|
/**
|
||||||
throw new Error("Base branch is required");
|
* The human-readable branch name behind a ref. Display and legacy identity only — it cannot
|
||||||
}
|
* round-trip, so anything that has to resolve to a commit keeps the exact ref instead.
|
||||||
|
*
|
||||||
|
* refs/remotes/<remote>/<branch> works for any remote, not just origin. Git allows slashes in
|
||||||
|
* remote names, so refs/remotes/a/b/c is ambiguous and the first segment is read as the
|
||||||
|
* remote: slashes are everywhere in branch names and rare in remote names. A remote genuinely
|
||||||
|
* named "team/upstream" therefore displays as "upstream/main" rather than "main"; the exact
|
||||||
|
* ref is unaffected, which is why this is display-only.
|
||||||
|
*/
|
||||||
|
export function branchNameFromRef(ref: string): string {
|
||||||
|
const trimmed = ref.trim();
|
||||||
if (trimmed.startsWith("refs/heads/")) {
|
if (trimmed.startsWith("refs/heads/")) {
|
||||||
return trimmed.slice("refs/heads/".length);
|
return trimmed.slice("refs/heads/".length);
|
||||||
}
|
}
|
||||||
if (trimmed.startsWith("refs/remotes/origin/")) {
|
if (trimmed.startsWith(REMOTE_TRACKING_PREFIX)) {
|
||||||
return trimmed.slice("refs/remotes/origin/".length);
|
const remainder = trimmed.slice(REMOTE_TRACKING_PREFIX.length);
|
||||||
|
const separator = remainder.indexOf("/");
|
||||||
|
return separator === -1 ? remainder : remainder.slice(separator + 1);
|
||||||
}
|
}
|
||||||
|
// Short form. It cannot be generalized to any remote the way the qualified form can:
|
||||||
|
// without the remote list, "feature/x" is indistinguishable from "<remote>/x".
|
||||||
if (trimmed.startsWith("origin/")) {
|
if (trimmed.startsWith("origin/")) {
|
||||||
return trimmed.slice("origin/".length);
|
return trimmed.slice("origin/".length);
|
||||||
}
|
}
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeBaseRefName(input: string): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error("Base branch is required");
|
||||||
|
}
|
||||||
|
return branchNameFromRef(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidBaseRef(value: string): void {
|
||||||
|
if (value === "HEAD") {
|
||||||
|
throw new Error("Base branch cannot be HEAD");
|
||||||
|
}
|
||||||
|
if (value.includes("..") || value.includes("@{")) {
|
||||||
|
throw new Error(`Invalid base branch: ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function writePaseoWorktreeMetadata(
|
export function writePaseoWorktreeMetadata(
|
||||||
worktreeRoot: string,
|
worktreeRoot: string,
|
||||||
options: {
|
options: {
|
||||||
baseRefName: string;
|
baseRefName: string;
|
||||||
|
baseRef?: string;
|
||||||
changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint;
|
changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
const baseRefName = normalizeBaseRefName(options.baseRefName);
|
const baseRefName = normalizeBaseRefName(options.baseRefName);
|
||||||
if (baseRefName === "HEAD") {
|
assertValidBaseRef(baseRefName);
|
||||||
throw new Error("Base branch cannot be HEAD");
|
const baseRef = options.baseRef?.trim();
|
||||||
}
|
if (baseRef) {
|
||||||
if (baseRefName.includes("..") || baseRefName.includes("@{")) {
|
assertValidBaseRef(baseRef);
|
||||||
throw new Error(`Invalid base branch: ${baseRefName}`);
|
|
||||||
}
|
|
||||||
if (!/^[0-9A-Za-z._/-]+$/.test(baseRefName)) {
|
|
||||||
throw new Error(`Invalid base branch: ${baseRefName}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata: PaseoWorktreeMetadata = {
|
const metadata: PaseoWorktreeMetadata = {
|
||||||
version: 1,
|
version: 1,
|
||||||
baseRefName,
|
baseRefName,
|
||||||
|
...(baseRef ? { baseRef } : {}),
|
||||||
...(options.changeRequestLookupTarget
|
...(options.changeRequestLookupTarget
|
||||||
? { changeRequestLookupTarget: options.changeRequestLookupTarget }
|
? { changeRequestLookupTarget: options.changeRequestLookupTarget }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -192,14 +225,8 @@ export function writePaseoWorktreeRuntimeMetadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const next: PaseoWorktreeMetadata = {
|
const next: PaseoWorktreeMetadata = {
|
||||||
|
...current,
|
||||||
version: 2,
|
version: 2,
|
||||||
baseRefName: current.baseRefName,
|
|
||||||
...(current.changeRequestLookupTarget
|
|
||||||
? { changeRequestLookupTarget: current.changeRequestLookupTarget }
|
|
||||||
: {}),
|
|
||||||
...(current.version === 2 && current.firstAgentBranchAutoName
|
|
||||||
? { firstAgentBranchAutoName: current.firstAgentBranchAutoName }
|
|
||||||
: {}),
|
|
||||||
runtime: {
|
runtime: {
|
||||||
worktreePort: options.worktreePort,
|
worktreePort: options.worktreePort,
|
||||||
},
|
},
|
||||||
@@ -222,16 +249,12 @@ export function writePaseoWorktreeFirstAgentBranchAutoNameMetadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
writePaseoWorktreeMetadataFile(worktreeRoot, {
|
writePaseoWorktreeMetadataFile(worktreeRoot, {
|
||||||
|
...current,
|
||||||
version: 2,
|
version: 2,
|
||||||
baseRefName: current.baseRefName,
|
|
||||||
...(current.changeRequestLookupTarget
|
|
||||||
? { changeRequestLookupTarget: current.changeRequestLookupTarget }
|
|
||||||
: {}),
|
|
||||||
firstAgentBranchAutoName: {
|
firstAgentBranchAutoName: {
|
||||||
status: "pending",
|
status: "pending",
|
||||||
placeholderBranchName,
|
placeholderBranchName,
|
||||||
},
|
},
|
||||||
...(current.version === 2 && current.runtime ? { runtime: current.runtime } : {}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,17 +268,12 @@ export function markPaseoWorktreeFirstAgentBranchAutoNameAttempted(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const next: PaseoWorktreeMetadata = {
|
const next: PaseoWorktreeMetadata = {
|
||||||
version: 2,
|
...current,
|
||||||
baseRefName: current.baseRefName,
|
|
||||||
...(current.changeRequestLookupTarget
|
|
||||||
? { changeRequestLookupTarget: current.changeRequestLookupTarget }
|
|
||||||
: {}),
|
|
||||||
firstAgentBranchAutoName: {
|
firstAgentBranchAutoName: {
|
||||||
status: "attempted",
|
status: "attempted",
|
||||||
placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName,
|
placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName,
|
||||||
attemptedAt: options.attemptedAt ?? new Date().toISOString(),
|
attemptedAt: options.attemptedAt ?? new Date().toISOString(),
|
||||||
},
|
},
|
||||||
...(current.runtime ? { runtime: current.runtime } : {}),
|
|
||||||
};
|
};
|
||||||
writePaseoWorktreeMetadataFile(worktreeRoot, next);
|
writePaseoWorktreeMetadataFile(worktreeRoot, next);
|
||||||
return next;
|
return next;
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ import {
|
|||||||
} from "./worktree";
|
} from "./worktree";
|
||||||
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
||||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||||
|
import {
|
||||||
|
getCheckoutDiff,
|
||||||
|
getCheckoutStatus,
|
||||||
|
listCheckoutCommits,
|
||||||
|
mergeFromBase,
|
||||||
|
mergeToBase,
|
||||||
|
} from "./checkout-git.js";
|
||||||
import { execFileSync } from "child_process";
|
import { execFileSync } from "child_process";
|
||||||
import { isPlatform } from "../test-utils/platform.js";
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
import {
|
import {
|
||||||
@@ -436,7 +443,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
|||||||
const localResult = await createLegacyWorktreeForTest({
|
const localResult = await createLegacyWorktreeForTest({
|
||||||
branchName: "prefer-local-feature",
|
branchName: "prefer-local-feature",
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
baseBranch: "main",
|
baseBranch: "refs/heads/main",
|
||||||
worktreeSlug: "prefer-local-feature",
|
worktreeSlug: "prefer-local-feature",
|
||||||
runSetup: false,
|
runSetup: false,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
@@ -451,6 +458,15 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(readFileSync(join(localResult.worktreePath, "file.txt"), "utf8")).toBe("from-local\n");
|
expect(readFileSync(join(localResult.worktreePath, "file.txt"), "utf8")).toBe("from-local\n");
|
||||||
|
const localStatus = await getCheckoutStatus(localResult.worktreePath, { paseoHome });
|
||||||
|
expect(localStatus.isGit).toBe(true);
|
||||||
|
if (!localStatus.isGit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(localStatus.aheadBehind).toEqual({ ahead: 0, behind: 0 });
|
||||||
|
await expect(
|
||||||
|
getCheckoutDiff(localResult.worktreePath, { mode: "base", baseRef: "main" }, { paseoHome }),
|
||||||
|
).resolves.toMatchObject({ diff: "" });
|
||||||
expect(readFileSync(join(originResult.worktreePath, "file.txt"), "utf8")).toBe(
|
expect(readFileSync(join(originResult.worktreePath, "file.txt"), "utf8")).toBe(
|
||||||
"from-origin\n",
|
"from-origin\n",
|
||||||
);
|
);
|
||||||
@@ -459,6 +475,137 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
|||||||
).toMatchObject({ baseRefName: "main" });
|
).toMatchObject({ baseRefName: "main" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("records the branch name when the base is on a remote other than origin", async () => {
|
||||||
|
const forkDir = join(tempDir, "fork.git");
|
||||||
|
const forkCloneDir = join(tempDir, "fork-clone");
|
||||||
|
execFileSync("git", ["init", "--bare", forkDir]);
|
||||||
|
execFileSync("git", ["remote", "add", "upstream", forkDir], { cwd: repoDir });
|
||||||
|
execFileSync("git", ["push", "-u", "upstream", "main"], { cwd: repoDir });
|
||||||
|
|
||||||
|
execFileSync("git", ["clone", "--branch", "main", forkDir, forkCloneDir]);
|
||||||
|
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: forkCloneDir });
|
||||||
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: forkCloneDir });
|
||||||
|
writeFileSync(join(forkCloneDir, "file.txt"), "from-upstream\n");
|
||||||
|
execFileSync("git", ["add", "file.txt"], { cwd: forkCloneDir });
|
||||||
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "advance upstream main"], {
|
||||||
|
cwd: forkCloneDir,
|
||||||
|
});
|
||||||
|
execFileSync("git", ["push", "origin", "main"], { cwd: forkCloneDir });
|
||||||
|
execFileSync("git", ["fetch", "upstream"], { cwd: repoDir });
|
||||||
|
const localMainHead = execFileSync("git", ["rev-parse", "refs/heads/main"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const result = await createLegacyWorktreeForTest({
|
||||||
|
branchName: "fork-base-feature",
|
||||||
|
cwd: repoDir,
|
||||||
|
baseBranch: "refs/remotes/upstream/main",
|
||||||
|
worktreeSlug: "fork-base-feature",
|
||||||
|
runSetup: false,
|
||||||
|
paseoHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-upstream\n");
|
||||||
|
// The display name and the exact ref are both recorded. Only the ref can resolve back
|
||||||
|
// to the commit the worktree was cut from: "main" resolves local-first, which here is
|
||||||
|
// a different commit than the fork's upstream.
|
||||||
|
expect(
|
||||||
|
JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")),
|
||||||
|
).toMatchObject({ baseRefName: "main", baseRef: "refs/remotes/upstream/main" });
|
||||||
|
|
||||||
|
// An untouched child must report no work of its own. Comparing against the wrong base
|
||||||
|
// shows the upstream-only commit as if the workspace had written it.
|
||||||
|
const status = await getCheckoutStatus(result.worktreePath, { paseoHome });
|
||||||
|
expect(status.isGit).toBe(true);
|
||||||
|
if (!status.isGit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The wire keeps the display name; the exact ref above is what the comparison used,
|
||||||
|
// which is why an untouched child reports no work of its own.
|
||||||
|
expect(status.baseRef).toBe("main");
|
||||||
|
expect(status.aheadBehind).toEqual({ ahead: 0, behind: 0 });
|
||||||
|
await expect(
|
||||||
|
getCheckoutDiff(
|
||||||
|
result.worktreePath,
|
||||||
|
{ mode: "base", baseRef: "refs/remotes/origin/main" },
|
||||||
|
{ paseoHome },
|
||||||
|
),
|
||||||
|
).rejects.toThrow(
|
||||||
|
"Base ref mismatch: stored refs/remotes/upstream/main, requested refs/remotes/origin/main",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await getCheckoutDiff(
|
||||||
|
result.worktreePath,
|
||||||
|
{ mode: "base", baseRef: "main" },
|
||||||
|
{ paseoHome },
|
||||||
|
),
|
||||||
|
).toMatchObject({ diff: "" });
|
||||||
|
const history = await listCheckoutCommits({
|
||||||
|
cwd: result.worktreePath,
|
||||||
|
context: { paseoHome },
|
||||||
|
});
|
||||||
|
expect(history.commits.every((commit) => commit.isOnBase)).toBe(true);
|
||||||
|
await expect(
|
||||||
|
mergeToBase(result.worktreePath, { baseRef: "main" }, { paseoHome }),
|
||||||
|
).rejects.toThrow(
|
||||||
|
"No local merge target is recorded for base ref refs/remotes/upstream/main",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
execFileSync("git", ["rev-parse", "refs/heads/main"], { cwd: repoDir }).toString().trim(),
|
||||||
|
).toBe(localMainHead);
|
||||||
|
|
||||||
|
writeFileSync(join(forkCloneDir, "later.txt"), "later\n");
|
||||||
|
execFileSync("git", ["add", "later.txt"], { cwd: forkCloneDir });
|
||||||
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "advance again"], {
|
||||||
|
cwd: forkCloneDir,
|
||||||
|
});
|
||||||
|
execFileSync("git", ["push", "origin", "main"], { cwd: forkCloneDir });
|
||||||
|
execFileSync("git", ["fetch", "upstream"], { cwd: result.worktreePath });
|
||||||
|
await mergeFromBase(result.worktreePath, { baseRef: "main" }, { paseoHome });
|
||||||
|
expect(readFileSync(join(result.worktreePath, "later.txt"), "utf8")).toBe("later\n");
|
||||||
|
|
||||||
|
execFileSync("git", ["update-ref", "-d", "refs/remotes/upstream/main"], {
|
||||||
|
cwd: result.worktreePath,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
getCheckoutDiff(result.worktreePath, { mode: "base", baseRef: "main" }, { paseoHome }),
|
||||||
|
).rejects.toThrow("Base ref not found: refs/remotes/upstream/main");
|
||||||
|
await expect(
|
||||||
|
mergeFromBase(result.worktreePath, { baseRef: "main" }, { paseoHome }),
|
||||||
|
).rejects.toThrow("Base ref not found: refs/remotes/upstream/main");
|
||||||
|
await expect(
|
||||||
|
listCheckoutCommits({ cwd: result.worktreePath, context: { paseoHome } }),
|
||||||
|
).rejects.toThrow("Base ref not found: refs/remotes/upstream/main");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records Git-valid characters in a remote base branch", async () => {
|
||||||
|
const upstreamDir = join(tempDir, "upstream.git");
|
||||||
|
execFileSync("git", ["init", "--bare", upstreamDir]);
|
||||||
|
execFileSync("git", ["remote", "add", "upstream", upstreamDir], { cwd: repoDir });
|
||||||
|
execFileSync("git", ["push", "upstream", "refs/heads/main:refs/heads/release+hotfix"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
});
|
||||||
|
execFileSync("git", ["fetch", "upstream"], { cwd: repoDir });
|
||||||
|
|
||||||
|
const result = await createLegacyWorktreeForTest({
|
||||||
|
branchName: "release-hotfix-feature",
|
||||||
|
cwd: repoDir,
|
||||||
|
baseBranch: "refs/remotes/upstream/release+hotfix",
|
||||||
|
worktreeSlug: "release-hotfix-feature",
|
||||||
|
runSetup: false,
|
||||||
|
paseoHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")),
|
||||||
|
).toMatchObject({
|
||||||
|
baseRefName: "release+hotfix",
|
||||||
|
baseRef: "refs/remotes/upstream/release+hotfix",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("uses a 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 });
|
||||||
|
|||||||
@@ -1271,6 +1271,7 @@ export const createWorktree = async ({
|
|||||||
|
|
||||||
writePaseoWorktreeMetadata(worktreePath, {
|
writePaseoWorktreeMetadata(worktreePath, {
|
||||||
baseRefName: sourcePlan.metadataBaseRefName,
|
baseRefName: sourcePlan.metadataBaseRefName,
|
||||||
|
...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}),
|
||||||
...(sourcePlan.changeRequestLookupTarget
|
...(sourcePlan.changeRequestLookupTarget
|
||||||
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
|
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -1300,7 +1301,11 @@ interface ResolveWorktreeSourcePlanOptions {
|
|||||||
|
|
||||||
interface WorktreeSourcePlan {
|
interface WorktreeSourcePlan {
|
||||||
branchName: string;
|
branchName: string;
|
||||||
|
// Display name and exact ref are two different facts. The name cannot round-trip to a
|
||||||
|
// commit — "main" resolves local-first even when the worktree was cut from a fork's
|
||||||
|
// upstream — so comparisons and actions read the ref and the UI reads the name.
|
||||||
metadataBaseRefName: string;
|
metadataBaseRefName: string;
|
||||||
|
metadataBaseRef?: string;
|
||||||
changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint;
|
changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint;
|
||||||
addArguments: string[];
|
addArguments: string[];
|
||||||
pushRemote?: {
|
pushRemote?: {
|
||||||
@@ -1334,6 +1339,7 @@ async function resolveWorktreeSourcePlan({
|
|||||||
return {
|
return {
|
||||||
branchName: newBranchName,
|
branchName: newBranchName,
|
||||||
metadataBaseRefName: normalizedBaseBranch,
|
metadataBaseRefName: normalizedBaseBranch,
|
||||||
|
metadataBaseRef: resolvedBaseBranch,
|
||||||
addArguments: ["-b", newBranchName, "--no-track", base],
|
addArguments: ["-b", newBranchName, "--no-track", base],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user