From f31bc2b8b7c8e3d5565be7ff9ae4e36be376025b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 29 Jun 2026 20:21:19 +0200 Subject: [PATCH] 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 --- .../resolve-worktree-creation-intent.test.ts | 49 +++ .../resolve-worktree-creation-intent.ts | 4 +- .../src/server/worktree-core.posix.test.ts | 381 +++++++++++++++++- .../server/src/utils/checkout-git.test.ts | 217 +++++++++- packages/server/src/utils/checkout-git.ts | 245 ++++++++++- packages/server/src/utils/worktree.ts | 131 +++++- 6 files changed, 998 insertions(+), 29 deletions(-) diff --git a/packages/server/src/server/resolve-worktree-creation-intent.test.ts b/packages/server/src/server/resolve-worktree-creation-intent.test.ts index 7381f7658..7998b3815 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.test.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.test.ts @@ -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(); diff --git a/packages/server/src/server/resolve-worktree-creation-intent.ts b/packages/server/src/server/resolve-worktree-creation-intent.ts index 5b0adba5d..8378496f3 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.ts @@ -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 } : {}), }; } diff --git a/packages/server/src/server/worktree-core.posix.test.ts b/packages/server/src/server/worktree-core.posix.test.ts index 58778b3f6..f5e105502 100644 --- a/packages/server/src/server/worktree-core.posix.test.ts +++ b/packages/server/src/server/worktree-core.posix.test.ts @@ -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 () => { diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 2a28cdeaa..fde7ad0d8 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -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"], { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 61dab9c79..208aef3a7 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -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 { + 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 { @@ -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 { + 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 { + 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 { + 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; diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 8c3a62c2c..dde89cad0 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -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 = {}; + 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 { 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 { + 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 { + await runGitCommand( + [ + "branch", + "--set-upstream-to", + `${options.remote.name}/${options.remote.headRef}`, + options.branchName, + ], { cwd: options.cwd }, ); }