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
+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