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 }]);
|
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 () => {
|
test("uses an explicit PR head ref without calling GitHub", async () => {
|
||||||
const deps = createResolverHarness();
|
const deps = createResolverHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -116,9 +116,10 @@ async function resolveGitHubPrCheckoutIntent(params: {
|
|||||||
checkoutTarget?.baseRefName?.trim() ||
|
checkoutTarget?.baseRefName?.trim() ||
|
||||||
(await resolveDefaultBranch(params.repoRoot, params.deps));
|
(await resolveDefaultBranch(params.repoRoot, params.deps));
|
||||||
const localBranchName = buildGitHubPrLocalBranchName({ headRef, checkoutTarget });
|
const localBranchName = buildGitHubPrLocalBranchName({ headRef, checkoutTarget });
|
||||||
const pushRemoteUrl = checkoutTarget
|
const pushRemoteUrl = checkoutTarget?.isCrossRepository
|
||||||
? checkoutTarget.headRepositorySshUrl || checkoutTarget.headRepositoryUrl || undefined
|
? checkoutTarget.headRepositorySshUrl || checkoutTarget.headRepositoryUrl || undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const trackOriginHead = checkoutTarget ? !checkoutTarget.isCrossRepository : false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: "checkout-github-pr",
|
kind: "checkout-github-pr",
|
||||||
@@ -127,6 +128,7 @@ async function resolveGitHubPrCheckoutIntent(params: {
|
|||||||
baseRefName,
|
baseRefName,
|
||||||
...(localBranchName !== headRef ? { localBranchName } : {}),
|
...(localBranchName !== headRef ? { localBranchName } : {}),
|
||||||
...(pushRemoteUrl ? { pushRemoteUrl } : {}),
|
...(pushRemoteUrl ? { pushRemoteUrl } : {}),
|
||||||
|
...(trackOriginHead ? { trackOriginHead } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { tmpdir } from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, test, afterEach } from "vitest";
|
import { describe, expect, test, afterEach } from "vitest";
|
||||||
import type { GitHubService } from "../services/github-service.js";
|
import type { GitHubService } from "../services/github-service.js";
|
||||||
|
import { getCheckoutStatus, pushCurrentBranch } from "../utils/checkout-git.js";
|
||||||
import { UnknownBranchError } from "../utils/worktree.js";
|
import { UnknownBranchError } from "../utils/worktree.js";
|
||||||
import { createWorktreeCore as createCoreWorktree } from "./worktree-core.js";
|
import { createWorktreeCore as createCoreWorktree } from "./worktree-core.js";
|
||||||
import { isPlatform } from "../test-utils/platform.js";
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
@@ -123,6 +124,44 @@ function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string; paseoHo
|
|||||||
return { tempDir, repoDir, paseoHome };
|
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(): {
|
function createForkGitHubPrRemoteRepo(): {
|
||||||
tempDir: string;
|
tempDir: string;
|
||||||
repoDir: string;
|
repoDir: string;
|
||||||
@@ -411,6 +450,201 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
|||||||
expect(result.worktree.branchName).toBe("pr-123");
|
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 () => {
|
test("checks out a fork PR whose head branch collides with local main", async () => {
|
||||||
const { tempDir, repoDir, headRemoteDir, paseoHome } = createForkGitHubPrRemoteRepo();
|
const { tempDir, repoDir, headRemoteDir, paseoHome } = createForkGitHubPrRemoteRepo();
|
||||||
cleanupPaths.push(tempDir);
|
cleanupPaths.push(tempDir);
|
||||||
@@ -452,6 +686,13 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
|||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
const readme = readFileSync(path.join(result.worktree.worktreePath, "README.md"), "utf8");
|
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");
|
writeFileSync(path.join(result.worktree.worktreePath, "FOLLOWUP.md"), "maintainer edit\n");
|
||||||
execFileSync("git", ["add", "FOLLOWUP.md"], {
|
execFileSync("git", ["add", "FOLLOWUP.md"], {
|
||||||
cwd: result.worktree.worktreePath,
|
cwd: result.worktree.worktreePath,
|
||||||
@@ -461,11 +702,23 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
|||||||
cwd: result.worktree.worktreePath,
|
cwd: result.worktree.worktreePath,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
const pushDryRunResult = spawnSync("git", ["push", "--dry-run"], {
|
const localHead = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||||
cwd: result.worktree.worktreePath,
|
cwd: result.worktree.worktreePath,
|
||||||
encoding: "utf8",
|
stdio: "pipe",
|
||||||
});
|
})
|
||||||
const pushDryRun = `${pushDryRunResult.stdout}${pushDryRunResult.stderr}`;
|
.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(sourceBranch).toBe("main");
|
||||||
expect(result.intent).toEqual({
|
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(path.basename(result.worktree.worktreePath)).toBe("therainisme-main");
|
||||||
expect(worktreeBranch).toBe("therainisme/main");
|
expect(worktreeBranch).toBe("therainisme/main");
|
||||||
expect(readme.replace(/\r\n/g, "\n")).toBe("fork pr main branch\n");
|
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 () => {
|
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(first.worktree.branchName).toBe("therainisme/main");
|
||||||
expect(second.worktree.branchName).toBe("therainisme/main-1");
|
expect(second.worktree.branchName).toBe("therainisme/main-1");
|
||||||
expect(
|
expect(getBranchUpstream(first.worktree.worktreePath)).toBe("paseo-pr-526/main");
|
||||||
execFileSync("git", ["config", "--get", "remote.paseo-pr-526.push"], {
|
expect(getBranchUpstream(second.worktree.worktreePath)).toBe("paseo-pr-526/main");
|
||||||
cwd: second.worktree.worktreePath,
|
|
||||||
stdio: "pipe",
|
|
||||||
})
|
|
||||||
.toString()
|
|
||||||
.trim(),
|
|
||||||
).toBe("HEAD:refs/heads/main");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("throws a typed error for an unknown checkout branch", async () => {
|
test("throws a typed error for an unknown checkout branch", async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { execFileSync, execSync } from "child_process";
|
import { execFileSync, execSync, spawnSync } from "child_process";
|
||||||
import {
|
import {
|
||||||
existsSync,
|
existsSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
@@ -187,6 +187,14 @@ async function readPullRequestLookupTargetFromFacts(
|
|||||||
return facts.pullRequestLookupTarget;
|
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(
|
function setupRemoteTrackingMain(
|
||||||
repoDir: string,
|
repoDir: string,
|
||||||
tempDir: string,
|
tempDir: string,
|
||||||
@@ -1700,6 +1708,183 @@ const x = 1;
|
|||||||
expect(upstream).toBe("origin/feature");
|
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 () => {
|
it("lists merged local and remote branch suggestions with provenance", async () => {
|
||||||
const remoteDir = join(tempDir, "remote.git");
|
const remoteDir = join(tempDir, "remote.git");
|
||||||
execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]);
|
execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]);
|
||||||
@@ -2110,6 +2295,36 @@ const x = 1;
|
|||||||
expect(lookupTarget).toEqual({ headRef: "main", headRepositoryOwner: "chethanuk" });
|
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 () => {
|
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", ["checkout", "-b", "tender-parrot"], { cwd: repoDir });
|
||||||
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
|
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ interface PullRequestLookupTargetBranchConfig {
|
|||||||
resolvedBaseRef: string | null;
|
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 {
|
function getErrorStderr(error: Error): string {
|
||||||
return "stderr" in error && typeof error.stderr === "string" ? error.stderr : "";
|
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 {
|
function parseBranchMergeHeadRef(mergeRef: string | null): string | null {
|
||||||
const prefix = "refs/heads/";
|
const prefix = "refs/heads/";
|
||||||
if (!mergeRef?.startsWith(prefix)) {
|
if (!mergeRef?.startsWith(prefix)) {
|
||||||
@@ -1156,7 +1183,14 @@ async function resolvePullRequestStatusLookupTarget(
|
|||||||
resolvedBaseRef: null,
|
resolvedBaseRef: null,
|
||||||
});
|
});
|
||||||
if (localBranchTarget.headRef === currentBranch) {
|
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([
|
const [branchRemoteUrl, originRemoteUrl, resolvedBaseRef] = await Promise.all([
|
||||||
@@ -1164,7 +1198,7 @@ async function resolvePullRequestStatusLookupTarget(
|
|||||||
getGitConfigValue(cwd, "remote.origin.url", context),
|
getGitConfigValue(cwd, "remote.origin.url", context),
|
||||||
getResolvedBaseRefForCwd(cwd, context),
|
getResolvedBaseRefForCwd(cwd, context),
|
||||||
]);
|
]);
|
||||||
return buildPullRequestLookupTargetFromBranchConfig({
|
const branchTarget = buildPullRequestLookupTargetFromBranchConfig({
|
||||||
currentBranch,
|
currentBranch,
|
||||||
branchRemoteName,
|
branchRemoteName,
|
||||||
branchMergeRef,
|
branchMergeRef,
|
||||||
@@ -1172,6 +1206,17 @@ async function resolvePullRequestStatusLookupTarget(
|
|||||||
originRemoteUrl,
|
originRemoteUrl,
|
||||||
resolvedBaseRef,
|
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> {
|
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(
|
export async function getCheckoutSnapshotFacts(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
@@ -1602,7 +1706,7 @@ export async function getCheckoutSnapshotFacts(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const pullRequestLookupTarget = inspected.currentBranch
|
let pullRequestLookupTarget = inspected.currentBranch
|
||||||
? buildPullRequestLookupTargetFromBranchConfig({
|
? buildPullRequestLookupTargetFromBranchConfig({
|
||||||
currentBranch: inspected.currentBranch,
|
currentBranch: inspected.currentBranch,
|
||||||
branchRemoteName,
|
branchRemoteName,
|
||||||
@@ -1612,6 +1716,20 @@ export async function getCheckoutSnapshotFacts(
|
|||||||
resolvedBaseRef,
|
resolvedBaseRef,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
if (
|
||||||
|
inspected.currentBranch &&
|
||||||
|
pullRequestLookupTarget?.headRef === inspected.currentBranch &&
|
||||||
|
!pullRequestLookupTarget.headRepositoryOwner
|
||||||
|
) {
|
||||||
|
pullRequestLookupTarget =
|
||||||
|
(await resolvePullRequestLookupTargetFromPushConfig(
|
||||||
|
cwd,
|
||||||
|
inspected.currentBranch,
|
||||||
|
inspected.remoteUrl,
|
||||||
|
resolvedBaseRef,
|
||||||
|
context,
|
||||||
|
)) ?? pullRequestLookupTarget;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isGit: true,
|
isGit: true,
|
||||||
@@ -2689,6 +2807,27 @@ export async function pushCurrentBranch(cwd: string, github?: GitHubService): Pr
|
|||||||
if (!currentBranch || currentBranch === "HEAD") {
|
if (!currentBranch || currentBranch === "HEAD") {
|
||||||
throw new Error("Unable to determine current branch for push");
|
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);
|
const hasRemote = await hasOriginRemote(cwd);
|
||||||
if (!hasRemote) {
|
if (!hasRemote) {
|
||||||
throw new Error("Remote 'origin' is not configured.");
|
throw new Error("Remote 'origin' is not configured.");
|
||||||
@@ -2697,6 +2836,106 @@ export async function pushCurrentBranch(cwd: string, github?: GitHubService): Pr
|
|||||||
github?.invalidate({ cwd });
|
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 {
|
export interface CreatePullRequestOptions {
|
||||||
title: string;
|
title: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ export type WorktreeSource =
|
|||||||
baseRefName: string;
|
baseRefName: string;
|
||||||
localBranchName?: string;
|
localBranchName?: string;
|
||||||
pushRemoteUrl?: string;
|
pushRemoteUrl?: string;
|
||||||
|
trackOriginHead?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface CreateWorktreeOptions {
|
export interface CreateWorktreeOptions {
|
||||||
@@ -1201,6 +1202,13 @@ export const createWorktree = async ({
|
|||||||
remote: sourcePlan.pushRemote,
|
remote: sourcePlan.pushRemote,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (sourcePlan.trackingRemote) {
|
||||||
|
await configureWorktreeTrackingRemote({
|
||||||
|
cwd,
|
||||||
|
branchName: sourcePlan.branchName,
|
||||||
|
remote: sourcePlan.trackingRemote,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
writePaseoWorktreeMetadata(worktreePath, { baseRefName: sourcePlan.metadataBaseRefName });
|
writePaseoWorktreeMetadata(worktreePath, { baseRefName: sourcePlan.metadataBaseRefName });
|
||||||
|
|
||||||
@@ -1245,6 +1253,11 @@ interface WorktreeSourcePlan {
|
|||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
headRef: string;
|
headRef: string;
|
||||||
|
track: boolean;
|
||||||
|
};
|
||||||
|
trackingRemote?: {
|
||||||
|
name: string;
|
||||||
|
headRef: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1309,20 +1322,42 @@ async function resolveWorktreeSourcePlan({
|
|||||||
timeout: 120_000,
|
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 {
|
return {
|
||||||
branchName: localBranchName,
|
branchName: localBranchName,
|
||||||
metadataBaseRefName: normalizedBaseRefName,
|
metadataBaseRefName: normalizedBaseRefName,
|
||||||
addArguments: [localBranchName],
|
addArguments: [localBranchName],
|
||||||
...(source.pushRemoteUrl
|
...remotePlan,
|
||||||
? {
|
|
||||||
pushRemote: {
|
|
||||||
name: `paseo-pr-${source.githubPrNumber}`,
|
|
||||||
url: source.pushRemoteUrl,
|
|
||||||
headRef: source.headRef,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1335,6 +1370,7 @@ async function configureWorktreePushRemote(options: {
|
|||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
headRef: string;
|
headRef: string;
|
||||||
|
track: boolean;
|
||||||
};
|
};
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await runGitCommand(["config", `remote.${options.remote.name}.url`, options.remote.url], {
|
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}`],
|
["config", `remote.${options.remote.name}.push`, `HEAD:refs/heads/${options.remote.headRef}`],
|
||||||
{ cwd: options.cwd },
|
{ 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,
|
cwd: options.cwd,
|
||||||
});
|
});
|
||||||
|
if (!options.remote.track) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await runGitCommand(
|
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 },
|
{ cwd: options.cwd },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user