| author | |
| committer | |
| log | f7570712a741136999a77d5a8f6490a8c7d9e249 |
| tree | 3117fba6350db3eaaddea9e5842378255dc48d1c |
| parent | 2847655a1a1f8f45d64224ee45b9262a1fc14dfc |
- 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 | 1407 | }), |
| 1408 | 1408 | ); |
| 1409 | 1409 | |
| 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 | 1521 | it.effect("reuses an existing PR worktree and restores fork upstream tracking", () => |
| 1411 | 1522 | Effect.gen(function* () { |
| 1412 | 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 | 2 | import { realpathSync } from "node:fs"; |
| 3 | 3 | |
| 4 | 4 | import { Effect, FileSystem, Layer, Path } from "effect"; |
| 5 | import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | |
| 5 | import { | |
| 6 | resolveAutoFeatureBranchName, | |
| 7 | sanitizeBranchFragment, | |
| 8 | sanitizeFeatureBranchName, | |
| 9 | } from "@t3tools/shared/git"; | |
| 6 | 10 | |
| 7 | 11 | import { GitManagerError } from "../Errors.ts"; |
| 8 | 12 | import { GitManager, type GitManagerShape } from "../Services/GitManager.ts"; |
| ... | ... | @@ -66,6 +70,18 @@ function resolveHeadRepositoryNameWithOwner( |
| 66 | 70 | return `${ownerLogin}/${repositoryName}`; |
| 67 | 71 | } |
| 68 | 72 | |
| 73 | function 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 | ||
| 69 | 85 | function parsePullRequestList(raw: unknown): PullRequestInfo[] { |
| 70 | 86 | if (!Array.isArray(raw)) return []; |
| 71 | 87 | |
| ... | ... | @@ -322,6 +338,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 322 | 338 | const materializePullRequestHeadBranch = ( |
| 323 | 339 | cwd: string, |
| 324 | 340 | pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, |
| 341 | localBranch = pullRequest.headBranch, | |
| 325 | 342 | ) => |
| 326 | 343 | Effect.gen(function* () { |
| 327 | 344 | const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; |
| ... | ... | @@ -330,7 +347,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 330 | 347 | yield* gitCore.fetchPullRequestBranch({ |
| 331 | 348 | cwd, |
| 332 | 349 | prNumber: pullRequest.number, |
| 333 | branch: pullRequest.headBranch, | |
| 350 | branch: localBranch, | |
| 334 | 351 | }); |
| 335 | 352 | return; |
| 336 | 353 | } |
| ... | ... | @@ -355,11 +372,11 @@ export const makeGitManager = Effect.gen(function* () { |
| 355 | 372 | cwd, |
| 356 | 373 | remoteName, |
| 357 | 374 | remoteBranch: pullRequest.headBranch, |
| 358 | localBranch: pullRequest.headBranch, | |
| 375 | localBranch, | |
| 359 | 376 | }); |
| 360 | 377 | yield* gitCore.setBranchUpstream({ |
| 361 | 378 | cwd, |
| 362 | branch: pullRequest.headBranch, | |
| 379 | branch: localBranch, | |
| 363 | 380 | remoteName, |
| 364 | 381 | remoteBranch: pullRequest.headBranch, |
| 365 | 382 | }); |
| ... | ... | @@ -368,7 +385,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 368 | 385 | gitCore.fetchPullRequestBranch({ |
| 369 | 386 | cwd, |
| 370 | 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 | 718 | ...pullRequest, |
| 702 | 719 | ...toPullRequestHeadRemoteInfo(pullRequestSummary), |
| 703 | 720 | } as const; |
| 721 | const localPullRequestBranch = resolvePullRequestWorktreeLocalBranchName( | |
| 722 | pullRequestWithRemoteInfo, | |
| 723 | ); | |
| 704 | 724 | |
| 705 | 725 | const findLocalHeadBranch = (cwd: string) => |
| 706 | 726 | 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 | }), | |
| 712 | 747 | ); |
| 713 | 748 | |
| 714 | 749 | const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); |
| ... | ... | @@ -722,7 +757,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 722 | 757 | yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); |
| 723 | 758 | return { |
| 724 | 759 | pullRequest, |
| 725 | branch: pullRequest.headBranch, | |
| 760 | branch: localPullRequestBranch, | |
| 726 | 761 | worktreePath: existingBranchBeforeFetch.worktreePath, |
| 727 | 762 | }; |
| 728 | 763 | } |
| ... | ... | @@ -733,7 +768,11 @@ export const makeGitManager = Effect.gen(function* () { |
| 733 | 768 | ); |
| 734 | 769 | } |
| 735 | 770 | |
| 736 | yield* materializePullRequestHeadBranch(input.cwd, pullRequestWithRemoteInfo); | |
| 771 | yield* materializePullRequestHeadBranch( | |
| 772 | input.cwd, | |
| 773 | pullRequestWithRemoteInfo, | |
| 774 | localPullRequestBranch, | |
| 775 | ); | |
| 737 | 776 | |
| 738 | 777 | const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); |
| 739 | 778 | const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath |
| ... | ... | @@ -746,7 +785,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 746 | 785 | yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); |
| 747 | 786 | return { |
| 748 | 787 | pullRequest, |
| 749 | branch: pullRequest.headBranch, | |
| 788 | branch: localPullRequestBranch, | |
| 750 | 789 | worktreePath: existingBranchAfterFetch.worktreePath, |
| 751 | 790 | }; |
| 752 | 791 | } |
| ... | ... | @@ -759,7 +798,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 759 | 798 | |
| 760 | 799 | const worktree = yield* gitCore.createWorktree({ |
| 761 | 800 | cwd: input.cwd, |
| 762 | branch: pullRequest.headBranch, | |
| 801 | branch: localPullRequestBranch, | |
| 763 | 802 | path: null, |
| 764 | 803 | }); |
| 765 | 804 | yield* ensureExistingWorktreeUpstream(worktree.worktree.path); |