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) => {...@@ -1407,6 +1407,117 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1407 }),1407 }),
1408 );1408 );
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
1410 it.effect("reuses an existing PR worktree and restores fork upstream tracking", () =>1521 it.effect("reuses an existing PR worktree and restores fork upstream tracking", () =>
1411 Effect.gen(function* () {1522 Effect.gen(function* () {
1412 const repoDir = yield* makeTempDir("t3code-git-manager-");1523 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";...@@ -2,7 +2,11 @@ import { randomUUID } from "node:crypto";
2import { realpathSync } from "node:fs";2import { realpathSync } from "node:fs";
33
4import { Effect, FileSystem, Layer, Path } from "effect";4import { 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
7import { GitManagerError } from "../Errors.ts";11import { GitManagerError } from "../Errors.ts";
8import { GitManager, type GitManagerShape } from "../Services/GitManager.ts";12import { GitManager, type GitManagerShape } from "../Services/GitManager.ts";
...@@ -66,6 +70,18 @@ function resolveHeadRepositoryNameWithOwner(...@@ -66,6 +70,18 @@ function resolveHeadRepositoryNameWithOwner(
66 return `${ownerLogin}/${repositoryName}`;70 return `${ownerLogin}/${repositoryName}`;
67}71}
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
69function parsePullRequestList(raw: unknown): PullRequestInfo[] {85function parsePullRequestList(raw: unknown): PullRequestInfo[] {
70 if (!Array.isArray(raw)) return [];86 if (!Array.isArray(raw)) return [];
7187
...@@ -322,6 +338,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -322,6 +338,7 @@ export const makeGitManager = Effect.gen(function* () {
322 const materializePullRequestHeadBranch = (338 const materializePullRequestHeadBranch = (
323 cwd: string,339 cwd: string,
324 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,340 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
341 localBranch = pullRequest.headBranch,
325 ) =>342 ) =>
326 Effect.gen(function* () {343 Effect.gen(function* () {
327 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";344 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";
...@@ -330,7 +347,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -330,7 +347,7 @@ export const makeGitManager = Effect.gen(function* () {
330 yield* gitCore.fetchPullRequestBranch({347 yield* gitCore.fetchPullRequestBranch({
331 cwd,348 cwd,
332 prNumber: pullRequest.number,349 prNumber: pullRequest.number,
333 branch: pullRequest.headBranch,350 branch: localBranch,
334 });351 });
335 return;352 return;
336 }353 }
...@@ -355,11 +372,11 @@ export const makeGitManager = Effect.gen(function* () {...@@ -355,11 +372,11 @@ export const makeGitManager = Effect.gen(function* () {
355 cwd,372 cwd,
356 remoteName,373 remoteName,
357 remoteBranch: pullRequest.headBranch,374 remoteBranch: pullRequest.headBranch,
358 localBranch: pullRequest.headBranch,375 localBranch,
359 });376 });
360 yield* gitCore.setBranchUpstream({377 yield* gitCore.setBranchUpstream({
361 cwd,378 cwd,
362 branch: pullRequest.headBranch,379 branch: localBranch,
363 remoteName,380 remoteName,
364 remoteBranch: pullRequest.headBranch,381 remoteBranch: pullRequest.headBranch,
365 });382 });
...@@ -368,7 +385,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -368,7 +385,7 @@ export const makeGitManager = Effect.gen(function* () {
368 gitCore.fetchPullRequestBranch({385 gitCore.fetchPullRequestBranch({
369 cwd,386 cwd,
370 prNumber: pullRequest.number,387 prNumber: pullRequest.number,
371 branch: pullRequest.headBranch,388 branch: localBranch,
372 }),389 }),
373 ),390 ),
374 );391 );
...@@ -701,14 +718,32 @@ export const makeGitManager = Effect.gen(function* () {...@@ -701,14 +718,32 @@ export const makeGitManager = Effect.gen(function* () {
701 ...pullRequest,718 ...pullRequest,
702 ...toPullRequestHeadRemoteInfo(pullRequestSummary),719 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
703 } as const;720 } as const;
721 const localPullRequestBranch = resolvePullRequestWorktreeLocalBranchName(
722 pullRequestWithRemoteInfo,
723 );
704724
705 const findLocalHeadBranch = (cwd: string) =>725 const findLocalHeadBranch = (cwd: string) =>
706 gitCore.listBranches({ cwd }).pipe(726 gitCore.listBranches({ cwd }).pipe(
707 Effect.map((result) =>727 Effect.map((result) => {
708 result.branches.find(728 const localBranch = result.branches.find(
709 (branch) => !branch.isRemote && branch.name === pullRequest.headBranch,729 (branch) => !branch.isRemote && branch.name === localPullRequestBranch,
710 ),730 );
711 ),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 }),
712 );747 );
713748
714 const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd);749 const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd);
...@@ -722,7 +757,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -722,7 +757,7 @@ export const makeGitManager = Effect.gen(function* () {
722 yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath);757 yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath);
723 return {758 return {
724 pullRequest,759 pullRequest,
725 branch: pullRequest.headBranch,760 branch: localPullRequestBranch,
726 worktreePath: existingBranchBeforeFetch.worktreePath,761 worktreePath: existingBranchBeforeFetch.worktreePath,
727 };762 };
728 }763 }
...@@ -733,7 +768,11 @@ export const makeGitManager = Effect.gen(function* () {...@@ -733,7 +768,11 @@ export const makeGitManager = Effect.gen(function* () {
733 );768 );
734 }769 }
735770
736 yield* materializePullRequestHeadBranch(input.cwd, pullRequestWithRemoteInfo);771 yield* materializePullRequestHeadBranch(
772 input.cwd,
773 pullRequestWithRemoteInfo,
774 localPullRequestBranch,
775 );
737776
738 const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd);777 const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd);
739 const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath778 const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath
...@@ -746,7 +785,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -746,7 +785,7 @@ export const makeGitManager = Effect.gen(function* () {
746 yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath);785 yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath);
747 return {786 return {
748 pullRequest,787 pullRequest,
749 branch: pullRequest.headBranch,788 branch: localPullRequestBranch,
750 worktreePath: existingBranchAfterFetch.worktreePath,789 worktreePath: existingBranchAfterFetch.worktreePath,
751 };790 };
752 }791 }
...@@ -759,7 +798,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -759,7 +798,7 @@ export const makeGitManager = Effect.gen(function* () {
759798
760 const worktree = yield* gitCore.createWorktree({799 const worktree = yield* gitCore.createWorktree({
761 cwd: input.cwd,800 cwd: input.cwd,
762 branch: pullRequest.headBranch,801 branch: localPullRequestBranch,
763 path: null,802 path: null,
764 });803 });
765 yield* ensureExistingWorktreeUpstream(worktree.worktree.path);804 yield* ensureExistingWorktreeUpstream(worktree.worktree.path);