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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user