diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index f3b3ed4de..e9d58f762 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -576,7 +576,6 @@ export class CheckoutSession { // Branch is a git fact derived per-descriptor from each workspace's own // 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. - // TODO(K10): PR-binding on branch rename is deferred — see plan K10. // Push a workspace_update immediately so the sidebar/header reflect // the new branch name without waiting for the background git watcher. diff --git a/packages/server/src/server/worktree-core.posix.test.ts b/packages/server/src/server/worktree-core.posix.test.ts index 2d4aaf87d..40bd983c7 100644 --- a/packages/server/src/server/worktree-core.posix.test.ts +++ b/packages/server/src/server/worktree-core.posix.test.ts @@ -879,6 +879,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { expect(metadata?.changeRequestLookupTarget).toEqual({ headRef: "feature/gitlab-mr", changeRequestNumber: 14, + localBranchName: "feature/gitlab-mr-1", }); expect(facts).toMatchObject({ isGit: true, diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 110d3599f..8a2f879e1 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -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"], { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index d2e7ae29c..77763f921 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -3,6 +3,7 @@ import { existsSync, realpathSync } from "fs"; import { open as openFile, readFile, stat as statFile } from "fs/promises"; import { TTLCache } from "@isaacs/ttlcache"; import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages"; +import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import { maxBase64EncryptedPlaintextByteLength } from "@getpaseo/relay"; import type { Logger } from "pino"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; @@ -28,7 +29,12 @@ import { import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { runGitCommand } from "./run-git-command.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 = { GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C", @@ -827,6 +833,22 @@ async function requireGitRepo(cwd: string): Promise { } } +async function requireGitWorktreeRoot(cwd: string): Promise { + 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 { try { const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], { @@ -1037,7 +1059,7 @@ export async function renameCurrentBranch( cwd: string, newName: string, ): Promise<{ previousBranch: string | null; currentBranch: string | null }> { - await requireGitRepo(cwd); + const worktreeRoot = await requireGitWorktreeRoot(cwd); const previousBranch = await getCurrentBranch(cwd); if (!previousBranch || previousBranch === "HEAD") { @@ -1050,6 +1072,9 @@ export async function renameCurrentBranch( }); const currentBranch = await getCurrentBranch(cwd); + if (currentBranch) { + rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch); + } return { previousBranch, currentBranch }; } @@ -1600,22 +1625,25 @@ function buildPullRequestLookupTargetFromBranchConfig( input: PullRequestLookupTargetBranchConfig, ): PullRequestStatusLookupTarget { const trackedHeadRef = parseBranchMergeHeadRef(input.branchMergeRef); - if (!input.branchRemoteName || !trackedHeadRef || trackedHeadRef === input.currentBranch) { + if (!input.branchRemoteName || !trackedHeadRef) { return { headRef: input.currentBranch }; } - const remoteRepo = input.branchRemoteUrl - ? parseGitHubRepoFromRemote(input.branchRemoteUrl) - : null; - const originRepo = input.originRemoteUrl - ? parseGitHubRepoFromRemote(input.originRemoteUrl) - : null; - const isSameRepo = Boolean(remoteRepo && originRepo && remoteRepo === originRepo); + const remoteRepo = parseRepositoryIdentityFromRemote(input.branchRemoteUrl); + const originRepo = parseRepositoryIdentityFromRemote(input.originRemoteUrl); + const isSameRepo = areSameGitHubRepository(remoteRepo, originRepo); const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null; const normalizedBaseRef = input.resolvedBaseRef ? normalizeLocalBranchRefName(input.resolvedBaseRef) : null; - if (trackedHeadRef === normalizedBaseRef && !headRepositoryOwner) { + if ( + trackedHeadRef === normalizedBaseRef && + !doesLocalBranchNameIdentifyTrackedHead( + input.currentBranch, + trackedHeadRef, + headRepositoryOwner, + ) + ) { 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( input: PullRequestLookupTargetPushConfig, ): PullRequestStatusLookupTarget | null { @@ -1641,7 +1697,7 @@ function buildPullRequestLookupTargetFromPushConfig( const originRepo = input.originRemoteUrl ? parseGitHubRepoFromRemote(input.originRemoteUrl) : null; - const isSameRepo = Boolean(remoteRepo && originRepo && remoteRepo === originRepo); + const isSameRepo = areSameGitHubRepository(remoteRepo, originRepo); const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null; const normalizedBaseRef = input.resolvedBaseRef ? normalizeLocalBranchRefName(input.resolvedBaseRef) @@ -1658,8 +1714,9 @@ function buildPullRequestLookupTargetFromPushConfig( function buildPullRequestLookupTargetFromMetadata( metadata: PaseoWorktreeMetadata | null, + currentBranch: string, ): PullRequestStatusLookupTarget | null { - const target = metadata?.changeRequestLookupTarget; + const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, currentBranch); if (!target) { return null; } @@ -1682,8 +1739,22 @@ function buildInitialPullRequestLookupTarget(input: { 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 ( - buildPullRequestLookupTargetFromMetadata(input.metadata) ?? + buildPullRequestLookupTargetFromMetadata(input.metadata, input.currentBranch) ?? buildPullRequestLookupTargetFromBranchConfig({ currentBranch: input.currentBranch, branchRemoteName: input.branchRemoteName, diff --git a/packages/server/src/utils/worktree-metadata.ts b/packages/server/src/utils/worktree-metadata.ts index 4300c4370..931a29966 100644 --- a/packages/server/src/utils/worktree-metadata.ts +++ b/packages/server/src/utils/worktree-metadata.ts @@ -6,6 +6,7 @@ const ChangeRequestLookupTargetSchema = z.object({ headRef: z.string().min(1), headRepositoryOwner: z.string().min(1).optional(), changeRequestNumber: z.number().int().positive().optional(), + localBranchName: z.string().min(1).optional(), }); const PaseoWorktreeMetadataV1Schema = z.object({ @@ -44,9 +45,66 @@ const PaseoWorktreeMetadataSchema = z.union([ ]); export type PaseoWorktreeMetadata = z.infer; -export type PaseoWorktreeChangeRequestLookupTarget = z.infer< - typeof ChangeRequestLookupTargetSchema ->; +export type PaseoWorktreeChangeRequestHint = z.infer; + +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(); + 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 { const gitPath = join(worktreeRoot, ".git"); @@ -90,7 +148,7 @@ export function writePaseoWorktreeMetadata( worktreeRoot: string, options: { baseRefName: string; - changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget; + changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint; }, ): void { const baseRefName = normalizeBaseRefName(options.baseRefName); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 4ab641806..45fe7fa10 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -22,8 +22,9 @@ export { } from "@getpaseo/protocol/paseo-config-schema"; import { PaseoConfigSchema, type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema"; import { + createPaseoWorktreeChangeRequestHint, normalizeBaseRefName, - type PaseoWorktreeChangeRequestLookupTarget, + type PaseoWorktreeChangeRequestHint, readPaseoWorktreeMetadata, readPaseoWorktreeRuntimePort, writePaseoWorktreeMetadata, @@ -1300,7 +1301,7 @@ interface ResolveWorktreeSourcePlanOptions { interface WorktreeSourcePlan { branchName: string; metadataBaseRefName: string; - changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget; + changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint; addArguments: string[]; pushRemote?: { name: string; @@ -1408,13 +1409,14 @@ async function resolveWorktreeSourcePlan({ return { branchName: localBranchName, metadataBaseRefName: normalizedBaseRefName, - changeRequestLookupTarget: { + changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({ headRef: source.headRef, ...(source.headRepositoryOwner ? { headRepositoryOwner: source.headRepositoryOwner } : {}), changeRequestNumber, - }, + localBranchName, + }), addArguments: [localBranchName], ...remotePlan, };