authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-09 16:00:02-07:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-09 22:50:45-07:00
log268016d872e724bd996fcd585835d169db903814
treefc2e4fec7fd4a3f8707329cae8ba1547b1d5a967
parentac3f22c274e5b116b04220b29d1470e9d39bd0f2

Fix PR thread upstream tracking for fork branches

- Restore/set upstream tracking when preparing local and reused PR worktrees - Derive fork `owner/repo` from PR URL when GitHub omits `headRepositoryNameWithOwner` - Expand GitManager tests to cover local mode, URL-derived fork identity, and reused worktree upstream recovery

2 files changed, 284 insertions(+), 18 deletions(-)

apps/server/src/git/Layers/GitManager.test.ts+221-6
......@@ -1,5 +1,6 @@
11import fs from "node:fs";
22import path from "node:path";
3import { spawnSync } from "node:child_process";
34
45import * as NodeServices from "@effect/platform-node/NodeServices";
56import { it } from "@effect/vitest";
......@@ -65,6 +66,29 @@ interface FakeGitTextGeneration {
6566
6667type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;
6768
69function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void {
70 const result = spawnSync("git", args, {
71 cwd,
72 encoding: "utf8",
73 });
74 if (result.status === 0) {
75 return;
76 }
77 throw new GitHubCliError({
78 operation: "execute",
79 detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`,
80 });
81}
82
83function isGitHubCliError(error: unknown): error is GitHubCliError {
84 return (
85 typeof error === "object" &&
86 error !== null &&
87 "_tag" in error &&
88 (error as { _tag?: unknown })._tag === "GitHubCliError"
89 );
90}
91
6892function makeTempDir(
6993 prefix: string,
7094): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> {
......@@ -252,12 +276,42 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
252276 }
253277
254278 if (args[0] === "pr" && args[1] === "checkout") {
255 return Effect.succeed({
256 stdout: "",
257 stderr: "",
258 code: 0,
259 signal: null,
260 timedOut: false,
279 return Effect.try({
280 try: () => {
281 const headBranch = scenario.pullRequest?.headRefName;
282 if (headBranch) {
283 const existingBranch = spawnSync(
284 "git",
285 ["show-ref", "--verify", "--quiet", `refs/heads/${headBranch}`],
286 {
287 cwd: input.cwd,
288 encoding: "utf8",
289 },
290 );
291 if (existingBranch.status === 0) {
292 runGitSyncForFakeGh(input.cwd, ["checkout", headBranch]);
293 } else {
294 runGitSyncForFakeGh(input.cwd, ["checkout", "-b", headBranch]);
295 }
296 }
297 return {
298 stdout: "",
299 stderr: "",
300 code: 0,
301 signal: null,
302 timedOut: false,
303 };
304 },
305 catch: (error) =>
306 isGitHubCliError(error)
307 ? error
308 : new GitHubCliError({
309 operation: "execute",
310 detail:
311 error instanceof Error
312 ? `Failed to simulate gh checkout: ${error.message}`
313 : "Failed to simulate gh checkout.",
314 }),
261315 });
262316 }
263317
......@@ -1210,6 +1264,111 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
12101264 }),
12111265 );
12121266
1267 it.effect("preserves fork upstream tracking when preparing a local PR thread", () =>
1268 Effect.gen(function* () {
1269 const repoDir = yield* makeTempDir("t3code-git-manager-");
1270 yield* initRepo(repoDir);
1271 const originDir = yield* createBareRemote();
1272 const forkDir = yield* createBareRemote();
1273 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1274 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1275 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1276 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-fork"]);
1277 fs.writeFileSync(path.join(repoDir, "local-fork.txt"), "local fork\n");
1278 yield* runGit(repoDir, ["add", "local-fork.txt"]);
1279 yield* runGit(repoDir, ["commit", "-m", "Local fork PR branch"]);
1280 yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-local-fork"]);
1281 yield* runGit(repoDir, ["checkout", "main"]);
1282 yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-fork"]);
1283
1284 const { manager } = yield* makeManager({
1285 ghScenario: {
1286 pullRequest: {
1287 number: 82,
1288 title: "Local Fork PR",
1289 url: "https://github.com/pingdotgg/codething-mvp/pull/82",
1290 baseRefName: "main",
1291 headRefName: "feature/pr-local-fork",
1292 state: "open",
1293 isCrossRepository: true,
1294 headRepositoryNameWithOwner: "octocat/codething-mvp",
1295 headRepositoryOwnerLogin: "octocat",
1296 },
1297 repositoryCloneUrls: {
1298 "octocat/codething-mvp": {
1299 url: forkDir,
1300 sshUrl: forkDir,
1301 },
1302 },
1303 },
1304 });
1305
1306 const result = yield* preparePullRequestThread(manager, {
1307 cwd: repoDir,
1308 reference: "82",
1309 mode: "local",
1310 });
1311
1312 expect(result.worktreePath).toBeNull();
1313 expect(result.branch).toBe("feature/pr-local-fork");
1314 expect(
1315 (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(),
1316 ).toBe("fork-seed/feature/pr-local-fork");
1317 }),
1318 );
1319
1320 it.effect("derives fork repository identity from PR URL when GitHub omits nameWithOwner", () =>
1321 Effect.gen(function* () {
1322 const repoDir = yield* makeTempDir("t3code-git-manager-");
1323 yield* initRepo(repoDir);
1324 const originDir = yield* createBareRemote();
1325 const forkDir = yield* createBareRemote();
1326 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1327 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1328 yield* runGit(repoDir, ["remote", "add", "binbandit-seed", forkDir]);
1329 yield* runGit(repoDir, ["checkout", "-b", "fix/git-action-default-without-origin"]);
1330 fs.writeFileSync(path.join(repoDir, "derived-fork.txt"), "derived fork\n");
1331 yield* runGit(repoDir, ["add", "derived-fork.txt"]);
1332 yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]);
1333 yield* runGit(repoDir, ["push", "-u", "binbandit-seed", "fix/git-action-default-without-origin"]);
1334 yield* runGit(repoDir, ["checkout", "main"]);
1335 yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]);
1336
1337 const { manager } = yield* makeManager({
1338 ghScenario: {
1339 pullRequest: {
1340 number: 642,
1341 title: "fix: use commit as the default git action without origin",
1342 url: "https://github.com/pingdotgg/t3code/pull/642",
1343 baseRefName: "main",
1344 headRefName: "fix/git-action-default-without-origin",
1345 state: "open",
1346 isCrossRepository: true,
1347 headRepositoryOwnerLogin: "binbandit",
1348 },
1349 repositoryCloneUrls: {
1350 "binbandit/t3code": {
1351 url: forkDir,
1352 sshUrl: forkDir,
1353 },
1354 },
1355 },
1356 });
1357
1358 const result = yield* preparePullRequestThread(manager, {
1359 cwd: repoDir,
1360 reference: "642",
1361 mode: "local",
1362 });
1363
1364 expect(result.branch).toBe("fix/git-action-default-without-origin");
1365 expect(result.worktreePath).toBeNull();
1366 expect(
1367 (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(),
1368 ).toBe("binbandit-seed/fix/git-action-default-without-origin");
1369 }),
1370 );
1371
12131372 it.effect("reuses an existing dedicated worktree for the PR head branch", () =>
12141373 Effect.gen(function* () {
12151374 const repoDir = yield* makeTempDir("t3code-git-manager-");
......@@ -1248,6 +1407,62 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
12481407 }),
12491408 );
12501409
1410 it.effect("reuses an existing PR worktree and restores fork upstream tracking", () =>
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", "feature/pr-reused-fork"]);
1420 fs.writeFileSync(path.join(repoDir, "reused-fork.txt"), "reused fork\n");
1421 yield* runGit(repoDir, ["add", "reused-fork.txt"]);
1422 yield* runGit(repoDir, ["commit", "-m", "Reused fork PR branch"]);
1423 yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-reused-fork"]);
1424 yield* runGit(repoDir, ["checkout", "main"]);
1425 const worktreePath = path.join(repoDir, "..", `pr-reused-fork-${Date.now()}`);
1426 yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-fork"]);
1427 yield* runGit(worktreePath, ["branch", "--unset-upstream"], true);
1428
1429 const { manager } = yield* makeManager({
1430 ghScenario: {
1431 pullRequest: {
1432 number: 83,
1433 title: "Reused Fork PR",
1434 url: "https://github.com/pingdotgg/codething-mvp/pull/83",
1435 baseRefName: "main",
1436 headRefName: "feature/pr-reused-fork",
1437 state: "open",
1438 isCrossRepository: true,
1439 headRepositoryNameWithOwner: "octocat/codething-mvp",
1440 headRepositoryOwnerLogin: "octocat",
1441 },
1442 repositoryCloneUrls: {
1443 "octocat/codething-mvp": {
1444 url: forkDir,
1445 sshUrl: forkDir,
1446 },
1447 },
1448 },
1449 });
1450
1451 const result = yield* preparePullRequestThread(manager, {
1452 cwd: repoDir,
1453 reference: "83",
1454 mode: "worktree",
1455 });
1456
1457 expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe(
1458 fs.realpathSync.native(worktreePath),
1459 );
1460 expect(
1461 (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(),
1462 ).toBe("fork-seed/feature/pr-reused-fork");
1463 }),
1464 );
1465
12511466 it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () =>
12521467 Effect.gen(function* () {
12531468 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/Layers/GitManager.ts+63-12
......@@ -38,6 +38,34 @@ interface PullRequestHeadRemoteInfo {
3838 headRepositoryOwnerLogin?: string | null;
3939}
4040
41function parseRepositoryNameFromPullRequestUrl(url: string): string | null {
42 const trimmed = url.trim();
43 const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed);
44 const repositoryName = match?.[1]?.trim() ?? "";
45 return repositoryName.length > 0 ? repositoryName : null;
46}
47
48function resolveHeadRepositoryNameWithOwner(
49 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
50): string | null {
51 const explicitRepository = pullRequest.headRepositoryNameWithOwner?.trim() ?? "";
52 if (explicitRepository.length > 0) {
53 return explicitRepository;
54 }
55
56 if (!pullRequest.isCrossRepository) {
57 return null;
58 }
59
60 const ownerLogin = pullRequest.headRepositoryOwnerLogin?.trim() ?? "";
61 const repositoryName = parseRepositoryNameFromPullRequestUrl(pullRequest.url);
62 if (ownerLogin.length === 0 || !repositoryName) {
63 return null;
64 }
65
66 return `${ownerLogin}/${repositoryName}`;
67}
68
4169function parsePullRequestList(raw: unknown): PullRequestInfo[] {
4270 if (!Array.isArray(raw)) return [];
4371
......@@ -253,9 +281,10 @@ export const makeGitManager = Effect.gen(function* () {
253281 const configurePullRequestHeadUpstream = (
254282 cwd: string,
255283 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
284 localBranch = pullRequest.headBranch,
256285 ) =>
257286 Effect.gen(function* () {
258 const repositoryNameWithOwner = pullRequest.headRepositoryNameWithOwner?.trim() ?? "";
287 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";
259288 if (repositoryNameWithOwner.length === 0) {
260289 return;
261290 }
......@@ -278,14 +307,14 @@ export const makeGitManager = Effect.gen(function* () {
278307
279308 yield* gitCore.setBranchUpstream({
280309 cwd,
281 branch: pullRequest.headBranch,
310 branch: localBranch,
282311 remoteName,
283312 remoteBranch: pullRequest.headBranch,
284313 });
285314 }).pipe(
286315 Effect.catch((error) =>
287316 Effect.logWarning(
288 `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${pullRequest.headBranch} in ${cwd}: ${error.message}`,
317 `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${localBranch} -> ${pullRequest.headBranch} in ${cwd}: ${error.message}`,
289318 ).pipe(Effect.asVoid),
290319 ),
291320 );
......@@ -295,7 +324,7 @@ export const makeGitManager = Effect.gen(function* () {
295324 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
296325 ) =>
297326 Effect.gen(function* () {
298 const repositoryNameWithOwner = pullRequest.headRepositoryNameWithOwner?.trim() ?? "";
327 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";
299328
300329 if (repositoryNameWithOwner.length === 0) {
301330 yield* gitCore.fetchPullRequestBranch({
......@@ -639,11 +668,15 @@ export const makeGitManager = Effect.gen(function* () {
639668 reference: normalizedReference,
640669 force: true,
641670 });
642 yield* configurePullRequestHeadUpstream(input.cwd, {
643 ...pullRequest,
644 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
645 });
646671 const details = yield* gitCore.statusDetails(input.cwd);
672 yield* configurePullRequestHeadUpstream(
673 input.cwd,
674 {
675 ...pullRequest,
676 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
677 },
678 details.branch ?? pullRequest.headBranch,
679 );
647680 return {
648681 pullRequest,
649682 branch: details.branch ?? pullRequest.headBranch,
......@@ -651,6 +684,24 @@ export const makeGitManager = Effect.gen(function* () {
651684 };
652685 }
653686
687 const ensureExistingWorktreeUpstream = (worktreePath: string) =>
688 Effect.gen(function* () {
689 const details = yield* gitCore.statusDetails(worktreePath);
690 yield* configurePullRequestHeadUpstream(
691 worktreePath,
692 {
693 ...pullRequest,
694 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
695 },
696 details.branch ?? pullRequest.headBranch,
697 );
698 });
699
700 const pullRequestWithRemoteInfo = {
701 ...pullRequest,
702 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
703 } as const;
704
654705 const findLocalHeadBranch = (cwd: string) =>
655706 gitCore.listBranches({ cwd }).pipe(
656707 Effect.map((result) =>
......@@ -668,6 +719,7 @@ export const makeGitManager = Effect.gen(function* () {
668719 existingBranchBeforeFetch?.worktreePath &&
669720 existingBranchBeforeFetchPath !== rootWorktreePath
670721 ) {
722 yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath);
671723 return {
672724 pullRequest,
673725 branch: pullRequest.headBranch,
......@@ -681,10 +733,7 @@ export const makeGitManager = Effect.gen(function* () {
681733 );
682734 }
683735
684 yield* materializePullRequestHeadBranch(input.cwd, {
685 ...pullRequest,
686 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
687 });
736 yield* materializePullRequestHeadBranch(input.cwd, pullRequestWithRemoteInfo);
688737
689738 const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd);
690739 const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath
......@@ -694,6 +743,7 @@ export const makeGitManager = Effect.gen(function* () {
694743 existingBranchAfterFetch?.worktreePath &&
695744 existingBranchAfterFetchPath !== rootWorktreePath
696745 ) {
746 yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath);
697747 return {
698748 pullRequest,
699749 branch: pullRequest.headBranch,
......@@ -712,6 +762,7 @@ export const makeGitManager = Effect.gen(function* () {
712762 branch: pullRequest.headBranch,
713763 path: null,
714764 });
765 yield* ensureExistingWorktreeUpstream(worktree.worktree.path);
715766
716767 return {
717768 pullRequest,