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
@@ -113,6 +113,55 @@ describe("resolveWorktreeCreationIntent", () => {
expect(deps.headRefLookups).toEqual([{ cwd: repoRoot, number: 42 }]);
});
test("does not configure a synthetic push remote for same-repo PR targets", async () => {
const deps = createResolverHarness();
deps.github.getPullRequestCheckoutTarget = async () => ({
number: 1790,
baseRefName: "main",
headRefName: "daemon-shutdown-diagnostics",
headOwnerLogin: "getpaseo",
headRepositorySshUrl: "git@github.com:getpaseo/paseo.git",
headRepositoryUrl: "https://github.com/getpaseo/paseo",
isCrossRepository: false,
});
await expect(
resolveWorktreeCreationIntent({ action: "checkout", githubPrNumber: 1790 }, repoRoot, deps),
).resolves.toEqual({
kind: "checkout-github-pr",
githubPrNumber: 1790,
headRef: "daemon-shutdown-diagnostics",
baseRefName: "main",
trackOriginHead: true,
});
expect(deps.headRefLookups).toEqual([]);
});
test("configures the contributor remote for fork PR targets", async () => {
const deps = createResolverHarness();
deps.github.getPullRequestCheckoutTarget = async () => ({
number: 526,
baseRefName: "main",
headRefName: "main",
headOwnerLogin: "therainisme",
headRepositorySshUrl: "git@github.com:therainisme/paseo.git",
headRepositoryUrl: "https://github.com/therainisme/paseo",
isCrossRepository: true,
});
await expect(
resolveWorktreeCreationIntent({ action: "checkout", githubPrNumber: 526 }, repoRoot, deps),
).resolves.toEqual({
kind: "checkout-github-pr",
githubPrNumber: 526,
headRef: "main",
baseRefName: "main",
localBranchName: "therainisme/main",
pushRemoteUrl: "git@github.com:therainisme/paseo.git",
});
expect(deps.headRefLookups).toEqual([]);
});
test("uses an explicit PR head ref without calling GitHub", async () => {
const deps = createResolverHarness();
@@ -116,9 +116,10 @@ async function resolveGitHubPrCheckoutIntent(params: {
checkoutTarget?.baseRefName?.trim() ||
(await resolveDefaultBranch(params.repoRoot, params.deps));
const localBranchName = buildGitHubPrLocalBranchName({ headRef, checkoutTarget });
const pushRemoteUrl = checkoutTarget
const pushRemoteUrl = checkoutTarget?.isCrossRepository
? checkoutTarget.headRepositorySshUrl || checkoutTarget.headRepositoryUrl || undefined
: undefined;
const trackOriginHead = checkoutTarget ? !checkoutTarget.isCrossRepository : false;
return {
kind: "checkout-github-pr",
@@ -127,6 +128,7 @@ async function resolveGitHubPrCheckoutIntent(params: {
baseRefName,
...(localBranchName !== headRef ? { localBranchName } : {}),
...(pushRemoteUrl ? { pushRemoteUrl } : {}),
...(trackOriginHead ? { trackOriginHead } : {}),
};
}
@@ -14,6 +14,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test, afterEach } from "vitest";
import type { GitHubService } from "../services/github-service.js";
import { getCheckoutStatus, pushCurrentBranch } from "../utils/checkout-git.js";
import { UnknownBranchError } from "../utils/worktree.js";
import { createWorktreeCore as createCoreWorktree } from "./worktree-core.js";
import { isPlatform } from "../test-utils/platform.js";
@@ -123,6 +124,44 @@ function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string; paseoHo
return { tempDir, repoDir, paseoHome };
}
function createSameRepoGitHubPrRemoteRepo(): {
tempDir: string;
repoDir: string;
remoteDir: string;
paseoHome: string;
} {
const { tempDir, repoDir, paseoHome } = createGitRepo();
const remoteDir = path.join(tempDir, "origin.git");
const featureBranch = "daemon-shutdown-diagnostics";
execFileSync("git", ["clone", "--bare", repoDir, remoteDir], {
stdio: "pipe",
});
execFileSync("git", ["remote", "add", "origin", remoteDir], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["checkout", "-b", featureBranch], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "same repo pr branch\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "same repo pr branch"], {
cwd: repoDir,
stdio: "pipe",
});
const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" })
.toString()
.trim();
execFileSync("git", ["push", "origin", featureBranch], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/1790/head", prHead], {
stdio: "pipe",
});
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-D", featureBranch], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir, remoteDir, paseoHome };
}
function createForkGitHubPrRemoteRepo(): {
tempDir: string;
repoDir: string;
@@ -411,6 +450,201 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
expect(result.worktree.branchName).toBe("pr-123");
});
test("checks out a same-repo GitHub PR with valid origin tracking", async () => {
const { tempDir, repoDir, remoteDir, paseoHome } = createSameRepoGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const github = {
...createGitHubServiceStub(),
getPullRequestCheckoutTarget: async () => ({
number: 1790,
baseRefName: "main",
headRefName: "daemon-shutdown-diagnostics",
headOwnerLogin: "getpaseo",
headRepositorySshUrl: remoteDir,
headRepositoryUrl: remoteDir,
isCrossRepository: false,
}),
};
const result = await createCoreWorktree(
{
cwd: repoDir,
action: "checkout",
githubPrNumber: 1790,
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
const status = await getCheckoutStatus(result.worktree.worktreePath, { paseoHome });
expect(result.worktree.branchName).toBe("daemon-shutdown-diagnostics");
expect(getBranchUpstream(result.worktree.worktreePath)).toBe(
"origin/daemon-shutdown-diagnostics",
);
expect(status).toMatchObject({
isGit: true,
currentBranch: "daemon-shutdown-diagnostics",
aheadOfOrigin: 0,
behindOfOrigin: 0,
});
});
test("checks out a same-repo GitHub PR whose head branch was deleted", async () => {
const { tempDir, repoDir, remoteDir, paseoHome } = createSameRepoGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
execFileSync(
"git",
["--git-dir", remoteDir, "update-ref", "-d", "refs/heads/daemon-shutdown-diagnostics"],
{ stdio: "pipe" },
);
const github = {
...createGitHubServiceStub(),
getPullRequestCheckoutTarget: async () => ({
number: 1790,
baseRefName: "main",
headRefName: "daemon-shutdown-diagnostics",
headOwnerLogin: "getpaseo",
headRepositorySshUrl: remoteDir,
headRepositoryUrl: remoteDir,
isCrossRepository: false,
}),
};
const result = await createCoreWorktree(
{
cwd: repoDir,
action: "checkout",
githubPrNumber: 1790,
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
const readme = readFileSync(path.join(result.worktree.worktreePath, "README.md"), "utf8");
expect(result.worktree.branchName).toBe("daemon-shutdown-diagnostics");
expect(readme.replace(/\r\n/g, "\n")).toBe("same repo pr branch\n");
expect(getBranchUpstream(result.worktree.worktreePath)).toBeNull();
});
test("pushes a deduplicated same-repo GitHub PR branch to the PR head", async () => {
const { tempDir, repoDir, remoteDir, paseoHome } = createSameRepoGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
execFileSync("git", ["remote", "set-url", "origin", `file://${remoteDir}`], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["remote", "set-url", "--push", "origin", remoteDir], {
cwd: repoDir,
stdio: "pipe",
});
const github = {
...createGitHubServiceStub(),
getPullRequestCheckoutTarget: async () => ({
number: 1790,
baseRefName: "main",
headRefName: "daemon-shutdown-diagnostics",
headOwnerLogin: "getpaseo",
headRepositorySshUrl: remoteDir,
headRepositoryUrl: remoteDir,
isCrossRepository: false,
}),
};
await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "first-pr-worktree",
action: "checkout",
githubPrNumber: 1790,
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
const second = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "second-pr-worktree",
action: "checkout",
githubPrNumber: 1790,
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
writeFileSync(path.join(second.worktree.worktreePath, "FOLLOWUP.md"), "same repo edit\n");
execFileSync("git", ["add", "FOLLOWUP.md"], {
cwd: second.worktree.worktreePath,
stdio: "pipe",
});
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "same repo edit"], {
cwd: second.worktree.worktreePath,
stdio: "pipe",
});
const localHead = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: second.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
const pushRemote = execFileSync(
"git",
["config", `branch.${second.worktree.branchName}.pushRemote`],
{
cwd: second.worktree.worktreePath,
stdio: "pipe",
},
)
.toString()
.trim();
const pushRefspec = execFileSync("git", ["config", "remote.paseo-pr-1790.push"], {
cwd: second.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
const pushRemoteUrl = execFileSync("git", ["config", "remote.paseo-pr-1790.url"], {
cwd: second.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
execFileSync("git", ["push"], { cwd: second.worktree.worktreePath, stdio: "pipe" });
const remotePrHead = execFileSync(
"git",
["--git-dir", remoteDir, "rev-parse", "refs/heads/daemon-shutdown-diagnostics"],
{ stdio: "pipe" },
)
.toString()
.trim();
const dedupedRemoteBranch = spawnSync(
"git",
[
"--git-dir",
remoteDir,
"show-ref",
"--verify",
"--quiet",
"refs/heads/daemon-shutdown-diagnostics-1",
],
{ encoding: "utf8" },
);
expect(second.worktree.branchName).toBe("daemon-shutdown-diagnostics-1");
expect(getBranchUpstream(second.worktree.worktreePath)).toBe(
"origin/daemon-shutdown-diagnostics",
);
expect(pushRemote).toBe("paseo-pr-1790");
expect(pushRefspec).toBe("HEAD:refs/heads/daemon-shutdown-diagnostics");
expect(pushRemoteUrl).toBe(remoteDir);
expect(remotePrHead).toBe(localHead);
expect(dedupedRemoteBranch.status).toBe(1);
});
test("checks out a fork PR whose head branch collides with local main", async () => {
const { tempDir, repoDir, headRemoteDir, paseoHome } = createForkGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
@@ -452,6 +686,13 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
.toString()
.trim();
const readme = readFileSync(path.join(result.worktree.worktreePath, "README.md"), "utf8");
const cleanStatus = await getCheckoutStatus(result.worktree.worktreePath, { paseoHome });
const pushRefspec = execFileSync("git", ["config", "remote.paseo-pr-526.push"], {
cwd: result.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
writeFileSync(path.join(result.worktree.worktreePath, "FOLLOWUP.md"), "maintainer edit\n");
execFileSync("git", ["add", "FOLLOWUP.md"], {
cwd: result.worktree.worktreePath,
@@ -461,11 +702,23 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
cwd: result.worktree.worktreePath,
stdio: "pipe",
});
const pushDryRunResult = spawnSync("git", ["push", "--dry-run"], {
const localHead = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: result.worktree.worktreePath,
encoding: "utf8",
});
const pushDryRun = `${pushDryRunResult.stdout}${pushDryRunResult.stderr}`;
stdio: "pipe",
})
.toString()
.trim();
execFileSync("git", ["push"], { cwd: result.worktree.worktreePath, stdio: "pipe" });
const remotePrHead = execFileSync("git", [
"--git-dir",
headRemoteDir,
"rev-parse",
"refs/heads/main",
])
.toString()
.trim();
expect(sourceBranch).toBe("main");
expect(result.intent).toEqual({
@@ -480,7 +733,115 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
expect(path.basename(result.worktree.worktreePath)).toBe("therainisme-main");
expect(worktreeBranch).toBe("therainisme/main");
expect(readme.replace(/\r\n/g, "\n")).toBe("fork pr main branch\n");
expect(pushDryRun).toContain("HEAD -> main");
expect(getBranchUpstream(result.worktree.worktreePath)).toBe("paseo-pr-526/main");
expect(pushRefspec).toBe("HEAD:refs/heads/main");
expect(cleanStatus).toMatchObject({
isGit: true,
currentBranch: "therainisme/main",
aheadOfOrigin: 0,
behindOfOrigin: 0,
});
expect(remotePrHead).toBe(localHead);
});
test("pushes a fork PR when the contributor branch cannot be fetched at checkout", async () => {
const { tempDir, repoDir, headRemoteDir, paseoHome } = createForkGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
execFileSync("git", ["--git-dir", headRemoteDir, "update-ref", "-d", "refs/heads/main"], {
stdio: "pipe",
});
const github = {
...createGitHubServiceStub(),
getPullRequestCheckoutTarget: async () => ({
number: 526,
baseRefName: "main",
headRefName: "main",
headOwnerLogin: "therainisme",
headRepositorySshUrl: headRemoteDir,
headRepositoryUrl: headRemoteDir,
isCrossRepository: true,
}),
};
const result = await createCoreWorktree(
{
cwd: repoDir,
action: "checkout",
githubPrNumber: 526,
refName: "main",
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
const pushRemote = execFileSync(
"git",
["config", `branch.${result.worktree.branchName}.pushRemote`],
{
cwd: result.worktree.worktreePath,
stdio: "pipe",
},
)
.toString()
.trim();
const pushRefspec = execFileSync("git", ["config", "remote.paseo-pr-526.push"], {
cwd: result.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
const upstreamBeforePush = getBranchUpstream(result.worktree.worktreePath);
writeFileSync(path.join(result.worktree.worktreePath, "FOLLOWUP.md"), "fork edit\n");
execFileSync("git", ["add", "FOLLOWUP.md"], {
cwd: result.worktree.worktreePath,
stdio: "pipe",
});
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fork edit"], {
cwd: result.worktree.worktreePath,
stdio: "pipe",
});
const localHead = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: result.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
await pushCurrentBranch(result.worktree.worktreePath);
const remotePrHead = execFileSync("git", [
"--git-dir",
headRemoteDir,
"rev-parse",
"refs/heads/main",
])
.toString()
.trim();
const trackedPrRemoteHead = execFileSync(
"git",
["rev-parse", "refs/remotes/paseo-pr-526/main"],
{
cwd: result.worktree.worktreePath,
stdio: "pipe",
},
)
.toString()
.trim();
const afterPushStatus = await getCheckoutStatus(result.worktree.worktreePath);
expect(result.worktree.branchName).toBe("therainisme/main");
expect(upstreamBeforePush).toBeNull();
expect(getBranchUpstream(result.worktree.worktreePath)).toBe("paseo-pr-526/main");
expect(pushRemote).toBe("paseo-pr-526");
expect(pushRefspec).toBe("HEAD:refs/heads/main");
expect(remotePrHead).toBe(localHead);
expect(trackedPrRemoteHead).toBe(localHead);
expect(afterPushStatus).toMatchObject({
isGit: true,
currentBranch: "therainisme/main",
aheadOfOrigin: 0,
behindOfOrigin: 0,
});
});
test("uses a unique local branch when the same fork PR branch already exists", async () => {
@@ -526,14 +887,8 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
expect(first.worktree.branchName).toBe("therainisme/main");
expect(second.worktree.branchName).toBe("therainisme/main-1");
expect(
execFileSync("git", ["config", "--get", "remote.paseo-pr-526.push"], {
cwd: second.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim(),
).toBe("HEAD:refs/heads/main");
expect(getBranchUpstream(first.worktree.worktreePath)).toBe("paseo-pr-526/main");
expect(getBranchUpstream(second.worktree.worktreePath)).toBe("paseo-pr-526/main");
});
test("throws a typed error for an unknown checkout branch", async () => {
+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"], {
+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;
+120 -11
View File
@@ -166,6 +166,7 @@ export type WorktreeSource =
baseRefName: string;
localBranchName?: string;
pushRemoteUrl?: string;
trackOriginHead?: boolean;
};
export interface CreateWorktreeOptions {
@@ -1201,6 +1202,13 @@ export const createWorktree = async ({
remote: sourcePlan.pushRemote,
});
}
if (sourcePlan.trackingRemote) {
await configureWorktreeTrackingRemote({
cwd,
branchName: sourcePlan.branchName,
remote: sourcePlan.trackingRemote,
});
}
writePaseoWorktreeMetadata(worktreePath, { baseRefName: sourcePlan.metadataBaseRefName });
@@ -1245,6 +1253,11 @@ interface WorktreeSourcePlan {
name: string;
url: string;
headRef: string;
track: boolean;
};
trackingRemote?: {
name: string;
headRef: string;
};
}
@@ -1309,20 +1322,42 @@ async function resolveWorktreeSourcePlan({
timeout: 120_000,
},
);
const trackingRemote = source.trackOriginHead
? await tryFetchWorktreeTrackingRemote({
cwd,
remoteName: "origin",
headRef: source.headRef,
})
: undefined;
const remotePlan: Pick<WorktreeSourcePlan, "pushRemote" | "trackingRemote"> = {};
if (source.pushRemoteUrl) {
const remoteName = `paseo-pr-${source.githubPrNumber}`;
remotePlan.pushRemote = {
name: remoteName,
url: source.pushRemoteUrl,
headRef: source.headRef,
track: true,
};
} else if (source.trackOriginHead && localBranchName !== source.headRef) {
const originUrl = await getWorktreeRemotePushUrl(cwd, "origin");
if (originUrl) {
remotePlan.pushRemote = {
name: `paseo-pr-${source.githubPrNumber}`,
url: originUrl,
headRef: source.headRef,
track: false,
};
}
}
if (trackingRemote) {
remotePlan.trackingRemote = trackingRemote;
}
return {
branchName: localBranchName,
metadataBaseRefName: normalizedBaseRefName,
addArguments: [localBranchName],
...(source.pushRemoteUrl
? {
pushRemote: {
name: `paseo-pr-${source.githubPrNumber}`,
url: source.pushRemoteUrl,
headRef: source.headRef,
},
}
: {}),
...remotePlan,
};
}
}
@@ -1335,6 +1370,7 @@ async function configureWorktreePushRemote(options: {
name: string;
url: string;
headRef: string;
track: boolean;
};
}): Promise<void> {
await runGitCommand(["config", `remote.${options.remote.name}.url`, options.remote.url], {
@@ -1344,11 +1380,84 @@ async function configureWorktreePushRemote(options: {
["config", `remote.${options.remote.name}.push`, `HEAD:refs/heads/${options.remote.headRef}`],
{ cwd: options.cwd },
);
await runGitCommand(["config", `branch.${options.branchName}.remote`, options.remote.name], {
await runGitCommand(["config", `branch.${options.branchName}.pushRemote`, options.remote.name], {
cwd: options.cwd,
});
if (!options.remote.track) {
return;
}
await runGitCommand(
["config", `branch.${options.branchName}.merge`, `refs/heads/${options.remote.headRef}`],
[
"config",
`remote.${options.remote.name}.fetch`,
`+refs/heads/${options.remote.headRef}:refs/remotes/${options.remote.name}/${options.remote.headRef}`,
],
{ cwd: options.cwd },
);
const trackingRemote = await tryFetchWorktreeTrackingRemote({
cwd: options.cwd,
remoteName: options.remote.name,
headRef: options.remote.headRef,
});
if (trackingRemote) {
await configureWorktreeTrackingRemote({
cwd: options.cwd,
branchName: options.branchName,
remote: trackingRemote,
});
}
}
async function tryFetchWorktreeTrackingRemote(options: {
cwd: string;
remoteName: string;
headRef: string;
}): Promise<{ name: string; headRef: string } | undefined> {
const result = await runGitCommand(
[
"fetch",
options.remoteName,
`+refs/heads/${options.headRef}:refs/remotes/${options.remoteName}/${options.headRef}`,
],
{
cwd: options.cwd,
timeout: 120_000,
acceptExitCodes: [0, 1, 128],
},
);
return result.exitCode === 0 ? { name: options.remoteName, headRef: options.headRef } : undefined;
}
async function getWorktreeRemotePushUrl(
cwd: string,
remoteName: string,
): Promise<string | undefined> {
try {
const { stdout } = await runGitCommand(["remote", "get-url", "--push", remoteName], {
cwd,
});
const url = stdout.trim();
return url.length > 0 ? url : undefined;
} catch {
return undefined;
}
}
async function configureWorktreeTrackingRemote(options: {
cwd: string;
branchName: string;
remote: {
name: string;
headRef: string;
};
}): Promise<void> {
await runGitCommand(
[
"branch",
"--set-upstream-to",
`${options.remote.name}/${options.remote.headRef}`,
options.branchName,
],
{ cwd: options.cwd },
);
}