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
+216 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { execFileSync, execSync } from "child_process";
import { execFileSync, execSync, spawnSync } from "child_process";
import {
existsSync,
mkdtempSync,
@@ -187,6 +187,14 @@ async function readPullRequestLookupTargetFromFacts(
return facts.pullRequestLookupTarget;
}
function getBranchUpstream(cwd: string): string | null {
const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], {
cwd,
encoding: "utf8",
});
return result.status === 0 ? result.stdout.trim() : null;
}
function setupRemoteTrackingMain(
repoDir: string,
tempDir: string,
@@ -1700,6 +1708,183 @@ const x = 1;
expect(upstream).toBe("origin/feature");
});
it("pushes the current branch to its configured upstream", async () => {
const originDir = join(tempDir, "origin.git");
const prRemoteDir = join(tempDir, "pr-remote.git");
execFileSync("git", ["clone", "--bare", repoDir, originDir]);
execFileSync("git", ["clone", "--bare", repoDir, prRemoteDir]);
execFileSync("git", ["remote", "add", "origin", originDir], { cwd: repoDir });
execFileSync("git", ["remote", "add", "paseo-pr-526", prRemoteDir], { cwd: repoDir });
execFileSync("git", ["checkout", "-b", "therainisme/main"], { cwd: repoDir });
execFileSync("git", ["fetch", "paseo-pr-526", "main"], { cwd: repoDir });
execFileSync("git", ["config", "branch.therainisme/main.remote", "paseo-pr-526"], {
cwd: repoDir,
});
execFileSync("git", ["config", "branch.therainisme/main.merge", "refs/heads/main"], {
cwd: repoDir,
});
writeFileSync(join(repoDir, "fork-pr.txt"), "fork pr edit\n");
execFileSync("git", ["add", "fork-pr.txt"], { cwd: repoDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fork pr edit"], {
cwd: repoDir,
});
const localHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir })
.toString()
.trim();
await pushCurrentBranch(repoDir);
const prRemoteMain = execFileSync("git", [
"--git-dir",
prRemoteDir,
"rev-parse",
"refs/heads/main",
])
.toString()
.trim();
const upstream = execFileSync(
"git",
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
{ cwd: repoDir },
)
.toString()
.trim();
expect(prRemoteMain).toBe(localHead);
expect(upstream).toBe("paseo-pr-526/main");
});
it("pushes the current branch to its configured push remote", async () => {
const originDir = join(tempDir, "origin.git");
const prRemoteDir = join(tempDir, "pr-remote.git");
execFileSync("git", ["clone", "--bare", repoDir, originDir]);
execFileSync("git", ["clone", "--bare", repoDir, prRemoteDir]);
execFileSync("git", ["remote", "add", "origin", originDir], { cwd: repoDir });
execFileSync("git", ["remote", "add", "paseo-pr-526", prRemoteDir], { cwd: repoDir });
execFileSync("git", ["checkout", "-b", "therainisme/main"], { cwd: repoDir });
execFileSync("git", ["config", "branch.therainisme/main.pushRemote", "paseo-pr-526"], {
cwd: repoDir,
});
execFileSync("git", ["config", "remote.paseo-pr-526.push", "HEAD:refs/heads/main"], {
cwd: repoDir,
});
writeFileSync(join(repoDir, "fork-pr.txt"), "fork pr edit\n");
execFileSync("git", ["add", "fork-pr.txt"], { cwd: repoDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fork pr edit"], {
cwd: repoDir,
});
const localHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir })
.toString()
.trim();
const upstreamBeforePush = getBranchUpstream(repoDir);
await pushCurrentBranch(repoDir);
const prRemoteMain = execFileSync("git", [
"--git-dir",
prRemoteDir,
"rev-parse",
"refs/heads/main",
])
.toString()
.trim();
const originBranch = spawnSync(
"git",
["--git-dir", originDir, "show-ref", "--verify", "--quiet", "refs/heads/therainisme/main"],
{ encoding: "utf8" },
);
const trackedPrRemoteHead = execFileSync(
"git",
["rev-parse", "refs/remotes/paseo-pr-526/main"],
{
cwd: repoDir,
},
)
.toString()
.trim();
const afterPushStatus = await getCheckoutStatus(repoDir);
expect(upstreamBeforePush).toBeNull();
expect(prRemoteMain).toBe(localHead);
expect(getBranchUpstream(repoDir)).toBe("paseo-pr-526/main");
expect(trackedPrRemoteHead).toBe(localHead);
expect(afterPushStatus).toMatchObject({ aheadOfOrigin: 0, behindOfOrigin: 0 });
expect(originBranch.status).toBe(1);
});
it("refreshes the tracked ref after pushing through a configured push remote", async () => {
const originDir = join(tempDir, "origin.git");
execFileSync("git", ["clone", "--bare", repoDir, originDir]);
execFileSync("git", ["remote", "add", "origin", originDir], { cwd: repoDir });
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["push", "-u", "origin", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "paseo-pr-1790", originDir], { cwd: repoDir });
execFileSync("git", ["config", "branch.feature.pushRemote", "paseo-pr-1790"], {
cwd: repoDir,
});
execFileSync("git", ["config", "remote.paseo-pr-1790.push", "HEAD:refs/heads/feature"], {
cwd: repoDir,
});
writeFileSync(join(repoDir, "feature.txt"), "feature edit\n");
execFileSync("git", ["add", "feature.txt"], { cwd: repoDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature edit"], {
cwd: repoDir,
});
const localHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir })
.toString()
.trim();
const beforePushStatus = await getCheckoutStatus(repoDir);
await pushCurrentBranch(repoDir);
const trackedOriginHead = execFileSync("git", ["rev-parse", "refs/remotes/origin/feature"], {
cwd: repoDir,
})
.toString()
.trim();
const afterPushStatus = await getCheckoutStatus(repoDir);
expect(beforePushStatus).toMatchObject({ aheadOfOrigin: 1, behindOfOrigin: 0 });
expect(trackedOriginHead).toBe(localHead);
expect(afterPushStatus).toMatchObject({ aheadOfOrigin: 0, behindOfOrigin: 0 });
});
it("pushes ordinary branches to their configured upstream", async () => {
const originDir = join(tempDir, "origin.git");
const upstreamDir = join(tempDir, "upstream.git");
execFileSync("git", ["clone", "--bare", repoDir, originDir]);
execFileSync("git", ["clone", "--bare", repoDir, upstreamDir]);
execFileSync("git", ["remote", "add", "origin", originDir], { cwd: repoDir });
execFileSync("git", ["remote", "add", "upstream", upstreamDir], { cwd: repoDir });
execFileSync("git", ["fetch", "upstream", "main"], { cwd: repoDir });
execFileSync("git", ["checkout", "-b", "contrib"], { cwd: repoDir });
execFileSync("git", ["config", "branch.contrib.remote", "upstream"], { cwd: repoDir });
execFileSync("git", ["config", "branch.contrib.merge", "refs/heads/main"], { cwd: repoDir });
writeFileSync(join(repoDir, "contrib.txt"), "contrib edit\n");
execFileSync("git", ["add", "contrib.txt"], { cwd: repoDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "contrib edit"], {
cwd: repoDir,
});
const localHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir })
.toString()
.trim();
await pushCurrentBranch(repoDir);
const upstreamMainAfter = execFileSync("git", [
"--git-dir",
upstreamDir,
"rev-parse",
"refs/heads/main",
])
.toString()
.trim();
const originContrib = spawnSync(
"git",
["--git-dir", originDir, "show-ref", "--verify", "--quiet", "refs/heads/contrib"],
{ encoding: "utf8" },
);
expect(upstreamMainAfter).toBe(localHead);
expect(originContrib.status).toBe(1);
});
it("lists merged local and remote branch suggestions with provenance", async () => {
const remoteDir = join(tempDir, "remote.git");
execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]);
@@ -2110,6 +2295,36 @@ const x = 1;
expect(lookupTarget).toEqual({ headRef: "main", headRepositoryOwner: "chethanuk" });
});
it("uses the configured push remote for fork PR lookup when upstream is absent", async () => {
execFileSync("git", ["checkout", "-b", "chethanuk/main"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
cwd: repoDir,
});
execFileSync("git", ["remote", "add", "paseo-pr-345", "git@github.com:chethanuk/paseo.git"], {
cwd: repoDir,
});
execFileSync("git", ["config", "branch.chethanuk/main.pushRemote", "paseo-pr-345"], {
cwd: repoDir,
});
execFileSync("git", ["config", "remote.paseo-pr-345.push", "HEAD:refs/heads/main"], {
cwd: repoDir,
});
const requestedTargets: RequestedPullRequestTarget[] = [];
const github = createGitHubServiceRecordingPullRequestTargets({ requestedTargets });
const factsTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome);
await getPullRequestStatus(
repoDir,
github,
{ force: true, reason: "push-remote-pr-lookup" },
{ paseoHome },
);
expect(getBranchUpstream(repoDir)).toBeNull();
expect(factsTarget).toEqual({ headRef: "main", headRepositoryOwner: "chethanuk" });
expect(requestedTargets).toEqual([{ headRef: "main", headRepositoryOwner: "chethanuk" }]);
});
it("keeps the local branch lookup when same-repo tracking points at the base branch", async () => {
execFileSync("git", ["checkout", "-b", "tender-parrot"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {