Show the current pull request after switching branches (#2699)
* fix(git): reconcile pull requests after branch changes Treat persisted change-request metadata as a branch-bound hint and prefer current tracking configuration when it identifies the checked-out branch. * fix(git): preserve enterprise fork identity Use branch-bound metadata to supplement the owner when explicit tracking points at the same head on a self-hosted remote. * fix(git): honor local branch upstreams Treat Git remote dot with a merge ref as explicit current tracking so stale PR metadata cannot override a local upstream. * fix(git): own change request branch bindings Centralize persisted hint validation and rebinding so renames preserve change request identity while switches invalidate it, including normalized legacy Enterprise owners. * fix(git): preserve PR identity through workspace renames Resolve the backing worktree root during rename without adding a Git call, and retain fork owner identity from explicit same-name tracking configuration. * fix(git): trust current enterprise fork identity Explicit tracking now derives repository identity from the current remote path, so stale checkout metadata cannot override a repointed fork. * fix(git): recognize enterprise repository aliases GitHub repository identity is case-insensitive. Normalize equality checks so differently cased aliases cannot turn base tracking into a fork lookup. * fix(git): reject ownerless legacy fork matches Owner-qualified legacy hints only belong to their historical owner-prefixed branch names. A later ownerless branch reusing the same head must resolve from current Git state instead. * fix(git): keep fork feature lookups local A feature branch may track the upstream base branch for ancestry even when origin is a fork. Only treat that cross-repository base ref as PR identity when the local branch name identifies the tracked head.
This commit is contained in:
@@ -89,7 +89,11 @@ function createLegacyWorktreeForTest(
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
}
|
||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||
import {
|
||||
getPaseoWorktreeMetadataPath,
|
||||
readPaseoWorktreeMetadata,
|
||||
writePaseoWorktreeMetadata,
|
||||
} from "./worktree-metadata.js";
|
||||
|
||||
function initRepo(): { tempDir: string; repoDir: string } {
|
||||
const tempDir = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-git-test-")));
|
||||
@@ -2480,6 +2484,287 @@ const x = 1;
|
||||
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
|
||||
});
|
||||
|
||||
it("reconciles a PR worktree lookup from current branch tracking", async () => {
|
||||
execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/repo.git"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
execFileSync("git", ["branch", "contributor/old-change"], { cwd: repoDir });
|
||||
execFileSync("git", ["branch", "new-change"], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "branch.new-change.remote", "origin"], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "branch.new-change.merge", "refs/heads/new-change"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "pr-worktree");
|
||||
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
const staleLookupTarget = {
|
||||
headRef: "old-change",
|
||||
headRepositoryOwner: "contributor",
|
||||
changeRequestNumber: 41,
|
||||
};
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
...staleLookupTarget,
|
||||
localBranchName: "contributor/old-change",
|
||||
},
|
||||
});
|
||||
|
||||
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
|
||||
headRef: "old-change",
|
||||
headRepositoryOwner: "contributor",
|
||||
});
|
||||
|
||||
execFileSync("git", ["checkout", "new-change"], { cwd: workspaceDir });
|
||||
startGitCommandMetrics();
|
||||
const switchedTarget = await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome);
|
||||
const commands = stopGitCommandMetrics().commands.map((command) => command.args[0]);
|
||||
|
||||
expect(switchedTarget).toMatchObject({ headRef: "new-change" });
|
||||
expect(switchedTarget).not.toHaveProperty("headRepositoryOwner");
|
||||
expect(commands).not.toContain("fetch");
|
||||
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
...staleLookupTarget,
|
||||
localBranchName: "new-change",
|
||||
},
|
||||
});
|
||||
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
|
||||
headRef: "new-change",
|
||||
});
|
||||
|
||||
execFileSync(
|
||||
"git",
|
||||
["remote", "add", "enterprise-fork", "git@github.acme.internal:contributor/repo.git"],
|
||||
{
|
||||
cwd: repoDir,
|
||||
},
|
||||
);
|
||||
execFileSync("git", ["config", "branch.new-change.remote", "enterprise-fork"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
execFileSync("git", ["config", "branch.new-change.merge", "refs/heads/old-change"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
|
||||
headRef: "old-change",
|
||||
headRepositoryOwner: "contributor",
|
||||
});
|
||||
|
||||
execFileSync("git", ["branch", "local-upstream"], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "branch.new-change.remote", "."], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "branch.new-change.merge", "refs/heads/local-upstream"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
|
||||
headRef: "local-upstream",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not apply ambiguous legacy PR metadata to a suffixed branch", async () => {
|
||||
execFileSync("git", ["branch", "contributor/old-change-1"], { cwd: repoDir });
|
||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "legacy-pr-worktree");
|
||||
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change-1"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "old-change",
|
||||
headRepositoryOwner: "contributor",
|
||||
changeRequestNumber: 41,
|
||||
},
|
||||
});
|
||||
|
||||
const lookupTarget = await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome);
|
||||
|
||||
expect(lookupTarget).toMatchObject({ headRef: "contributor/old-change-1" });
|
||||
expect(lookupTarget).not.toHaveProperty("headRepositoryOwner");
|
||||
});
|
||||
|
||||
it("does not apply a legacy fork hint to an ownerless branch with the same head", async () => {
|
||||
execFileSync("git", ["branch", "contributor/old-change"], { cwd: repoDir });
|
||||
execFileSync("git", ["branch", "old-change"], { cwd: repoDir });
|
||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "legacy-fork-worktree");
|
||||
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "old-change",
|
||||
headRepositoryOwner: "contributor",
|
||||
changeRequestNumber: 41,
|
||||
},
|
||||
});
|
||||
const requestedTargets: RequestedPullRequestTarget[] = [];
|
||||
const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets });
|
||||
|
||||
const forkFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
||||
await getPullRequestStatus(
|
||||
workspaceDir,
|
||||
forge,
|
||||
{ force: true, reason: "legacy-fork-branch" },
|
||||
{ paseoHome, facts: forkFacts },
|
||||
);
|
||||
expect(requestedTargets.at(-1)).toMatchObject({
|
||||
headRef: "old-change",
|
||||
headRepositoryOwner: "contributor",
|
||||
});
|
||||
|
||||
execFileSync("git", ["checkout", "old-change"], { cwd: workspaceDir });
|
||||
const ownerlessFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
||||
await getPullRequestStatus(
|
||||
workspaceDir,
|
||||
forge,
|
||||
{ force: true, reason: "ownerless-same-head" },
|
||||
{ paseoHome, facts: ownerlessFacts },
|
||||
);
|
||||
|
||||
expect(requestedTargets.at(-1)).toMatchObject({ headRef: "old-change" });
|
||||
expect(requestedTargets.at(-1)).not.toHaveProperty("headRepositoryOwner");
|
||||
});
|
||||
|
||||
it("recognizes a normalized GitHub owner branch from legacy Enterprise metadata", async () => {
|
||||
execFileSync("git", ["remote", "add", "origin", "git@github.acme.internal:base/repo.git"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
execFileSync(
|
||||
"git",
|
||||
["remote", "add", "enterprise-fork", "git@github.acme.internal:MixedOwner/repo.git"],
|
||||
{
|
||||
cwd: repoDir,
|
||||
},
|
||||
);
|
||||
execFileSync("git", ["branch", "mixedowner/old-change"], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "branch.mixedowner/old-change.remote", "enterprise-fork"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
execFileSync("git", ["config", "branch.mixedowner/old-change.merge", "refs/heads/old-change"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "legacy-enterprise-worktree");
|
||||
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "mixedowner/old-change"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "old-change",
|
||||
headRepositoryOwner: "MixedOwner",
|
||||
changeRequestNumber: 41,
|
||||
},
|
||||
});
|
||||
const requestedTargets: RequestedPullRequestTarget[] = [];
|
||||
const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets });
|
||||
|
||||
const facts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
||||
await getPullRequestStatus(
|
||||
workspaceDir,
|
||||
forge,
|
||||
{ force: true, reason: "legacy-enterprise-owner" },
|
||||
{ paseoHome, facts },
|
||||
);
|
||||
|
||||
expect(requestedTargets).toEqual([
|
||||
expect.objectContaining({ headRef: "old-change", headRepositoryOwner: "MixedOwner" }),
|
||||
]);
|
||||
|
||||
execFileSync(
|
||||
"git",
|
||||
["remote", "add", "replacement-fork", "git@github.acme.internal:OtherOwner/repo.git"],
|
||||
{ cwd: repoDir },
|
||||
);
|
||||
execFileSync("git", ["config", "branch.mixedowner/old-change.remote", "replacement-fork"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
const repointedFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
||||
await getPullRequestStatus(
|
||||
workspaceDir,
|
||||
forge,
|
||||
{ force: true, reason: "repointed-enterprise-owner" },
|
||||
{ paseoHome, facts: repointedFacts },
|
||||
);
|
||||
|
||||
expect(requestedTargets.at(-1)).toMatchObject({
|
||||
headRef: "old-change",
|
||||
headRepositoryOwner: "OtherOwner",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a ref-only change request bound across rename but not branch switch", async () => {
|
||||
execFileSync("git", ["branch", "feature/gitlab-mr"], { cwd: repoDir });
|
||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "gitlab-mr-worktree");
|
||||
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "feature/gitlab-mr"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "feature/gitlab-mr",
|
||||
changeRequestNumber: 14,
|
||||
localBranchName: "feature/gitlab-mr",
|
||||
},
|
||||
});
|
||||
const workspaceCwd = join(workspaceDir, "packages", "service");
|
||||
mkdirSync(workspaceCwd, { recursive: true });
|
||||
const requestedTargets: RequestedPullRequestTarget[] = [];
|
||||
const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets });
|
||||
|
||||
await renameCurrentBranch(workspaceCwd, "feature/renamed");
|
||||
expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toMatchObject({
|
||||
localBranchName: "feature/renamed",
|
||||
});
|
||||
const renamedFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
||||
const renamedStatus = await getPullRequestStatus(
|
||||
workspaceDir,
|
||||
forge,
|
||||
{ force: true, reason: "renamed-change-request" },
|
||||
{ paseoHome, facts: renamedFacts },
|
||||
);
|
||||
|
||||
expect(requestedTargets).toEqual([expect.objectContaining({ headRef: "feature/gitlab-mr" })]);
|
||||
expect(renamedStatus.status?.headRefName).toBe("feature/gitlab-mr");
|
||||
|
||||
execFileSync("git", ["checkout", "-b", "other-branch"], { cwd: workspaceDir });
|
||||
const switchedFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
||||
await getPullRequestStatus(
|
||||
workspaceDir,
|
||||
forge,
|
||||
{ force: true, reason: "switched-after-rename" },
|
||||
{ paseoHome, facts: switchedFacts },
|
||||
);
|
||||
|
||||
expect(requestedTargets.at(-1)).toMatchObject({ headRef: "other-branch" });
|
||||
});
|
||||
|
||||
it("keeps fork identity when the local and tracked branch names match", async () => {
|
||||
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
execFileSync("git", ["remote", "add", "contributor", "git@github.com:contributor/paseo.git"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
execFileSync("git", ["checkout", "-b", "topic"], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "branch.topic.remote", "contributor"], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "branch.topic.merge", "refs/heads/topic"], { cwd: repoDir });
|
||||
|
||||
const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome);
|
||||
|
||||
expect(lookupTarget).toMatchObject({
|
||||
headRef: "topic",
|
||||
headRepositoryOwner: "contributor",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not attach an owner when the tracked remote is the same GitHub repository", async () => {
|
||||
execFileSync("git", ["checkout", "-b", "local-feature"], { cwd: repoDir });
|
||||
execFileSync("git", ["remote", "add", "origin", "git@github.com:getpaseo/paseo.git"], {
|
||||
@@ -2571,6 +2856,50 @@ const x = 1;
|
||||
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
|
||||
});
|
||||
|
||||
it("keeps the local branch lookup when a fork tracks the upstream base branch", async () => {
|
||||
execFileSync("git", ["checkout", "-b", "local-feature"], { cwd: repoDir });
|
||||
execFileSync("git", ["remote", "add", "origin", "git@github.com:contributor/paseo.git"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
execFileSync("git", ["remote", "add", "upstream", "https://github.com/getpaseo/paseo.git"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
execFileSync("git", ["config", "branch.local-feature.remote", "upstream"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
execFileSync("git", ["config", "branch.local-feature.merge", "refs/heads/main"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome);
|
||||
|
||||
expect(lookupTarget).toMatchObject({ headRef: "local-feature" });
|
||||
expect(lookupTarget).not.toHaveProperty("headRepositoryOwner");
|
||||
});
|
||||
|
||||
it("treats differently cased Enterprise remotes as the same repository", async () => {
|
||||
execFileSync("git", ["checkout", "-b", "local-feature"], { cwd: repoDir });
|
||||
execFileSync("git", ["remote", "add", "origin", "git@github.acme.internal:Acme/Repo.git"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
execFileSync(
|
||||
"git",
|
||||
["remote", "add", "enterprise-alias", "git@github.acme.internal:acme/repo.git"],
|
||||
{ cwd: repoDir },
|
||||
);
|
||||
execFileSync("git", ["config", "branch.local-feature.remote", "enterprise-alias"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
execFileSync("git", ["config", "branch.local-feature.merge", "refs/heads/main"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome);
|
||||
|
||||
expect(lookupTarget).toMatchObject({ headRef: "local-feature" });
|
||||
expect(lookupTarget).not.toHaveProperty("headRepositoryOwner");
|
||||
});
|
||||
|
||||
it("derives the same origin tracked head for on-demand PR status reads", async () => {
|
||||
execFileSync("git", ["checkout", "-b", "tender-parrot"], { cwd: repoDir });
|
||||
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
|
||||
|
||||
Reference in New Issue
Block a user