Fix pushed state for checked-out PR worktrees (#1804)

* fix(worktree): track checkout PR branches correctly

Checkout-PR worktrees could point branch tracking at a PR push remote without fetching a matching remote-tracking ref. Configure same-repo PRs to track origin, make fork PR remotes fetchable before assigning upstream, and push fork PR branches to their configured remote.

* fix(worktree): scope PR push routing to Paseo targets

Review caught that using ordinary upstream config for Push could send normal branches to non-origin remotes and mishandle deduped PR branch names. Store a Paseo-specific push target during PR worktree creation, then have Push honor only that marker before falling back to origin.

* fix(worktree): use git upstream for PR pushes

* fix(worktree): allow PR checkout without head upstream

* fix(worktree): preserve fork PR git push target

* fix(worktree): preserve PR push targets

* fix(worktree): preserve PR push status

* fix(worktree): set upstream after push-only PR push
This commit is contained in:
Mohamed Boudra
2026-06-29 20:21:19 +02:00
committed by GitHub
parent 31d14ec8a7
commit f31bc2b8b7
6 changed files with 998 additions and 29 deletions
+242 -3
View File
@@ -79,6 +79,15 @@ interface PullRequestLookupTargetBranchConfig {
resolvedBaseRef: string | null;
}
interface PullRequestLookupTargetPushConfig {
currentBranch: string;
pushRemoteName: string | null;
pushRefspec: string | null;
pushRemoteUrl: string | null;
originRemoteUrl: string | null;
resolvedBaseRef: string | null;
}
function getErrorStderr(error: Error): string {
return "stderr" in error && typeof error.stderr === "string" ? error.stderr : "";
}
@@ -1124,6 +1133,24 @@ async function getGitConfigValue(
}
}
async function getGitRemotePushUrl(
cwd: string,
remoteName: string,
context?: CheckoutContext,
): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["remote", "get-url", "--push", remoteName], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
logger: context?.logger,
});
const value = stdout.trim();
return value.length > 0 ? value : null;
} catch {
return null;
}
}
function parseBranchMergeHeadRef(mergeRef: string | null): string | null {
const prefix = "refs/heads/";
if (!mergeRef?.startsWith(prefix)) {
@@ -1156,7 +1183,14 @@ async function resolvePullRequestStatusLookupTarget(
resolvedBaseRef: null,
});
if (localBranchTarget.headRef === currentBranch) {
return localBranchTarget;
const pushTarget = await resolvePullRequestLookupTargetFromPushConfig(
cwd,
currentBranch,
null,
null,
context,
);
return pushTarget ?? localBranchTarget;
}
const [branchRemoteUrl, originRemoteUrl, resolvedBaseRef] = await Promise.all([
@@ -1164,7 +1198,7 @@ async function resolvePullRequestStatusLookupTarget(
getGitConfigValue(cwd, "remote.origin.url", context),
getResolvedBaseRefForCwd(cwd, context),
]);
return buildPullRequestLookupTargetFromBranchConfig({
const branchTarget = buildPullRequestLookupTargetFromBranchConfig({
currentBranch,
branchRemoteName,
branchMergeRef,
@@ -1172,6 +1206,17 @@ async function resolvePullRequestStatusLookupTarget(
originRemoteUrl,
resolvedBaseRef,
});
if (branchTarget.headRef !== currentBranch || branchTarget.headRepositoryOwner) {
return branchTarget;
}
const pushTarget = await resolvePullRequestLookupTargetFromPushConfig(
cwd,
currentBranch,
originRemoteUrl,
resolvedBaseRef,
context,
);
return pushTarget ?? branchTarget;
}
export async function resolveAbsoluteGitDir(cwd: string): Promise<string | null> {
@@ -1553,6 +1598,65 @@ function buildPullRequestLookupTargetFromBranchConfig(
};
}
function buildPullRequestLookupTargetFromPushConfig(
input: PullRequestLookupTargetPushConfig,
): PullRequestStatusLookupTarget | null {
const pushedHeadRef = parseHeadPushRefspec(input.pushRefspec);
if (!input.pushRemoteName || !pushedHeadRef || pushedHeadRef === input.currentBranch) {
return null;
}
const remoteRepo = input.pushRemoteUrl ? parseGitHubRepoFromRemote(input.pushRemoteUrl) : null;
const originRepo = input.originRemoteUrl
? parseGitHubRepoFromRemote(input.originRemoteUrl)
: null;
const isSameRepo = Boolean(remoteRepo && originRepo && remoteRepo === originRepo);
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
const normalizedBaseRef = input.resolvedBaseRef
? normalizeLocalBranchRefName(input.resolvedBaseRef)
: null;
if (pushedHeadRef === normalizedBaseRef && !headRepositoryOwner) {
return null;
}
return {
headRef: pushedHeadRef,
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
};
}
async function resolvePullRequestLookupTargetFromPushConfig(
cwd: string,
currentBranch: string,
knownOriginRemoteUrl: string | null,
knownResolvedBaseRef: string | null,
context?: CheckoutContext,
): Promise<PullRequestStatusLookupTarget | null> {
const pushRemoteName = await getGitConfigValue(
cwd,
`branch.${currentBranch}.pushRemote`,
context,
);
if (!pushRemoteName) {
return null;
}
const [pushRefspec, pushRemoteUrl, originRemoteUrl, resolvedBaseRef] = await Promise.all([
getGitConfigValue(cwd, `remote.${pushRemoteName}.push`, context),
getGitConfigValue(cwd, `remote.${pushRemoteName}.url`, context),
knownOriginRemoteUrl === null ? getGitConfigValue(cwd, "remote.origin.url", context) : null,
knownResolvedBaseRef === null ? getResolvedBaseRefForCwd(cwd, context) : null,
]);
return buildPullRequestLookupTargetFromPushConfig({
currentBranch,
pushRemoteName,
pushRefspec,
pushRemoteUrl,
originRemoteUrl: knownOriginRemoteUrl ?? originRemoteUrl,
resolvedBaseRef: knownResolvedBaseRef ?? resolvedBaseRef,
});
}
export async function getCheckoutSnapshotFacts(
cwd: string,
context?: CheckoutContext,
@@ -1602,7 +1706,7 @@ export async function getCheckoutSnapshotFacts(
]);
}
}
const pullRequestLookupTarget = inspected.currentBranch
let pullRequestLookupTarget = inspected.currentBranch
? buildPullRequestLookupTargetFromBranchConfig({
currentBranch: inspected.currentBranch,
branchRemoteName,
@@ -1612,6 +1716,20 @@ export async function getCheckoutSnapshotFacts(
resolvedBaseRef,
})
: null;
if (
inspected.currentBranch &&
pullRequestLookupTarget?.headRef === inspected.currentBranch &&
!pullRequestLookupTarget.headRepositoryOwner
) {
pullRequestLookupTarget =
(await resolvePullRequestLookupTargetFromPushConfig(
cwd,
inspected.currentBranch,
inspected.remoteUrl,
resolvedBaseRef,
context,
)) ?? pullRequestLookupTarget;
}
return {
isGit: true,
@@ -2689,6 +2807,27 @@ export async function pushCurrentBranch(cwd: string, github?: GitHubService): Pr
if (!currentBranch || currentBranch === "HEAD") {
throw new Error("Unable to determine current branch for push");
}
const configuredPushTarget = await getCurrentBranchConfiguredPushTarget(cwd, currentBranch);
if (configuredPushTarget) {
await runGitCommand(
["push", configuredPushTarget.remoteName, `HEAD:refs/heads/${configuredPushTarget.headRef}`],
{ cwd, timeout: 120_000 },
);
await refreshCurrentBranchTrackedRefAfterPush(cwd, currentBranch, configuredPushTarget);
github?.invalidate({ cwd });
return;
}
const upstreamTarget = await getCurrentBranchUpstreamPushTarget(cwd, currentBranch);
if (upstreamTarget) {
await runGitCommand(
["push", "-u", upstreamTarget.remoteName, `HEAD:refs/heads/${upstreamTarget.headRef}`],
{ cwd, timeout: 120_000 },
);
github?.invalidate({ cwd });
return;
}
const hasRemote = await hasOriginRemote(cwd);
if (!hasRemote) {
throw new Error("Remote 'origin' is not configured.");
@@ -2697,6 +2836,106 @@ export async function pushCurrentBranch(cwd: string, github?: GitHubService): Pr
github?.invalidate({ cwd });
}
async function getCurrentBranchConfiguredPushTarget(
cwd: string,
currentBranch: string,
): Promise<{ remoteName: string; headRef: string } | null> {
const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.pushRemote`);
const pushRefspec = remoteName ? await getGitConfigValue(cwd, `remote.${remoteName}.push`) : null;
const headRef = parseHeadPushRefspec(pushRefspec);
if (!remoteName || !headRef) {
return null;
}
const remoteUrl = await getGitConfigValue(cwd, `remote.${remoteName}.url`);
return remoteUrl ? { remoteName, headRef } : null;
}
async function refreshCurrentBranchTrackedRefAfterPush(
cwd: string,
currentBranch: string,
pushedTarget: { remoteName: string; headRef: string },
): Promise<void> {
const trackingRemoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`);
const trackingMergeRef = await getGitConfigValue(cwd, `branch.${currentBranch}.merge`);
const trackingHeadRef = parseBranchMergeHeadRef(trackingMergeRef);
if (!trackingRemoteName && !trackingMergeRef) {
const updated = await updateRemoteTrackingRef(
cwd,
pushedTarget.remoteName,
pushedTarget.headRef,
);
if (!updated) {
return;
}
await runGitCommand(["config", `branch.${currentBranch}.remote`, pushedTarget.remoteName], {
cwd,
});
await runGitCommand(
["config", `branch.${currentBranch}.merge`, `refs/heads/${pushedTarget.headRef}`],
{
cwd,
},
);
return;
}
if (!trackingRemoteName || trackingHeadRef !== pushedTarget.headRef) {
return;
}
const [trackingRemotePushUrl, pushedRemotePushUrl] = await Promise.all([
getGitRemotePushUrl(cwd, trackingRemoteName),
getGitRemotePushUrl(cwd, pushedTarget.remoteName),
]);
if (!trackingRemotePushUrl || trackingRemotePushUrl !== pushedRemotePushUrl) {
return;
}
await updateRemoteTrackingRef(cwd, trackingRemoteName, trackingHeadRef);
}
async function updateRemoteTrackingRef(
cwd: string,
remoteName: string,
headRef: string,
): Promise<boolean> {
const trackingRef = `refs/remotes/${remoteName}/${headRef}`;
const checkRef = await runGitCommand(["check-ref-format", trackingRef], {
cwd,
acceptExitCodes: [0, 1],
});
if (checkRef.exitCode !== 0) {
return false;
}
await runGitCommand(["update-ref", trackingRef, "HEAD"], { cwd, timeout: 120_000 });
return true;
}
async function getCurrentBranchUpstreamPushTarget(
cwd: string,
currentBranch: string,
): Promise<{ remoteName: string; headRef: string } | null> {
const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`);
const mergeRef = remoteName
? await getGitConfigValue(cwd, `branch.${currentBranch}.merge`)
: null;
const headRef = parseBranchMergeHeadRef(mergeRef);
if (!remoteName || !headRef) {
return null;
}
const remoteUrl = await getGitConfigValue(cwd, `remote.${remoteName}.url`);
return remoteUrl ? { remoteName, headRef } : null;
}
function parseHeadPushRefspec(refspec: string | null): string | null {
const prefix = "HEAD:refs/heads/";
const normalized = refspec?.trim().replace(/^\+/, "");
if (!normalized?.startsWith(prefix)) {
return null;
}
const headRef = normalized.slice(prefix.length).trim();
return headRef.length > 0 ? headRef : null;
}
export interface CreatePullRequestOptions {
title: string;
body?: string;