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:
@@ -24,6 +24,10 @@ import {
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
pasteGithubPrUrl,
|
||||
captureStartingRefPicker,
|
||||
expectStartingRefRows,
|
||||
startingRefRow,
|
||||
submitNewWorkspaceEmpty,
|
||||
searchAndSelectBranchInPicker,
|
||||
selectBranchInPicker,
|
||||
selectGitHubPrInPicker,
|
||||
@@ -31,7 +35,14 @@ import {
|
||||
selectWorkspaceIsolation,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { createTempGitRepo, readWorktreeBranchInfo } from "../support/helpers/workspace";
|
||||
import {
|
||||
commitLocalOnly,
|
||||
createTempGitRepo,
|
||||
readRepoRef,
|
||||
readWorktreeBaseMetadata,
|
||||
readWorktreeBranchInfo,
|
||||
trackForkUpstream,
|
||||
} from "../support/helpers/workspace";
|
||||
import {
|
||||
createLocalGithubPrFixture,
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -348,6 +348,23 @@ export async function searchAndSelectBranchInPicker(page: Page, name: string): P
|
||||
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> {
|
||||
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
|
||||
await expect(prRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 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<{
|
||||
currentBranch: string;
|
||||
hasAncestor: (ref: string) => boolean;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type { CheckoutStatusPayload } from "@/git/checkout-status-cache";
|
||||
import {
|
||||
type BranchPickerDetail,
|
||||
branchPickerOptionId,
|
||||
buildBranchPickerItems,
|
||||
buildPickerOptionData,
|
||||
defaultBasePickerItem,
|
||||
pickerItemToCheckoutRequest,
|
||||
resolveCheckoutRequest,
|
||||
type PickerItem,
|
||||
} from "./new-workspace-picker-item";
|
||||
|
||||
@@ -104,7 +105,7 @@ describe("pickerItemToCheckoutRequest", () => {
|
||||
});
|
||||
|
||||
describe("buildBranchPickerItems", () => {
|
||||
it("hides the origin duplicate when local and origin match", () => {
|
||||
it("keeps a single origin row when local and origin match", () => {
|
||||
expect(
|
||||
buildBranchPickerItems([
|
||||
{
|
||||
@@ -120,14 +121,14 @@ describe("buildBranchPickerItems", () => {
|
||||
{
|
||||
kind: "branch",
|
||||
name: "main",
|
||||
refName: "refs/heads/main",
|
||||
accessibilityLabel: "main, local branch, up to date with origin main",
|
||||
refName: "refs/remotes/origin/main",
|
||||
accessibilityLabel: "main, origin branch",
|
||||
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(
|
||||
buildBranchPickerItems([
|
||||
{
|
||||
@@ -143,17 +144,16 @@ describe("buildBranchPickerItems", () => {
|
||||
{
|
||||
kind: "branch",
|
||||
name: "main",
|
||||
refName: "refs/heads/main",
|
||||
divergenceLabel: "+3 −2",
|
||||
accessibilityLabel: "main, local branch, 3 commits ahead and 2 behind origin main",
|
||||
refName: "refs/remotes/origin/main",
|
||||
accessibilityLabel: "main, origin branch",
|
||||
committerDate: 10,
|
||||
},
|
||||
{
|
||||
kind: "branch",
|
||||
name: "origin/main",
|
||||
refName: "refs/remotes/origin/main",
|
||||
divergenceLabel: "+2 −3",
|
||||
accessibilityLabel: "origin main, 2 commits ahead and 3 behind local main",
|
||||
name: "main (local)",
|
||||
refName: "refs/heads/main",
|
||||
divergenceLabel: "+3 −2",
|
||||
accessibilityLabel: "main, local branch, 3 commits ahead and 2 behind origin main",
|
||||
committerDate: 10,
|
||||
},
|
||||
]);
|
||||
@@ -172,9 +172,30 @@ describe("buildBranchPickerItems", () => {
|
||||
).toEqual([
|
||||
{
|
||||
kind: "branch",
|
||||
name: "origin/release",
|
||||
name: "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,
|
||||
},
|
||||
]);
|
||||
@@ -194,15 +215,15 @@ describe("buildBranchPickerItems", () => {
|
||||
{
|
||||
kind: "branch",
|
||||
name: "main",
|
||||
refName: "refs/heads/main",
|
||||
accessibilityLabel: "main, local branch",
|
||||
refName: "refs/remotes/origin/main",
|
||||
accessibilityLabel: "main, origin branch",
|
||||
committerDate: 10,
|
||||
},
|
||||
{
|
||||
kind: "branch",
|
||||
name: "origin/main",
|
||||
refName: "refs/remotes/origin/main",
|
||||
accessibilityLabel: "origin main, origin branch",
|
||||
name: "main (local)",
|
||||
refName: "refs/heads/main",
|
||||
accessibilityLabel: "main, local branch",
|
||||
committerDate: 10,
|
||||
},
|
||||
]);
|
||||
@@ -221,38 +242,101 @@ describe("buildBranchPickerItems", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCheckoutRequest", () => {
|
||||
const checkoutStatus = {
|
||||
currentBranch: "feature/current",
|
||||
} as CheckoutStatusPayload;
|
||||
const mainRow: BranchPickerDetail = {
|
||||
name: "main",
|
||||
committerDate: 10,
|
||||
hasLocal: true,
|
||||
hasRemote: true,
|
||||
localAhead: 2,
|
||||
localBehind: 0,
|
||||
};
|
||||
|
||||
it("branches from the loaded current branch when nothing was picked", () => {
|
||||
expect(resolveCheckoutRequest(null, checkoutStatus)).toEqual({
|
||||
action: "branch-off",
|
||||
refName: "feature/current",
|
||||
describe("buildPickerOptionData", () => {
|
||||
it("marks origin and keeps an ahead local main explicit", () => {
|
||||
const baseItem = defaultBasePickerItem({
|
||||
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", () => {
|
||||
expect(
|
||||
resolveCheckoutRequest(
|
||||
{
|
||||
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",
|
||||
it("adds and disambiguates a fork upstream absent from branch suggestions", () => {
|
||||
const baseItem = defaultBasePickerItem({
|
||||
currentBranch: "main",
|
||||
upstreamRef: "refs/remotes/upstream/main",
|
||||
});
|
||||
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(
|
||||
resolveCheckoutRequest(null, { ...checkoutStatus, currentBranch: null }),
|
||||
).toBeUndefined();
|
||||
defaultBasePickerItem({
|
||||
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 { 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 {
|
||||
name: string;
|
||||
@@ -31,6 +32,8 @@ export type PickerCheckoutRequest = Pick<
|
||||
>;
|
||||
|
||||
const BRANCH_OPTION_PREFIX = "branch:";
|
||||
const PR_OPTION_PREFIX = "github-pr:";
|
||||
const REMOTE_TRACKING_PREFIX = "refs/remotes/";
|
||||
|
||||
export function branchPickerOptionId(refName: string): string {
|
||||
return `${BRANCH_OPTION_PREFIX}${refName}`;
|
||||
@@ -82,11 +85,27 @@ export function buildBranchPickerItems(details: readonly BranchPickerDetail[]):
|
||||
const localBehind = detail.localBehind;
|
||||
const hasDivergence =
|
||||
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) {
|
||||
const localItem: Extract<PickerItem, { kind: "branch" }> = {
|
||||
// The origin row goes first because it is the default base, and it never states
|
||||
// divergence: it is the reference point the local row is measured against.
|
||||
if (hasRemote) {
|
||||
items.push({
|
||||
kind: "branch",
|
||||
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}`,
|
||||
accessibilityLabel: `${detail.name}, local branch`,
|
||||
committerDate: detail.committerDate,
|
||||
@@ -101,33 +120,64 @@ export function buildBranchPickerItems(details: readonly BranchPickerDetail[]):
|
||||
}
|
||||
items.push(localItem);
|
||||
}
|
||||
|
||||
const refsDiffer = hasDivergence && (localAhead > 0 || localBehind > 0);
|
||||
if (hasRemote && (!hasLocal || !hasDivergence || refsDiffer)) {
|
||||
const remoteAhead = hasDivergence ? localBehind : 0;
|
||||
const remoteBehind = hasDivergence ? localAhead : 0;
|
||||
const remoteItem: Extract<PickerItem, { kind: "branch" }> = {
|
||||
kind: "branch",
|
||||
name: `origin/${detail.name}`,
|
||||
refName: `refs/remotes/origin/${detail.name}`,
|
||||
accessibilityLabel: `origin ${detail.name}, origin branch`,
|
||||
committerDate: detail.committerDate,
|
||||
};
|
||||
if (hasDivergence) {
|
||||
remoteItem.divergenceLabel = divergenceLabel(remoteAhead, remoteBehind);
|
||||
remoteItem.accessibilityLabel = `origin ${detail.name}, ${divergenceAccessibility(
|
||||
remoteAhead,
|
||||
remoteBehind,
|
||||
`local ${detail.name}`,
|
||||
)}`;
|
||||
}
|
||||
items.push(remoteItem);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export 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(
|
||||
item: PickerItem | null,
|
||||
): PickerCheckoutRequest | undefined {
|
||||
@@ -159,15 +209,98 @@ export function pickerItemToCheckoutRequest(
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCheckoutRequest(
|
||||
selectedItem: PickerItem | null,
|
||||
checkoutStatus: CheckoutStatusPayload,
|
||||
): PickerCheckoutRequest | undefined {
|
||||
const selectedCheckoutRequest = pickerItemToCheckoutRequest(selectedItem);
|
||||
if (selectedCheckoutRequest) return selectedCheckoutRequest;
|
||||
if (!checkoutStatus.currentBranch) return undefined;
|
||||
export function prPickerOptionId(number: number): string {
|
||||
return `${PR_OPTION_PREFIX}${number}`;
|
||||
}
|
||||
|
||||
export function pickerOptionId(item: PickerItem): string {
|
||||
return item.kind === "branch"
|
||||
? 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 {
|
||||
action: "branch-off",
|
||||
refName: checkoutStatus.currentBranch,
|
||||
...item,
|
||||
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 { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { getForgePresentation } from "@/git/forge";
|
||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
@@ -90,12 +89,14 @@ import {
|
||||
remapDraftCwdToWorkspace,
|
||||
} from "./new-workspace-fork-context";
|
||||
import {
|
||||
branchPickerOptionId,
|
||||
buildBranchPickerItems,
|
||||
resolveCheckoutRequest,
|
||||
buildPickerOptionData,
|
||||
defaultBasePickerItem,
|
||||
pickerItemLabel,
|
||||
pickerItemToCheckoutRequest,
|
||||
type BranchPickerDetail,
|
||||
type PickerCheckoutRequest,
|
||||
type PickerItem,
|
||||
type PickerOptionData,
|
||||
} from "./new-workspace-picker-item";
|
||||
import {
|
||||
clearPickerPrAttachmentForTargetChange,
|
||||
@@ -169,12 +170,6 @@ interface NewWorkspaceScreenProps {
|
||||
draftId?: string;
|
||||
}
|
||||
|
||||
interface PickerOptionData {
|
||||
options: ComboboxOptionType[];
|
||||
itemById: Map<string, PickerItem>;
|
||||
}
|
||||
|
||||
const PR_OPTION_PREFIX = "github-pr:";
|
||||
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
|
||||
// Stable reference so the keyboard-action handler doesn't re-register each render.
|
||||
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({
|
||||
option,
|
||||
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 {
|
||||
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({
|
||||
pickerAnchorRef,
|
||||
onPress,
|
||||
@@ -1655,7 +1599,6 @@ export function NewWorkspaceScreen({
|
||||
cwd: selectedSourceDirectory ?? "",
|
||||
});
|
||||
|
||||
const currentBranch = checkoutStatus?.currentBranch ?? null;
|
||||
const worktreeSupport = selectedProject
|
||||
? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId })
|
||||
: "unsupported";
|
||||
@@ -1709,25 +1652,23 @@ export function NewWorkspaceScreen({
|
||||
return githubPrSearchQuery.data?.items ?? [];
|
||||
}, [forgeSearchAuthenticated, githubPrSearchQuery.data?.items]);
|
||||
|
||||
const { options, itemById }: PickerOptionData = useMemo(
|
||||
() => computePickerOptionData(branchDetails, prItems),
|
||||
[branchDetails, prItems],
|
||||
const baseItem = useMemo(
|
||||
() => selectedItem ?? (checkoutStatus ? defaultBasePickerItem(checkoutStatus) : null),
|
||||
[checkoutStatus, selectedItem],
|
||||
);
|
||||
const { options, itemById, selectedOptionId }: PickerOptionData = useMemo(
|
||||
() =>
|
||||
buildPickerOptionData({
|
||||
branchDetails,
|
||||
prItems,
|
||||
baseItem,
|
||||
}),
|
||||
[baseItem, branchDetails, prItems],
|
||||
);
|
||||
const triggerLabel = useMemo(() => {
|
||||
if (selectedItem) return pickerItemTriggerLabel(selectedItem);
|
||||
return currentBranch ?? "main";
|
||||
}, [currentBranch, selectedItem]);
|
||||
|
||||
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 displayItem = itemById.get(selectedOptionId);
|
||||
return displayItem ? pickerItemLabel(displayItem) : "main";
|
||||
}, [itemById, selectedOptionId]);
|
||||
const selectPickerItem = useCallback(
|
||||
(item: PickerItem) => {
|
||||
const nextAttachments = syncPickerPrAttachment({
|
||||
@@ -1929,15 +1870,17 @@ export function NewWorkspaceScreen({
|
||||
}
|
||||
const connectedClient = withConnectedClient();
|
||||
const createsWorktree = !supportsWorkspaceMultiplicity || effectiveIsolation === "worktree";
|
||||
const checkoutRequest = createsWorktree
|
||||
? resolveCheckoutRequest(
|
||||
selectedItem,
|
||||
await ensureCheckoutStatus({
|
||||
queryClient,
|
||||
client: connectedClient,
|
||||
serverId: selectedServerId,
|
||||
cwd: selectedSourceDirectory,
|
||||
}),
|
||||
const checkoutStatusForCreate = createsWorktree
|
||||
? await ensureCheckoutStatus({
|
||||
queryClient,
|
||||
client: connectedClient,
|
||||
serverId: selectedServerId,
|
||||
cwd: selectedSourceDirectory,
|
||||
})
|
||||
: null;
|
||||
const checkoutRequest = checkoutStatusForCreate
|
||||
? pickerItemToCheckoutRequest(
|
||||
selectedItem ?? defaultBasePickerItem(checkoutStatusForCreate),
|
||||
)
|
||||
: undefined;
|
||||
const normalizedWorkspace = supportsWorkspaceMultiplicity
|
||||
|
||||
Reference in New Issue
Block a user