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:
@@ -576,7 +576,6 @@ export class CheckoutSession {
|
|||||||
// Branch is a git fact derived per-descriptor from each workspace's own
|
// Branch is a git fact derived per-descriptor from each workspace's own
|
||||||
// live git snapshot (id → cwd); the reconciliation pass re-persists the
|
// live git snapshot (id → cwd); the reconciliation pass re-persists the
|
||||||
// `branch` field per workspace from its own cwd. No cwd → ids fan-out here.
|
// `branch` field per workspace from its own cwd. No cwd → ids fan-out here.
|
||||||
// TODO(K10): PR-binding on branch rename is deferred — see plan K10.
|
|
||||||
|
|
||||||
// Push a workspace_update immediately so the sidebar/header reflect
|
// Push a workspace_update immediately so the sidebar/header reflect
|
||||||
// the new branch name without waiting for the background git watcher.
|
// the new branch name without waiting for the background git watcher.
|
||||||
|
|||||||
@@ -879,6 +879,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
|||||||
expect(metadata?.changeRequestLookupTarget).toEqual({
|
expect(metadata?.changeRequestLookupTarget).toEqual({
|
||||||
headRef: "feature/gitlab-mr",
|
headRef: "feature/gitlab-mr",
|
||||||
changeRequestNumber: 14,
|
changeRequestNumber: 14,
|
||||||
|
localBranchName: "feature/gitlab-mr-1",
|
||||||
});
|
});
|
||||||
expect(facts).toMatchObject({
|
expect(facts).toMatchObject({
|
||||||
isGit: true,
|
isGit: true,
|
||||||
|
|||||||
@@ -89,7 +89,11 @@ function createLegacyWorktreeForTest(
|
|||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
import {
|
||||||
|
getPaseoWorktreeMetadataPath,
|
||||||
|
readPaseoWorktreeMetadata,
|
||||||
|
writePaseoWorktreeMetadata,
|
||||||
|
} from "./worktree-metadata.js";
|
||||||
|
|
||||||
function initRepo(): { tempDir: string; repoDir: string } {
|
function initRepo(): { tempDir: string; repoDir: string } {
|
||||||
const tempDir = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-git-test-")));
|
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}$/);
|
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 () => {
|
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", ["checkout", "-b", "local-feature"], { cwd: repoDir });
|
||||||
execFileSync("git", ["remote", "add", "origin", "git@github.com:getpaseo/paseo.git"], {
|
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}$/);
|
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 () => {
|
it("derives the same origin tracked head for on-demand PR status reads", 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"], {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { existsSync, realpathSync } from "fs";
|
|||||||
import { open as openFile, readFile, stat as statFile } from "fs/promises";
|
import { open as openFile, readFile, stat as statFile } from "fs/promises";
|
||||||
import { TTLCache } from "@isaacs/ttlcache";
|
import { TTLCache } from "@isaacs/ttlcache";
|
||||||
import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages";
|
import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages";
|
||||||
|
import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||||
import { maxBase64EncryptedPlaintextByteLength } from "@getpaseo/relay";
|
import { maxBase64EncryptedPlaintextByteLength } from "@getpaseo/relay";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||||
@@ -28,7 +29,12 @@ import {
|
|||||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||||
import { runGitCommand } from "./run-git-command.js";
|
import { runGitCommand } from "./run-git-command.js";
|
||||||
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
|
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
|
||||||
import { type PaseoWorktreeMetadata, readPaseoWorktreeMetadata } from "./worktree-metadata.js";
|
import {
|
||||||
|
getPaseoWorktreeChangeRequestHintForBranch,
|
||||||
|
type PaseoWorktreeMetadata,
|
||||||
|
readPaseoWorktreeMetadata,
|
||||||
|
rebindPaseoWorktreeChangeRequestHint,
|
||||||
|
} from "./worktree-metadata.js";
|
||||||
const READ_ONLY_GIT_ENV = {
|
const READ_ONLY_GIT_ENV = {
|
||||||
GIT_OPTIONAL_LOCKS: "0",
|
GIT_OPTIONAL_LOCKS: "0",
|
||||||
LC_ALL: "C",
|
LC_ALL: "C",
|
||||||
@@ -827,6 +833,22 @@ async function requireGitRepo(cwd: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requireGitWorktreeRoot(cwd: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
|
||||||
|
cwd,
|
||||||
|
envOverlay: READ_ONLY_GIT_ENV,
|
||||||
|
});
|
||||||
|
const worktreeRoot = parseGitRevParsePath(stdout);
|
||||||
|
if (!worktreeRoot) {
|
||||||
|
throw new Error("Git returned no worktree root");
|
||||||
|
}
|
||||||
|
return worktreeRoot;
|
||||||
|
} catch {
|
||||||
|
throw new NotGitRepoError(cwd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCurrentBranch(cwd: string): Promise<string | null> {
|
export async function getCurrentBranch(cwd: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
|
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||||
@@ -1037,7 +1059,7 @@ export async function renameCurrentBranch(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
newName: string,
|
newName: string,
|
||||||
): Promise<{ previousBranch: string | null; currentBranch: string | null }> {
|
): Promise<{ previousBranch: string | null; currentBranch: string | null }> {
|
||||||
await requireGitRepo(cwd);
|
const worktreeRoot = await requireGitWorktreeRoot(cwd);
|
||||||
|
|
||||||
const previousBranch = await getCurrentBranch(cwd);
|
const previousBranch = await getCurrentBranch(cwd);
|
||||||
if (!previousBranch || previousBranch === "HEAD") {
|
if (!previousBranch || previousBranch === "HEAD") {
|
||||||
@@ -1050,6 +1072,9 @@ export async function renameCurrentBranch(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const currentBranch = await getCurrentBranch(cwd);
|
const currentBranch = await getCurrentBranch(cwd);
|
||||||
|
if (currentBranch) {
|
||||||
|
rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch);
|
||||||
|
}
|
||||||
return { previousBranch, currentBranch };
|
return { previousBranch, currentBranch };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1600,22 +1625,25 @@ function buildPullRequestLookupTargetFromBranchConfig(
|
|||||||
input: PullRequestLookupTargetBranchConfig,
|
input: PullRequestLookupTargetBranchConfig,
|
||||||
): PullRequestStatusLookupTarget {
|
): PullRequestStatusLookupTarget {
|
||||||
const trackedHeadRef = parseBranchMergeHeadRef(input.branchMergeRef);
|
const trackedHeadRef = parseBranchMergeHeadRef(input.branchMergeRef);
|
||||||
if (!input.branchRemoteName || !trackedHeadRef || trackedHeadRef === input.currentBranch) {
|
if (!input.branchRemoteName || !trackedHeadRef) {
|
||||||
return { headRef: input.currentBranch };
|
return { headRef: input.currentBranch };
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteRepo = input.branchRemoteUrl
|
const remoteRepo = parseRepositoryIdentityFromRemote(input.branchRemoteUrl);
|
||||||
? parseGitHubRepoFromRemote(input.branchRemoteUrl)
|
const originRepo = parseRepositoryIdentityFromRemote(input.originRemoteUrl);
|
||||||
: null;
|
const isSameRepo = areSameGitHubRepository(remoteRepo, originRepo);
|
||||||
const originRepo = input.originRemoteUrl
|
|
||||||
? parseGitHubRepoFromRemote(input.originRemoteUrl)
|
|
||||||
: null;
|
|
||||||
const isSameRepo = Boolean(remoteRepo && originRepo && remoteRepo === originRepo);
|
|
||||||
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
|
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
|
||||||
const normalizedBaseRef = input.resolvedBaseRef
|
const normalizedBaseRef = input.resolvedBaseRef
|
||||||
? normalizeLocalBranchRefName(input.resolvedBaseRef)
|
? normalizeLocalBranchRefName(input.resolvedBaseRef)
|
||||||
: null;
|
: null;
|
||||||
if (trackedHeadRef === normalizedBaseRef && !headRepositoryOwner) {
|
if (
|
||||||
|
trackedHeadRef === normalizedBaseRef &&
|
||||||
|
!doesLocalBranchNameIdentifyTrackedHead(
|
||||||
|
input.currentBranch,
|
||||||
|
trackedHeadRef,
|
||||||
|
headRepositoryOwner,
|
||||||
|
)
|
||||||
|
) {
|
||||||
return { headRef: input.currentBranch };
|
return { headRef: input.currentBranch };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1629,6 +1657,34 @@ function buildPullRequestLookupTargetFromBranchConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseRepositoryIdentityFromRemote(remoteUrl: string | null): string | null {
|
||||||
|
if (!remoteUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const location = parseGitRemoteLocation(remoteUrl);
|
||||||
|
return location ? (parseGitHubRemoteIdentity(location.path)?.repo ?? null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function areSameGitHubRepository(left: string | null, right: string | null): boolean {
|
||||||
|
return Boolean(left && right && left.toLowerCase() === right.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function doesLocalBranchNameIdentifyTrackedHead(
|
||||||
|
currentBranch: string,
|
||||||
|
trackedHeadRef: string,
|
||||||
|
headRepositoryOwner: string | null,
|
||||||
|
): boolean {
|
||||||
|
if (currentBranch === trackedHeadRef) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!headRepositoryOwner) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return [headRepositoryOwner, headRepositoryOwner.toLowerCase()].some(
|
||||||
|
(owner) => currentBranch === `${owner}/${trackedHeadRef}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildPullRequestLookupTargetFromPushConfig(
|
function buildPullRequestLookupTargetFromPushConfig(
|
||||||
input: PullRequestLookupTargetPushConfig,
|
input: PullRequestLookupTargetPushConfig,
|
||||||
): PullRequestStatusLookupTarget | null {
|
): PullRequestStatusLookupTarget | null {
|
||||||
@@ -1641,7 +1697,7 @@ function buildPullRequestLookupTargetFromPushConfig(
|
|||||||
const originRepo = input.originRemoteUrl
|
const originRepo = input.originRemoteUrl
|
||||||
? parseGitHubRepoFromRemote(input.originRemoteUrl)
|
? parseGitHubRepoFromRemote(input.originRemoteUrl)
|
||||||
: null;
|
: null;
|
||||||
const isSameRepo = Boolean(remoteRepo && originRepo && remoteRepo === originRepo);
|
const isSameRepo = areSameGitHubRepository(remoteRepo, originRepo);
|
||||||
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
|
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
|
||||||
const normalizedBaseRef = input.resolvedBaseRef
|
const normalizedBaseRef = input.resolvedBaseRef
|
||||||
? normalizeLocalBranchRefName(input.resolvedBaseRef)
|
? normalizeLocalBranchRefName(input.resolvedBaseRef)
|
||||||
@@ -1658,8 +1714,9 @@ function buildPullRequestLookupTargetFromPushConfig(
|
|||||||
|
|
||||||
function buildPullRequestLookupTargetFromMetadata(
|
function buildPullRequestLookupTargetFromMetadata(
|
||||||
metadata: PaseoWorktreeMetadata | null,
|
metadata: PaseoWorktreeMetadata | null,
|
||||||
|
currentBranch: string,
|
||||||
): PullRequestStatusLookupTarget | null {
|
): PullRequestStatusLookupTarget | null {
|
||||||
const target = metadata?.changeRequestLookupTarget;
|
const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, currentBranch);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1682,8 +1739,22 @@ function buildInitialPullRequestLookupTarget(input: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasConfiguredBranchTarget = Boolean(
|
||||||
|
input.branchRemoteName && parseBranchMergeHeadRef(input.branchMergeRef),
|
||||||
|
);
|
||||||
|
if (hasConfiguredBranchTarget) {
|
||||||
|
return buildPullRequestLookupTargetFromBranchConfig({
|
||||||
|
currentBranch: input.currentBranch,
|
||||||
|
branchRemoteName: input.branchRemoteName,
|
||||||
|
branchMergeRef: input.branchMergeRef,
|
||||||
|
branchRemoteUrl: input.branchRemoteUrl,
|
||||||
|
originRemoteUrl: input.originRemoteUrl,
|
||||||
|
resolvedBaseRef: input.resolvedBaseRef,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
buildPullRequestLookupTargetFromMetadata(input.metadata) ??
|
buildPullRequestLookupTargetFromMetadata(input.metadata, input.currentBranch) ??
|
||||||
buildPullRequestLookupTargetFromBranchConfig({
|
buildPullRequestLookupTargetFromBranchConfig({
|
||||||
currentBranch: input.currentBranch,
|
currentBranch: input.currentBranch,
|
||||||
branchRemoteName: input.branchRemoteName,
|
branchRemoteName: input.branchRemoteName,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const ChangeRequestLookupTargetSchema = z.object({
|
|||||||
headRef: z.string().min(1),
|
headRef: z.string().min(1),
|
||||||
headRepositoryOwner: z.string().min(1).optional(),
|
headRepositoryOwner: z.string().min(1).optional(),
|
||||||
changeRequestNumber: z.number().int().positive().optional(),
|
changeRequestNumber: z.number().int().positive().optional(),
|
||||||
|
localBranchName: z.string().min(1).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const PaseoWorktreeMetadataV1Schema = z.object({
|
const PaseoWorktreeMetadataV1Schema = z.object({
|
||||||
@@ -44,9 +45,66 @@ const PaseoWorktreeMetadataSchema = z.union([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export type PaseoWorktreeMetadata = z.infer<typeof PaseoWorktreeMetadataSchema>;
|
export type PaseoWorktreeMetadata = z.infer<typeof PaseoWorktreeMetadataSchema>;
|
||||||
export type PaseoWorktreeChangeRequestLookupTarget = z.infer<
|
export type PaseoWorktreeChangeRequestHint = z.infer<typeof ChangeRequestLookupTargetSchema>;
|
||||||
typeof ChangeRequestLookupTargetSchema
|
|
||||||
>;
|
export function createPaseoWorktreeChangeRequestHint(
|
||||||
|
input: PaseoWorktreeChangeRequestHint,
|
||||||
|
): PaseoWorktreeChangeRequestHint {
|
||||||
|
return ChangeRequestLookupTargetSchema.parse(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPaseoWorktreeChangeRequestHintForBranch(
|
||||||
|
metadata: PaseoWorktreeMetadata | null,
|
||||||
|
currentBranch: string,
|
||||||
|
): PaseoWorktreeChangeRequestHint | null {
|
||||||
|
const target = metadata?.changeRequestLookupTarget;
|
||||||
|
if (!target) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (target.localBranchName) {
|
||||||
|
return target.localBranchName === currentBranch ? target : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// COMPAT(change-request-local-branch): metadata before v0.2.5 omitted the
|
||||||
|
// local binding; remove after 2027-07-31.
|
||||||
|
const canonicalBranches = new Set<string>();
|
||||||
|
if (target.headRepositoryOwner) {
|
||||||
|
canonicalBranches.add(`${target.headRepositoryOwner}/${target.headRef}`);
|
||||||
|
const normalizedOwner = normalizeLegacyGitHubOwnerForBranch(target.headRepositoryOwner);
|
||||||
|
if (normalizedOwner) {
|
||||||
|
canonicalBranches.add(`${normalizedOwner}/${target.headRef}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
canonicalBranches.add(target.headRef);
|
||||||
|
}
|
||||||
|
return canonicalBranches.has(currentBranch) ? target : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLegacyGitHubOwnerForBranch(owner: string): string | null {
|
||||||
|
const normalized = owner.trim().toLowerCase();
|
||||||
|
return /^[a-z0-9-]+$/.test(normalized) ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rebindPaseoWorktreeChangeRequestHint(
|
||||||
|
worktreeRoot: string,
|
||||||
|
previousBranch: string,
|
||||||
|
currentBranch: string,
|
||||||
|
): boolean {
|
||||||
|
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
|
||||||
|
const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, previousBranch);
|
||||||
|
if (!metadata || !target) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
writePaseoWorktreeMetadataFile(worktreeRoot, {
|
||||||
|
...metadata,
|
||||||
|
changeRequestLookupTarget: {
|
||||||
|
...target,
|
||||||
|
localBranchName: currentBranch,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function getGitDirForWorktreeRoot(worktreeRoot: string): string {
|
function getGitDirForWorktreeRoot(worktreeRoot: string): string {
|
||||||
const gitPath = join(worktreeRoot, ".git");
|
const gitPath = join(worktreeRoot, ".git");
|
||||||
@@ -90,7 +148,7 @@ export function writePaseoWorktreeMetadata(
|
|||||||
worktreeRoot: string,
|
worktreeRoot: string,
|
||||||
options: {
|
options: {
|
||||||
baseRefName: string;
|
baseRefName: string;
|
||||||
changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget;
|
changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
const baseRefName = normalizeBaseRefName(options.baseRefName);
|
const baseRefName = normalizeBaseRefName(options.baseRefName);
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ export {
|
|||||||
} from "@getpaseo/protocol/paseo-config-schema";
|
} from "@getpaseo/protocol/paseo-config-schema";
|
||||||
import { PaseoConfigSchema, type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
import { PaseoConfigSchema, type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
||||||
import {
|
import {
|
||||||
|
createPaseoWorktreeChangeRequestHint,
|
||||||
normalizeBaseRefName,
|
normalizeBaseRefName,
|
||||||
type PaseoWorktreeChangeRequestLookupTarget,
|
type PaseoWorktreeChangeRequestHint,
|
||||||
readPaseoWorktreeMetadata,
|
readPaseoWorktreeMetadata,
|
||||||
readPaseoWorktreeRuntimePort,
|
readPaseoWorktreeRuntimePort,
|
||||||
writePaseoWorktreeMetadata,
|
writePaseoWorktreeMetadata,
|
||||||
@@ -1300,7 +1301,7 @@ interface ResolveWorktreeSourcePlanOptions {
|
|||||||
interface WorktreeSourcePlan {
|
interface WorktreeSourcePlan {
|
||||||
branchName: string;
|
branchName: string;
|
||||||
metadataBaseRefName: string;
|
metadataBaseRefName: string;
|
||||||
changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget;
|
changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint;
|
||||||
addArguments: string[];
|
addArguments: string[];
|
||||||
pushRemote?: {
|
pushRemote?: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1408,13 +1409,14 @@ async function resolveWorktreeSourcePlan({
|
|||||||
return {
|
return {
|
||||||
branchName: localBranchName,
|
branchName: localBranchName,
|
||||||
metadataBaseRefName: normalizedBaseRefName,
|
metadataBaseRefName: normalizedBaseRefName,
|
||||||
changeRequestLookupTarget: {
|
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
||||||
headRef: source.headRef,
|
headRef: source.headRef,
|
||||||
...(source.headRepositoryOwner
|
...(source.headRepositoryOwner
|
||||||
? { headRepositoryOwner: source.headRepositoryOwner }
|
? { headRepositoryOwner: source.headRepositoryOwner }
|
||||||
: {}),
|
: {}),
|
||||||
changeRequestNumber,
|
changeRequestNumber,
|
||||||
},
|
localBranchName,
|
||||||
|
}),
|
||||||
addArguments: [localBranchName],
|
addArguments: [localBranchName],
|
||||||
...remotePlan,
|
...remotePlan,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user