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
@@ -33,6 +33,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({
isDirty: null,
baseRef: null,
aheadBehind: null,
upstreamRef: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
@@ -61,6 +62,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({
isDirty: snapshot.git.isDirty,
baseRef: snapshot.git.baseRef,
aheadBehind: snapshot.git.aheadBehind ?? null,
upstreamRef: snapshot.git.upstreamRef ?? null,
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
hasRemote: snapshot.git.hasRemote,
@@ -80,6 +82,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({
isDirty: snapshot.git.isDirty,
baseRef: snapshot.git.baseRef ?? null,
aheadBehind: snapshot.git.aheadBehind ?? null,
upstreamRef: snapshot.git.upstreamRef ?? null,
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
hasRemote: snapshot.git.hasRemote,
@@ -3373,6 +3373,7 @@ describe("session checkout status handling", () => {
aheadBehind: { ahead: 2, behind: 1 },
aheadOfOrigin: 2,
behindOfOrigin: 1,
upstreamRef: null,
hasRemote: true,
remoteUrl: "https://github.com/getpaseo/paseo.git",
isPaseoOwnedWorktree: false,
@@ -178,6 +178,7 @@ function createFallbackWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSna
isDirty: null,
baseRef: null,
aheadBehind: null,
upstreamRef: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
@@ -80,6 +80,7 @@ export interface WorkspaceGitRuntimeSnapshot {
isDirty: boolean | null;
baseRef: string | null;
aheadBehind: { ahead: number; behind: number } | null;
upstreamRef: string | null;
aheadOfOrigin: number | null;
behindOfOrigin: number | null;
hasRemote: boolean;
@@ -2182,6 +2183,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
: (target.latestGit?.isDirty ?? checkoutStatus.isDirty),
baseRef: checkoutStatus.baseRef,
aheadBehind: checkoutStatus.aheadBehind,
upstreamRef: checkoutStatus.upstreamRef,
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
behindOfOrigin: checkoutStatus.behindOfOrigin,
hasRemote: checkoutStatus.hasRemote,
@@ -2598,6 +2600,7 @@ function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
isDirty: null,
baseRef: null,
aheadBehind: null,
upstreamRef: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
+85 -7
View File
@@ -737,29 +737,107 @@ const x = 1;
execFileSync("git", ["push"], { cwd: cloneDir });
execFileSync("git", ["fetch", "origin"], { cwd: repoDir });
const facts = await getCheckoutSnapshotFacts(repoDir);
startGitCommandMetrics();
const facts = await getCheckoutSnapshotFacts(repoDir);
const divergedStatus = await getCheckoutStatus(repoDir, { facts });
const metrics = stopGitCommandMetrics();
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);
if (!divergedStatus.isGit) {
return;
}
expect(divergedStatus.upstreamRef).toBe("refs/remotes/origin/main");
expect(divergedStatus.aheadOfOrigin).toBe(3);
expect(divergedStatus.behindOfOrigin).toBe(2);
expect(upstreamCountCommands).toHaveLength(1);
expect(upstreamCountCommands[0]?.args).toEqual([
"rev-list",
"--left-right",
"--count",
"main...origin/main",
"for-each-ref",
"--format=%(upstream)%00%(upstream:track,nobracket)",
"refs/heads/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 () => {
setupRemoteTrackingMain(repoDir, tempDir);
const prRemoteDir = join(tempDir, "pr-remote.git");
@@ -3396,7 +3474,7 @@ const x = 1;
await expect(
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 () => {
+144 -93
View File
@@ -30,6 +30,7 @@ import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-pa
import { runGitCommand } from "./run-git-command.js";
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
import {
branchNameFromRef,
getPaseoWorktreeChangeRequestHintForBranch,
type PaseoWorktreeMetadata,
readPaseoWorktreeMetadata,
@@ -786,6 +787,10 @@ export interface CheckoutStatusGitNonPaseo {
isDirty: boolean;
baseRef: string | 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;
behindOfOrigin: number | null;
hasRemote: boolean;
@@ -801,6 +806,7 @@ export interface CheckoutStatusGitPaseo {
isDirty: boolean;
baseRef: string;
aheadBehind: AheadBehind | null;
upstreamRef: string | null;
aheadOfOrigin: number | null;
behindOfOrigin: number | null;
hasRemote: boolean;
@@ -859,6 +865,7 @@ export type CheckoutSnapshotFacts =
comparisonBaseRef: string | null;
branchRemoteName: string | null;
branchMergeRef: string | null;
upstreamStatus: UpstreamStatus | 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 {
return readPaseoWorktreeMetadata(worktreeRoot)?.baseRefName ?? null;
return storedBaseRefFromMetadata(readPaseoWorktreeMetadata(worktreeRoot));
}
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
// 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 {
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> {
const { stdout } = await runGitCommand(["status", "--porcelain"], {
cwd,
@@ -1450,29 +1488,25 @@ async function resolveBaseRef(repoRoot: string): Promise<string | null> {
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 {
localName: string;
originRef: string;
}
function normalizeComparisonBaseRefName(input: string): ComparisonBaseRefName {
const localName = normalizeLocalBranchRefName(input);
const localName = branchNameFromRef(input);
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(
cwd: string,
fullRef: string,
@@ -1487,11 +1521,23 @@ async function doesGitRefExist(
return result.exitCode === 0;
}
function isQualifiedBranchRef(ref: string): boolean {
return ref.startsWith("refs/heads/") || ref.startsWith("refs/remotes/");
}
async function resolveBestComparisonBaseRef(
cwd: string,
baseRef: string,
context?: CheckoutContext,
): 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 [hasLocal, hasOrigin] = await Promise.all([
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}`);
}
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([
doesGitRefExist(cwd, `refs/heads/${normalizedBaseRef}`),
doesGitRefExist(cwd, `refs/remotes/origin/${normalizedBaseRef}`),
@@ -1551,7 +1604,7 @@ async function getAheadBehind(
currentBranch: string,
context?: CheckoutContext,
): Promise<AheadBehind | null> {
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
const normalizedBaseRef = branchNameFromRef(baseRef);
if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) {
return null;
}
@@ -1575,48 +1628,32 @@ async function getAheadBehind(
return { ahead, behind };
}
async function getConfiguredUpstreamRef(
cwd: string,
currentBranch: string,
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;
interface UpstreamStatus {
ref: string;
aheadBehind: AheadBehind;
}
async function getOriginAheadBehind(
async function getUpstreamStatus(
cwd: string,
currentBranch: string,
context?: CheckoutContext,
): Promise<AheadBehind | null> {
if (!currentBranch) {
return null;
}
const upstreamRef = await getConfiguredUpstreamRef(cwd, currentBranch, context);
if (!upstreamRef) {
return null;
}
): Promise<UpstreamStatus | null> {
try {
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 },
);
const [aheadRaw, behindRaw] = stdout.trim().split(/\s+/);
const ahead = Number.parseInt(aheadRaw ?? "", 10);
const behind = Number.parseInt(behindRaw ?? "", 10);
return Number.isNaN(ahead) || Number.isNaN(behind) ? null : { ahead, behind };
const [ref = "", track = ""] = stdout.trim().split("\0", 2);
if (!ref || track === "gone") {
return null;
}
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 {
return null;
}
@@ -1674,9 +1711,7 @@ function buildPullRequestLookupTargetFromBranchConfig(
const originRepo = parseRepositoryIdentityFromRemote(input.originRemoteUrl);
const isSameRepo = areSameGitHubRepository(remoteRepo, originRepo);
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
const normalizedBaseRef = input.resolvedBaseRef
? normalizeLocalBranchRefName(input.resolvedBaseRef)
: null;
const normalizedBaseRef = input.resolvedBaseRef ? branchNameFromRef(input.resolvedBaseRef) : null;
if (
trackedHeadRef === normalizedBaseRef &&
!doesLocalBranchNameIdentifyTrackedHead(
@@ -1740,9 +1775,7 @@ function buildPullRequestLookupTargetFromPushConfig(
: null;
const isSameRepo = areSameGitHubRepository(remoteRepo, originRepo);
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
const normalizedBaseRef = input.resolvedBaseRef
? normalizeLocalBranchRefName(input.resolvedBaseRef)
: null;
const normalizedBaseRef = input.resolvedBaseRef ? branchNameFromRef(input.resolvedBaseRef) : null;
if (pushedHeadRef === normalizedBaseRef && !headRepositoryOwner) {
return null;
}
@@ -1855,7 +1888,7 @@ export async function getCheckoutSnapshotFacts(
const paseoWorktreeMetadata = inspected.paseoWorktree.isPaseoOwnedWorktree
? readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot)
: null;
const storedBaseRef = paseoWorktreeMetadata?.baseRefName ?? null;
const storedBaseRef = storedBaseRefFromMetadata(paseoWorktreeMetadata);
const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd));
const mainRepoRoot = await getMainRepoRootFromCommonDir(
cwd,
@@ -1866,7 +1899,7 @@ export async function getCheckoutSnapshotFacts(
if (
resolvedBaseRef &&
inspected.currentBranch &&
normalizeLocalBranchRefName(resolvedBaseRef) !== inspected.currentBranch
branchNameFromRef(resolvedBaseRef) !== inspected.currentBranch
) {
comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef, context).catch(
() => null,
@@ -1876,6 +1909,9 @@ export async function getCheckoutSnapshotFacts(
let branchRemoteName: string | null = null;
let branchMergeRef: string | null = null;
let branchRemoteUrl: string | null = null;
const upstreamStatusPromise = inspected.currentBranch
? getUpstreamStatus(cwd, inspected.currentBranch, context)
: Promise.resolve(null);
if (inspected.currentBranch) {
branchRemoteName = await getGitConfigValue(
cwd,
@@ -1919,6 +1955,7 @@ export async function getCheckoutSnapshotFacts(
pullRequestLookupTarget,
context,
);
const upstreamStatus = await upstreamStatusPromise;
return {
isGit: true,
@@ -1934,6 +1971,7 @@ export async function getCheckoutSnapshotFacts(
comparisonBaseRef,
branchRemoteName,
branchMergeRef,
upstreamStatus,
pullRequestLookupTarget,
};
}
@@ -2097,16 +2135,18 @@ export async function getCheckoutStatus(
const baseRef = facts.resolvedBaseRef;
const mainRepoRoot = facts.mainRepoRoot;
const factsContext = { ...context, facts };
const [aheadBehind, originAheadBehind] = await Promise.all([
const aheadBehind =
baseRef && currentBranch
? getAheadBehind(cwd, baseRef, currentBranch, factsContext)
: Promise.resolve(null),
hasRemote && currentBranch
? getOriginAheadBehind(cwd, currentBranch, factsContext)
: Promise.resolve(null),
]);
const aheadOfOrigin = originAheadBehind?.ahead ?? null;
const behindOfOrigin = originAheadBehind?.behind ?? null;
? await getAheadBehind(cwd, baseRef, currentBranch, factsContext)
: null;
const upstreamStatus = facts.upstreamStatus;
// The wire carries the display name: clients label the base with it and send it back to
// request diffs and merges. The exact ref stays in worktree.json and in facts, where the
// comparisons and actions read it.
const displayBaseRef = baseRef ? branchNameFromRef(baseRef) : null;
const upstreamRef = upstreamStatus?.ref ?? null;
const aheadOfOrigin = upstreamStatus?.aheadBehind.ahead ?? null;
const behindOfOrigin = upstreamStatus?.aheadBehind.behind ?? null;
if (paseoWorktree.isPaseoOwnedWorktree && baseRef) {
return {
@@ -2115,8 +2155,9 @@ export async function getCheckoutStatus(
mainRepoRoot: mainRepoRoot ?? worktreeRoot,
currentBranch,
isDirty,
baseRef,
baseRef: displayBaseRef ?? baseRef,
aheadBehind,
upstreamRef,
aheadOfOrigin,
behindOfOrigin,
hasRemote,
@@ -2132,8 +2173,9 @@ export async function getCheckoutStatus(
mainRepoRoot && resolve(mainRepoRoot) !== resolve(worktreeRoot) ? mainRepoRoot : null,
currentBranch,
isDirty,
baseRef,
baseRef: displayBaseRef,
aheadBehind,
upstreamRef,
aheadOfOrigin,
behindOfOrigin,
hasRemote,
@@ -2345,11 +2387,18 @@ async function tryResolveCheckoutCommitsBaseRef(
if (!baseRef) {
return null;
}
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
const normalizedBaseRef = branchNameFromRef(baseRef);
if (!normalizedBaseRef || normalizedBaseRef === currentBranch) {
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({
@@ -2365,7 +2414,7 @@ export async function listCheckoutCommits({
}
const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
const normalizedBaseRef = resolvedBaseRef ? normalizeLocalBranchRefName(resolvedBaseRef) : null;
const normalizedBaseRef = resolvedBaseRef ? branchNameFromRef(resolvedBaseRef) : null;
let comparisonBaseRef = await tryResolveCheckoutCommitsBaseRef(
cwd,
resolvedBaseRef,
@@ -2991,13 +3040,14 @@ async function resolveCheckoutDiffRefs(
return { baseRef: "HEAD", includeUntracked: true };
}
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
const baseRef = compare.baseRef ?? resolvedBaseRef;
const baseRef = resolveOperationBaseRef({
storedBaseRef,
resolvedBaseRef,
requestedBaseRef: compare.baseRef,
});
if (!baseRef) {
return null;
}
if (storedBaseRef && compare.baseRef && compare.baseRef !== storedBaseRef) {
throw baseRefMismatchError({ stored: storedBaseRef, requested: compare.baseRef });
}
const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef);
return {
baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef,
@@ -3209,18 +3259,18 @@ export async function mergeToBase(
await requireGitRepo(cwd);
const currentBranch = await getCurrentBranch(cwd);
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
const baseRef = options.baseRef ?? resolvedBaseRef;
const baseRef = resolveOperationBaseRef({
storedBaseRef,
resolvedBaseRef,
requestedBaseRef: options.baseRef,
});
if (!baseRef) {
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) {
throw new Error("Unable to determine current branch for merge");
}
let normalizedBaseRef = baseRef;
normalizedBaseRef = normalizeLocalBranchRefName(normalizedBaseRef);
const normalizedBaseRef = resolveMergeTargetBranch(baseRef);
const currentWorktreeRoot = (await getWorktreeRoot(cwd, context)) ?? cwd;
if (normalizedBaseRef === currentBranch) {
return currentWorktreeRoot;
@@ -3285,13 +3335,14 @@ export async function mergeFromBase(
}
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
const baseRef = options.baseRef ?? resolvedBaseRef;
const baseRef = resolveOperationBaseRef({
storedBaseRef,
resolvedBaseRef,
requestedBaseRef: options.baseRef,
});
if (!baseRef) {
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;
if (requireCleanTarget) {
@@ -3304,8 +3355,7 @@ export async function mergeFromBase(
}
}
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
const bestBaseRef = await resolveMostAheadBaseRef(cwd, normalizedBaseRef);
const bestBaseRef = await resolveMostAheadBaseRef(cwd, baseRef);
if (bestBaseRef === currentBranch) {
return;
}
@@ -3619,17 +3669,18 @@ export async function createPullRequest(
const head = options.head ?? (await getCurrentBranch(cwd));
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
const base = options.base ?? resolvedBaseRef;
const base = resolveOperationBaseRef({
storedBaseRef,
resolvedBaseRef,
requestedBaseRef: options.base,
});
if (!head) {
throw new Error("Unable to determine head branch for PR");
}
if (!base) {
throw new Error("Unable to determine base branch for PR");
}
const normalizedBase = normalizeLocalBranchRefName(base);
if (storedBaseRef && options.base && options.base !== storedBaseRef) {
throw baseRefMismatchError({ stored: storedBaseRef, requested: options.base });
}
const normalizedBase = branchNameFromRef(base);
// The push deliberately happens before the adapter resolves the target
// repository: slug resolution is adapter-internal (e.g. `gh repo view`, which
+51 -33
View File
@@ -9,15 +9,20 @@ const ChangeRequestLookupTargetSchema = z.object({
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({
version: z.literal(1),
baseRefName: z.string().min(1),
baseRef: z.string().min(1).optional(),
changeRequestLookupTarget: ChangeRequestLookupTargetSchema.optional(),
});
const PaseoWorktreeMetadataV2Schema = z.object({
version: z.literal(2),
baseRefName: z.string().min(1),
baseRef: z.string().min(1).optional(),
changeRequestLookupTarget: ChangeRequestLookupTargetSchema.optional(),
firstAgentBranchAutoName: z
.discriminatedUnion("status", [
@@ -133,44 +138,72 @@ export function getPaseoWorktreeMetadataPath(worktreeRoot: string): string {
return join(gitDir, "paseo", "worktree.json");
}
export function normalizeBaseRefName(input: string): string {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("Base branch is required");
}
const REMOTE_TRACKING_PREFIX = "refs/remotes/";
/**
* 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/")) {
return trimmed.slice("refs/heads/".length);
}
if (trimmed.startsWith("refs/remotes/origin/")) {
return trimmed.slice("refs/remotes/origin/".length);
if (trimmed.startsWith(REMOTE_TRACKING_PREFIX)) {
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/")) {
return trimmed.slice("origin/".length);
}
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(
worktreeRoot: string,
options: {
baseRefName: string;
baseRef?: string;
changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint;
},
): void {
const baseRefName = normalizeBaseRefName(options.baseRefName);
if (baseRefName === "HEAD") {
throw new Error("Base branch cannot be HEAD");
}
if (baseRefName.includes("..") || baseRefName.includes("@{")) {
throw new Error(`Invalid base branch: ${baseRefName}`);
}
if (!/^[0-9A-Za-z._/-]+$/.test(baseRefName)) {
throw new Error(`Invalid base branch: ${baseRefName}`);
assertValidBaseRef(baseRefName);
const baseRef = options.baseRef?.trim();
if (baseRef) {
assertValidBaseRef(baseRef);
}
const metadata: PaseoWorktreeMetadata = {
version: 1,
baseRefName,
...(baseRef ? { baseRef } : {}),
...(options.changeRequestLookupTarget
? { changeRequestLookupTarget: options.changeRequestLookupTarget }
: {}),
@@ -192,14 +225,8 @@ export function writePaseoWorktreeRuntimeMetadata(
}
const next: PaseoWorktreeMetadata = {
...current,
version: 2,
baseRefName: current.baseRefName,
...(current.changeRequestLookupTarget
? { changeRequestLookupTarget: current.changeRequestLookupTarget }
: {}),
...(current.version === 2 && current.firstAgentBranchAutoName
? { firstAgentBranchAutoName: current.firstAgentBranchAutoName }
: {}),
runtime: {
worktreePort: options.worktreePort,
},
@@ -222,16 +249,12 @@ export function writePaseoWorktreeFirstAgentBranchAutoNameMetadata(
}
writePaseoWorktreeMetadataFile(worktreeRoot, {
...current,
version: 2,
baseRefName: current.baseRefName,
...(current.changeRequestLookupTarget
? { changeRequestLookupTarget: current.changeRequestLookupTarget }
: {}),
firstAgentBranchAutoName: {
status: "pending",
placeholderBranchName,
},
...(current.version === 2 && current.runtime ? { runtime: current.runtime } : {}),
});
}
@@ -245,17 +268,12 @@ export function markPaseoWorktreeFirstAgentBranchAutoNameAttempted(
}
const next: PaseoWorktreeMetadata = {
version: 2,
baseRefName: current.baseRefName,
...(current.changeRequestLookupTarget
? { changeRequestLookupTarget: current.changeRequestLookupTarget }
: {}),
...current,
firstAgentBranchAutoName: {
status: "attempted",
placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName,
attemptedAt: options.attemptedAt ?? new Date().toISOString(),
},
...(current.runtime ? { runtime: current.runtime } : {}),
};
writePaseoWorktreeMetadataFile(worktreeRoot, next);
return next;
@@ -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 });
+6
View File
@@ -1271,6 +1271,7 @@ export const createWorktree = async ({
writePaseoWorktreeMetadata(worktreePath, {
baseRefName: sourcePlan.metadataBaseRefName,
...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}),
...(sourcePlan.changeRequestLookupTarget
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
: {}),
@@ -1300,7 +1301,11 @@ interface ResolveWorktreeSourcePlanOptions {
interface WorktreeSourcePlan {
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;
metadataBaseRef?: string;
changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint;
addArguments: string[];
pushRemote?: {
@@ -1334,6 +1339,7 @@ async function resolveWorktreeSourcePlan({
return {
branchName: newBranchName,
metadataBaseRefName: normalizedBaseBranch,
metadataBaseRef: resolvedBaseBranch,
addArguments: ["-b", newBranchName, "--no-track", base],
};
}