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:
@@ -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