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