authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-09 16:46:53-07:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-09 22:50:45-07:00
logf7570712a741136999a77d5a8f6490a8c7d9e249
tree3117fba6350db3eaaddea9e5842378255dc48d1c
parent2847655a1a1f8f45d64224ee45b9262a1fc14dfc

Avoid fork PR head branch collisions when creating worktrees

- create a unique local branch name for cross-repo PR worktrees (`pr/<number>-<head>`) - fetch/set upstream against that local branch instead of overwriting local `main` - add regression tests for fork PRs where head branch is `main`

2 files changed, 164 insertions(+), 14 deletions(-)

apps/server/src/git/Layers/GitManager.test.ts+111
......@@ -1407,6 +1407,117 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
14071407 }),
14081408 );
14091409
1410 it.effect("does not block fork PR worktree prep when the fork head branch collides with root main", () =>
1411 Effect.gen(function* () {
1412 const repoDir = yield* makeTempDir("t3code-git-manager-");
1413 yield* initRepo(repoDir);
1414 const originDir = yield* createBareRemote();
1415 const forkDir = yield* createBareRemote();
1416 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1417 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1418 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1419 yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]);
1420 fs.writeFileSync(path.join(repoDir, "fork-main.txt"), "fork main\n");
1421 yield* runGit(repoDir, ["add", "fork-main.txt"]);
1422 yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]);
1423 yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]);
1424 yield* runGit(repoDir, ["checkout", "main"]);
1425 const mainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();
1426
1427 const { manager } = yield* makeManager({
1428 ghScenario: {
1429 pullRequest: {
1430 number: 91,
1431 title: "Fork main PR",
1432 url: "https://github.com/pingdotgg/codething-mvp/pull/91",
1433 baseRefName: "main",
1434 headRefName: "main",
1435 state: "open",
1436 isCrossRepository: true,
1437 headRepositoryNameWithOwner: "octocat/codething-mvp",
1438 headRepositoryOwnerLogin: "octocat",
1439 },
1440 repositoryCloneUrls: {
1441 "octocat/codething-mvp": {
1442 url: forkDir,
1443 sshUrl: forkDir,
1444 },
1445 },
1446 },
1447 });
1448
1449 const result = yield* preparePullRequestThread(manager, {
1450 cwd: repoDir,
1451 reference: "91",
1452 mode: "worktree",
1453 });
1454
1455 expect(result.branch).toBe("pr/91-main");
1456 expect(result.worktreePath).not.toBeNull();
1457 expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main");
1458 expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainBefore);
1459 expect(
1460 (yield* runGit(result.worktreePath as string, ["branch", "--show-current"])).stdout.trim(),
1461 ).toBe("pr/91-main");
1462 }),
1463 );
1464
1465 it.effect("does not overwrite an existing local main branch when preparing a fork PR worktree", () =>
1466 Effect.gen(function* () {
1467 const repoDir = yield* makeTempDir("t3code-git-manager-");
1468 yield* initRepo(repoDir);
1469 const originDir = yield* createBareRemote();
1470 const forkDir = yield* createBareRemote();
1471 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1472 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1473 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1474 yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]);
1475 fs.writeFileSync(path.join(repoDir, "fork-main-second.txt"), "fork main second\n");
1476 yield* runGit(repoDir, ["add", "fork-main-second.txt"]);
1477 yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]);
1478 yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]);
1479 yield* runGit(repoDir, ["checkout", "main"]);
1480 const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();
1481 yield* runGit(repoDir, ["checkout", "-b", "feature/root-branch"]);
1482
1483 const { manager } = yield* makeManager({
1484 ghScenario: {
1485 pullRequest: {
1486 number: 92,
1487 title: "Fork main overwrite PR",
1488 url: "https://github.com/pingdotgg/codething-mvp/pull/92",
1489 baseRefName: "main",
1490 headRefName: "main",
1491 state: "open",
1492 isCrossRepository: true,
1493 headRepositoryNameWithOwner: "octocat/codething-mvp",
1494 headRepositoryOwnerLogin: "octocat",
1495 },
1496 repositoryCloneUrls: {
1497 "octocat/codething-mvp": {
1498 url: forkDir,
1499 sshUrl: forkDir,
1500 },
1501 },
1502 },
1503 });
1504
1505 const result = yield* preparePullRequestThread(manager, {
1506 cwd: repoDir,
1507 reference: "92",
1508 mode: "worktree",
1509 });
1510
1511 expect(result.branch).toBe("pr/92-main");
1512 expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore);
1513 expect(
1514 (yield* runGit(result.worktreePath as string, ["rev-parse", "--abbrev-ref", "@{upstream}"]))
1515 .stdout
1516 .trim(),
1517 ).toBe("fork-seed/main");
1518 }),
1519 );
1520
14101521 it.effect("reuses an existing PR worktree and restores fork upstream tracking", () =>
14111522 Effect.gen(function* () {
14121523 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/Layers/GitManager.ts+53-14
......@@ -2,7 +2,11 @@ import { randomUUID } from "node:crypto";
22import { realpathSync } from "node:fs";
33
44import { Effect, FileSystem, Layer, Path } from "effect";
5import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git";
5import {
6 resolveAutoFeatureBranchName,
7 sanitizeBranchFragment,
8 sanitizeFeatureBranchName,
9} from "@t3tools/shared/git";
610
711import { GitManagerError } from "../Errors.ts";
812import { GitManager, type GitManagerShape } from "../Services/GitManager.ts";
......@@ -66,6 +70,18 @@ function resolveHeadRepositoryNameWithOwner(
6670 return `${ownerLogin}/${repositoryName}`;
6771}
6872
73function resolvePullRequestWorktreeLocalBranchName(
74 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
75): string {
76 if (!pullRequest.isCrossRepository) {
77 return pullRequest.headBranch;
78 }
79
80 const sanitizedHeadBranch = sanitizeBranchFragment(pullRequest.headBranch).trim();
81 const suffix = sanitizedHeadBranch.length > 0 ? sanitizedHeadBranch : "head";
82 return `pr/${pullRequest.number}-${suffix}`;
83}
84
6985function parsePullRequestList(raw: unknown): PullRequestInfo[] {
7086 if (!Array.isArray(raw)) return [];
7187
......@@ -322,6 +338,7 @@ export const makeGitManager = Effect.gen(function* () {
322338 const materializePullRequestHeadBranch = (
323339 cwd: string,
324340 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
341 localBranch = pullRequest.headBranch,
325342 ) =>
326343 Effect.gen(function* () {
327344 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";
......@@ -330,7 +347,7 @@ export const makeGitManager = Effect.gen(function* () {
330347 yield* gitCore.fetchPullRequestBranch({
331348 cwd,
332349 prNumber: pullRequest.number,
333 branch: pullRequest.headBranch,
350 branch: localBranch,
334351 });
335352 return;
336353 }
......@@ -355,11 +372,11 @@ export const makeGitManager = Effect.gen(function* () {
355372 cwd,
356373 remoteName,
357374 remoteBranch: pullRequest.headBranch,
358 localBranch: pullRequest.headBranch,
375 localBranch,
359376 });
360377 yield* gitCore.setBranchUpstream({
361378 cwd,
362 branch: pullRequest.headBranch,
379 branch: localBranch,
363380 remoteName,
364381 remoteBranch: pullRequest.headBranch,
365382 });
......@@ -368,7 +385,7 @@ export const makeGitManager = Effect.gen(function* () {
368385 gitCore.fetchPullRequestBranch({
369386 cwd,
370387 prNumber: pullRequest.number,
371 branch: pullRequest.headBranch,
388 branch: localBranch,
372389 }),
373390 ),
374391 );
......@@ -701,14 +718,32 @@ export const makeGitManager = Effect.gen(function* () {
701718 ...pullRequest,
702719 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
703720 } as const;
721 const localPullRequestBranch = resolvePullRequestWorktreeLocalBranchName(
722 pullRequestWithRemoteInfo,
723 );
704724
705725 const findLocalHeadBranch = (cwd: string) =>
706726 gitCore.listBranches({ cwd }).pipe(
707 Effect.map((result) =>
708 result.branches.find(
709 (branch) => !branch.isRemote && branch.name === pullRequest.headBranch,
710 ),
711 ),
727 Effect.map((result) => {
728 const localBranch = result.branches.find(
729 (branch) => !branch.isRemote && branch.name === localPullRequestBranch,
730 );
731 if (localBranch) {
732 return localBranch;
733 }
734 if (localPullRequestBranch === pullRequest.headBranch) {
735 return null;
736 }
737 return (
738 result.branches.find(
739 (branch) =>
740 !branch.isRemote &&
741 branch.name === pullRequest.headBranch &&
742 branch.worktreePath !== null &&
743 canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath,
744 ) ?? null
745 );
746 }),
712747 );
713748
714749 const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd);
......@@ -722,7 +757,7 @@ export const makeGitManager = Effect.gen(function* () {
722757 yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath);
723758 return {
724759 pullRequest,
725 branch: pullRequest.headBranch,
760 branch: localPullRequestBranch,
726761 worktreePath: existingBranchBeforeFetch.worktreePath,
727762 };
728763 }
......@@ -733,7 +768,11 @@ export const makeGitManager = Effect.gen(function* () {
733768 );
734769 }
735770
736 yield* materializePullRequestHeadBranch(input.cwd, pullRequestWithRemoteInfo);
771 yield* materializePullRequestHeadBranch(
772 input.cwd,
773 pullRequestWithRemoteInfo,
774 localPullRequestBranch,
775 );
737776
738777 const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd);
739778 const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath
......@@ -746,7 +785,7 @@ export const makeGitManager = Effect.gen(function* () {
746785 yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath);
747786 return {
748787 pullRequest,
749 branch: pullRequest.headBranch,
788 branch: localPullRequestBranch,
750789 worktreePath: existingBranchAfterFetch.worktreePath,
751790 };
752791 }
......@@ -759,7 +798,7 @@ export const makeGitManager = Effect.gen(function* () {
759798
760799 const worktree = yield* gitCore.createWorktree({
761800 cwd: input.cwd,
762 branch: pullRequest.headBranch,
801 branch: localPullRequestBranch,
763802 path: null,
764803 });
765804 yield* ensureExistingWorktreeUpstream(worktree.worktree.path);