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:
@@ -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 () => {
|
||||
|
||||
@@ -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"], {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user