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:
Mohamed Boudra
2026-08-04 09:01:38 +02:00
committed by GitHub
parent 74dea38456
commit fb657ebc20
17 changed files with 1028 additions and 308 deletions
@@ -23,6 +23,13 @@ import {
} from "./worktree";
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
import {
getCheckoutDiff,
getCheckoutStatus,
listCheckoutCommits,
mergeFromBase,
mergeToBase,
} from "./checkout-git.js";
import { execFileSync } from "child_process";
import { isPlatform } from "../test-utils/platform.js";
import {
@@ -436,7 +443,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
const localResult = await createLegacyWorktreeForTest({
branchName: "prefer-local-feature",
cwd: repoDir,
baseBranch: "main",
baseBranch: "refs/heads/main",
worktreeSlug: "prefer-local-feature",
runSetup: false,
paseoHome,
@@ -451,6 +458,15 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
});
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(
"from-origin\n",
);
@@ -459,6 +475,137 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
).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 () => {
writeFileSync(join(repoDir, "file.txt"), "from-local-only\n");
execFileSync("git", ["add", "file.txt"], { cwd: repoDir });