authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-09 12:24:28-07:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-09 22:50:45-07:00
logb36888e9cc3692f931081a4d09bd58438d5f06eb
tree3bcb418e640a59882e106610868644558d926457
parent9bb9023009b0c45c66a609c386cd233855043caf

Preserve fork PR upstreams when preparing local and worktree threads

- add GitCore helpers to ensure remotes, fetch remote branches, and set upstreams - extend GitHub CLI parsing to include cross-repo PR metadata and repo clone URLs - update GitManager flow and tests to materialize fork heads and keep tracking on fork remotes

8 files changed, 525 insertions(+), 20 deletions(-)

apps/server/src/git/Layers/GitCore.test.ts+3
...@@ -106,6 +106,9 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) =>...@@ -106,6 +106,9 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) =>
106 listBranches: (input) => core.listBranches(input),106 listBranches: (input) => core.listBranches(input),
107 createWorktree: (input) => core.createWorktree(input),107 createWorktree: (input) => core.createWorktree(input),
108 fetchPullRequestBranch: (input) => core.fetchPullRequestBranch(input),108 fetchPullRequestBranch: (input) => core.fetchPullRequestBranch(input),
109 ensureRemote: (input) => core.ensureRemote(input),
110 fetchRemoteBranch: (input) => core.fetchRemoteBranch(input),
111 setBranchUpstream: (input) => core.setBranchUpstream(input),
109 removeWorktree: (input) => core.removeWorktree(input),112 removeWorktree: (input) => core.removeWorktree(input),
110 renameBranch: (input) => core.renameBranch(input),113 renameBranch: (input) => core.renameBranch(input),
111 createBranch: (input) => core.createBranch(input),114 createBranch: (input) => core.createBranch(input),
apps/server/src/git/Layers/GitCore.ts+83
...@@ -100,6 +100,31 @@ function parseRemoteNames(stdout: string): ReadonlyArray<string> {...@@ -100,6 +100,31 @@ function parseRemoteNames(stdout: string): ReadonlyArray<string> {
100 .toSorted((a, b) => b.length - a.length);100 .toSorted((a, b) => b.length - a.length);
101}101}
102102
103function sanitizeRemoteName(value: string): string {
104 const sanitized = value.trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
105 return sanitized.length > 0 ? sanitized : "fork";
106}
107
108function normalizeRemoteUrl(value: string): string {
109 return value.trim().replace(/\.git$/i, "").replace(/\/+$/g, "").toLowerCase();
110}
111
112function parseRemoteFetchUrls(stdout: string): Map<string, string> {
113 const remotes = new Map<string, string>();
114 for (const line of stdout.split("\n")) {
115 const trimmed = line.trim();
116 if (trimmed.length === 0) continue;
117 const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed);
118 if (!match) continue;
119 const [, remoteName = "", remoteUrl = "", direction = ""] = match;
120 if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) {
121 continue;
122 }
123 remotes.set(remoteName, remoteUrl);
124 }
125 return remotes;
126}
127
103function parseRemoteRefWithRemoteNames(128function parseRemoteRefWithRemoteNames(
104 branchName: string,129 branchName: string,
105 remoteNames: ReadonlyArray<string>,130 remoteNames: ReadonlyArray<string>,
...@@ -441,6 +466,32 @@ const makeGitCore = Effect.gen(function* () {...@@ -441,6 +466,32 @@ const makeGitCore = Effect.gen(function* () {
441 );466 );
442 });467 });
443468
469 const ensureRemote: GitCoreShape["ensureRemote"] = (input) =>
470 Effect.gen(function* () {
471 const preferredName = sanitizeRemoteName(input.preferredName);
472 const normalizedTargetUrl = normalizeRemoteUrl(input.url);
473 const remoteFetchUrls = yield* runGitStdout("GitCore.ensureRemote.listRemoteUrls", input.cwd, [
474 "remote",
475 "-v",
476 ]).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));
477
478 for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {
479 if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) {
480 return remoteName;
481 }
482 }
483
484 let remoteName = preferredName;
485 let suffix = 1;
486 while (remoteFetchUrls.has(remoteName)) {
487 remoteName = `${preferredName}-${suffix}`;
488 suffix += 1;
489 }
490
491 yield* runGit("GitCore.ensureRemote.add", input.cwd, ["remote", "add", remoteName, input.url]);
492 return remoteName;
493 });
494
444 const resolveBaseBranchForNoUpstream = (495 const resolveBaseBranchForNoUpstream = (
445 cwd: string,496 cwd: string,
446 branch: string,497 branch: string,
...@@ -1084,6 +1135,35 @@ const makeGitCore = Effect.gen(function* () {...@@ -1084,6 +1135,35 @@ const makeGitCore = Effect.gen(function* () {
1084 );1135 );
1085 }).pipe(Effect.asVoid);1136 }).pipe(Effect.asVoid);
10861137
1138 const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = (input) =>
1139 Effect.gen(function* () {
1140 yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [
1141 "fetch",
1142 "--quiet",
1143 "--no-tags",
1144 input.remoteName,
1145 `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`,
1146 ]);
1147
1148 const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch);
1149 const targetRef = `${input.remoteName}/${input.remoteBranch}`;
1150 yield* runGit(
1151 "GitCore.fetchRemoteBranch.materialize",
1152 input.cwd,
1153 localBranchAlreadyExists
1154 ? ["branch", "--force", input.localBranch, targetRef]
1155 : ["branch", input.localBranch, targetRef],
1156 );
1157 }).pipe(Effect.asVoid);
1158
1159 const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) =>
1160 runGit("GitCore.setBranchUpstream", input.cwd, [
1161 "branch",
1162 "--set-upstream-to",
1163 `${input.remoteName}/${input.remoteBranch}`,
1164 input.branch,
1165 ]);
1166
1087 const removeWorktree: GitCoreShape["removeWorktree"] = (input) =>1167 const removeWorktree: GitCoreShape["removeWorktree"] = (input) =>
1088 Effect.gen(function* () {1168 Effect.gen(function* () {
1089 const args = ["worktree", "remove"];1169 const args = ["worktree", "remove"];
...@@ -1244,6 +1324,9 @@ const makeGitCore = Effect.gen(function* () {...@@ -1244,6 +1324,9 @@ const makeGitCore = Effect.gen(function* () {
1244 listBranches,1324 listBranches,
1245 createWorktree,1325 createWorktree,
1246 fetchPullRequestBranch,1326 fetchPullRequestBranch,
1327 ensureRemote,
1328 fetchRemoteBranch,
1329 setBranchUpstream,
1247 removeWorktree,1330 removeWorktree,
1248 renameBranch,1331 renameBranch,
1249 createBranch,1332 createBranch,
apps/server/src/git/Layers/GitHubCli.test.ts+47-1
...@@ -22,6 +22,13 @@ describe("GitHubCliLive", () => {...@@ -22,6 +22,13 @@ describe("GitHubCliLive", () => {
22 headRefName: "feature/pr-threads",22 headRefName: "feature/pr-threads",
23 state: "OPEN",23 state: "OPEN",
24 mergedAt: null,24 mergedAt: null,
25 isCrossRepository: true,
26 headRepository: {
27 nameWithOwner: "octocat/codething-mvp",
28 },
29 headRepositoryOwner: {
30 login: "octocat",
31 },
25 }),32 }),
26 stderr: "",33 stderr: "",
27 code: 0,34 code: 0,
...@@ -46,14 +53,53 @@ describe("GitHubCliLive", () => {...@@ -46,14 +53,53 @@ describe("GitHubCliLive", () => {
46 baseRefName: "main",53 baseRefName: "main",
47 headRefName: "feature/pr-threads",54 headRefName: "feature/pr-threads",
48 state: "open",55 state: "open",
56 isCrossRepository: true,
57 headRepositoryNameWithOwner: "octocat/codething-mvp",
58 headRepositoryOwnerLogin: "octocat",
49 });59 });
50 expect(mockedRunProcess).toHaveBeenCalledWith(60 expect(mockedRunProcess).toHaveBeenCalledWith(
51 "gh",61 "gh",
52 ["pr", "view", "#42", "--json", "number,title,url,baseRefName,headRefName,state,mergedAt"],62 [
63 "pr",
64 "view",
65 "#42",
66 "--json",
67 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
68 ],
53 expect.objectContaining({ cwd: "/repo" }),69 expect.objectContaining({ cwd: "/repo" }),
54 );70 );
55 });71 });
5672
73 it("reads repository clone URLs", async () => {
74 mockedRunProcess.mockResolvedValueOnce({
75 stdout: JSON.stringify({
76 nameWithOwner: "octocat/codething-mvp",
77 url: "https://github.com/octocat/codething-mvp",
78 sshUrl: "git@github.com:octocat/codething-mvp.git",
79 }),
80 stderr: "",
81 code: 0,
82 signal: null,
83 timedOut: false,
84 });
85
86 const result = await Effect.runPromise(
87 Effect.gen(function* () {
88 const gh = yield* GitHubCli;
89 return yield* gh.getRepositoryCloneUrls({
90 cwd: "/repo",
91 repository: "octocat/codething-mvp",
92 });
93 }).pipe(Effect.provide(GitHubCliLive)),
94 );
95
96 expect(result).toEqual({
97 nameWithOwner: "octocat/codething-mvp",
98 url: "https://github.com/octocat/codething-mvp",
99 sshUrl: "git@github.com:octocat/codething-mvp.git",
100 });
101 });
102
57 it("surfaces a friendly error when the pull request is not found", async () => {103 it("surfaces a friendly error when the pull request is not found", async () => {
58 mockedRunProcess.mockRejectedValueOnce(104 mockedRunProcess.mockRejectedValueOnce(
59 new Error(105 new Error(
apps/server/src/git/Layers/GitHubCli.ts+83-1
...@@ -4,6 +4,7 @@ import { runProcess } from "../../processRunner";...@@ -4,6 +4,7 @@ import { runProcess } from "../../processRunner";
4import { GitHubCliError } from "../Errors.ts";4import { GitHubCliError } from "../Errors.ts";
5import {5import {
6 GitHubCli,6 GitHubCli,
7 type GitHubRepositoryCloneUrls,
7 type GitHubCliShape,8 type GitHubCliShape,
8 type GitHubPullRequestSummary,9 type GitHubPullRequestSummary,
9} from "../Services/GitHubCli.ts";10} from "../Services/GitHubCli.ts";
...@@ -85,6 +86,25 @@ function parsePullRequestSummary(...@@ -85,6 +86,25 @@ function parsePullRequestSummary(
85 const url = record.url;86 const url = record.url;
86 const baseRefName = record.baseRefName;87 const baseRefName = record.baseRefName;
87 const headRefName = record.headRefName;88 const headRefName = record.headRefName;
89 const isCrossRepository = record.isCrossRepository;
90 const headRepository =
91 record.headRepository && typeof record.headRepository === "object" && !Array.isArray(record.headRepository)
92 ? (record.headRepository as Record<string, unknown>)
93 : null;
94 const headRepositoryOwner =
95 record.headRepositoryOwner &&
96 typeof record.headRepositoryOwner === "object" &&
97 !Array.isArray(record.headRepositoryOwner)
98 ? (record.headRepositoryOwner as Record<string, unknown>)
99 : null;
100 const headRepositoryNameWithOwner =
101 typeof headRepository?.nameWithOwner === "string" ? headRepository.nameWithOwner : null;
102 const headRepositoryOwnerLogin =
103 typeof headRepositoryOwner?.login === "string"
104 ? headRepositoryOwner.login
105 : typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/")
106 ? headRepositoryNameWithOwner.split("/")[0] ?? null
107 : null;
88 if (108 if (
89 typeof number !== "number" ||109 typeof number !== "number" ||
90 !Number.isInteger(number) ||110 !Number.isInteger(number) ||
...@@ -103,6 +123,34 @@ function parsePullRequestSummary(...@@ -103,6 +123,34 @@ function parsePullRequestSummary(
103 baseRefName,123 baseRefName,
104 headRefName,124 headRefName,
105 state: normalizePullRequestState(record),125 state: normalizePullRequestState(record),
126 ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}),
127 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
128 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
129 };
130}
131
132function parseRepositoryCloneUrls(raw: unknown): GitHubRepositoryCloneUrls | null {
133 if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
134 return null;
135 }
136 const record = raw as Record<string, unknown>;
137 const nameWithOwner = record.nameWithOwner;
138 const url = record.url;
139 const sshUrl = record.sshUrl;
140 if (
141 typeof nameWithOwner !== "string" ||
142 typeof url !== "string" ||
143 typeof sshUrl !== "string" ||
144 nameWithOwner.trim().length === 0 ||
145 url.trim().length === 0 ||
146 sshUrl.trim().length === 0
147 ) {
148 return null;
149 }
150 return {
151 nameWithOwner,
152 url,
153 sshUrl,
106 };154 };
107}155}
108156
...@@ -179,7 +227,7 @@ const makeGitHubCli = Effect.sync(() => {...@@ -179,7 +227,7 @@ const makeGitHubCli = Effect.sync(() => {
179 "view",227 "view",
180 input.reference,228 input.reference,
181 "--json",229 "--json",
182 "number,title,url,baseRefName,headRefName,state,mergedAt",230 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
183 ],231 ],
184 }).pipe(232 }).pipe(
185 Effect.map((result) => result.stdout.trim()),233 Effect.map((result) => result.stdout.trim()),
...@@ -205,6 +253,40 @@ const makeGitHubCli = Effect.sync(() => {...@@ -205,6 +253,40 @@ const makeGitHubCli = Effect.sync(() => {
205 }),253 }),
206 ),254 ),
207 ),255 ),
256 getRepositoryCloneUrls: (input) =>
257 execute({
258 cwd: input.cwd,
259 args: [
260 "repo",
261 "view",
262 input.repository,
263 "--json",
264 "nameWithOwner,url,sshUrl",
265 ],
266 }).pipe(
267 Effect.map((result) => result.stdout.trim()),
268 Effect.flatMap((raw) =>
269 Effect.try({
270 try: () => {
271 const parsed = raw.length > 0 ? JSON.parse(raw) : null;
272 const repository = parseRepositoryCloneUrls(parsed);
273 if (!repository) {
274 throw new Error("GitHub CLI returned invalid repository clone URL JSON.");
275 }
276 return repository;
277 },
278 catch: (error: unknown) =>
279 new GitHubCliError({
280 operation: "getRepositoryCloneUrls",
281 detail:
282 error instanceof Error
283 ? `GitHub CLI returned invalid repository JSON: ${error.message}`
284 : "GitHub CLI returned invalid repository JSON.",
285 ...(error !== undefined ? { cause: error } : {}),
286 }),
287 }),
288 ),
289 ),
208 createPullRequest: (input) =>290 createPullRequest: (input) =>
209 execute({291 execute({
210 cwd: input.cwd,292 cwd: input.cwd,
apps/server/src/git/Layers/GitManager.test.ts+119-11
...@@ -30,7 +30,11 @@ interface FakeGhScenario {...@@ -30,7 +30,11 @@ interface FakeGhScenario {
30 baseRefName: string;30 baseRefName: string;
31 headRefName: string;31 headRefName: string;
32 state?: "open" | "closed" | "merged";32 state?: "open" | "closed" | "merged";
33 isCrossRepository?: boolean;
34 headRepositoryNameWithOwner?: string | null;
35 headRepositoryOwnerLogin?: string | null;
33 };36 };
37 repositoryCloneUrls?: Record<string, { url: string; sshUrl: string }>;
34 failWith?: GitHubCliError;38 failWith?: GitHubCliError;
35}39}
3640
...@@ -59,6 +63,8 @@ interface FakeGitTextGeneration {...@@ -59,6 +63,8 @@ interface FakeGitTextGeneration {
59 }) => Effect.Effect<{ branch: string }, TextGenerationError>;63 }) => Effect.Effect<{ branch: string }, TextGenerationError>;
60}64}
6165
66type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;
67
62function makeTempDir(68function makeTempDir(
63 prefix: string,69 prefix: string,
64): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> {70): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> {
...@@ -211,18 +217,33 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -211,18 +217,33 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
211 }217 }
212218
213 if (args[0] === "pr" && args[1] === "view") {219 if (args[0] === "pr" && args[1] === "view") {
220 const pullRequest: FakePullRequest = scenario.pullRequest ?? {
221 number: 101,
222 title: "Pull request",
223 url: "https://github.com/pingdotgg/codething-mvp/pull/101",
224 baseRefName: "main",
225 headRefName: "feature/pull-request",
226 state: "open",
227 };
214 return Effect.succeed({228 return Effect.succeed({
215 stdout:229 stdout:
216 JSON.stringify(230 JSON.stringify({
217 scenario.pullRequest ?? {231 ...pullRequest,
218 number: 101,232 ...(pullRequest.headRepositoryNameWithOwner
219 title: "Pull request",233 ? {
220 url: "https://github.com/pingdotgg/codething-mvp/pull/101",234 headRepository: {
221 baseRefName: "main",235 nameWithOwner: pullRequest.headRepositoryNameWithOwner,
222 headRefName: "feature/pull-request",236 },
223 state: "OPEN",237 }
224 },238 : {}),
225 ) + "\n",239 ...(pullRequest.headRepositoryOwnerLogin
240 ? {
241 headRepositoryOwner: {
242 login: pullRequest.headRepositoryOwnerLogin,
243 },
244 }
245 : {}),
246 }) + "\n",
226 stderr: "",247 stderr: "",
227 code: 0,248 code: 0,
228 signal: null,249 signal: null,
...@@ -241,6 +262,30 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -241,6 +262,30 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
241 }262 }
242263
243 if (args[0] === "repo" && args[1] === "view") {264 if (args[0] === "repo" && args[1] === "view") {
265 const repository = args[2];
266 if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) {
267 const cloneUrls = scenario.repositoryCloneUrls?.[repository];
268 if (!cloneUrls) {
269 return Effect.fail(
270 new GitHubCliError({
271 operation: "execute",
272 detail: `Unexpected repository lookup: ${repository}`,
273 }),
274 );
275 }
276 return Effect.succeed({
277 stdout:
278 JSON.stringify({
279 nameWithOwner: repository,
280 url: cloneUrls.url,
281 sshUrl: cloneUrls.sshUrl,
282 }) + "\n",
283 stderr: "",
284 code: 0,
285 signal: null,
286 timedOut: false,
287 });
288 }
244 return Effect.succeed({289 return Effect.succeed({
245 stdout: `${scenario.defaultBranch ?? "main"}\n`,290 stdout: `${scenario.defaultBranch ?? "main"}\n`,
246 stderr: "",291 stderr: "",
...@@ -315,11 +360,16 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -315,11 +360,16 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
315 "view",360 "view",
316 input.reference,361 input.reference,
317 "--json",362 "--json",
318 "number,title,url,baseRefName,headRefName,state,mergedAt",363 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
319 ],364 ],
320 }).pipe(365 }).pipe(
321 Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary),366 Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary),
322 ),367 ),
368 getRepositoryCloneUrls: (input) =>
369 execute({
370 cwd: input.cwd,
371 args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"],
372 }).pipe(Effect.map((result) => JSON.parse(result.stdout))),
323 checkoutPullRequest: (input) =>373 checkoutPullRequest: (input) =>
324 execute({374 execute({
325 cwd: input.cwd,375 cwd: input.cwd,
...@@ -1102,6 +1152,64 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1102,6 +1152,64 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1102 }),1152 }),
1103 );1153 );
11041154
1155 it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () =>
1156 Effect.gen(function* () {
1157 const repoDir = yield* makeTempDir("t3code-git-manager-");
1158 yield* initRepo(repoDir);
1159 const originDir = yield* createBareRemote();
1160 const forkDir = yield* createBareRemote();
1161 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1162 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1163 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1164 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-fork"]);
1165 fs.writeFileSync(path.join(repoDir, "fork.txt"), "fork\n");
1166 yield* runGit(repoDir, ["add", "fork.txt"]);
1167 yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]);
1168 yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-fork"]);
1169 yield* runGit(repoDir, ["checkout", "main"]);
1170
1171 const { manager } = yield* makeManager({
1172 ghScenario: {
1173 pullRequest: {
1174 number: 81,
1175 title: "Fork PR",
1176 url: "https://github.com/pingdotgg/codething-mvp/pull/81",
1177 baseRefName: "main",
1178 headRefName: "feature/pr-fork",
1179 state: "open",
1180 isCrossRepository: true,
1181 headRepositoryNameWithOwner: "octocat/codething-mvp",
1182 headRepositoryOwnerLogin: "octocat",
1183 },
1184 repositoryCloneUrls: {
1185 "octocat/codething-mvp": {
1186 url: forkDir,
1187 sshUrl: forkDir,
1188 },
1189 },
1190 },
1191 });
1192
1193 const result = yield* preparePullRequestThread(manager, {
1194 cwd: repoDir,
1195 reference: "81",
1196 mode: "worktree",
1197 });
1198
1199 expect(result.worktreePath).not.toBeNull();
1200 const upstreamRef = (
1201 yield* runGit(result.worktreePath as string, ["rev-parse", "--abbrev-ref", "@{upstream}"])
1202 ).stdout.trim();
1203 expect(upstreamRef).toBe("fork-seed/feature/pr-fork");
1204 expect(upstreamRef.startsWith("origin/")).toBe(false);
1205 expect(
1206 (yield* runGit(result.worktreePath as string, ["config", "--get", "remote.fork-seed.url"]))
1207 .stdout
1208 .trim(),
1209 ).toBe(forkDir);
1210 }),
1211 );
1212
1105 it.effect("reuses an existing dedicated worktree for the PR head branch", () =>1213 it.effect("reuses an existing dedicated worktree for the PR head branch", () =>
1106 Effect.gen(function* () {1214 Effect.gen(function* () {
1107 const repoDir = yield* makeTempDir("t3code-git-manager-");1215 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/Layers/GitManager.ts+132-7
...@@ -32,6 +32,12 @@ interface ResolvedPullRequest {...@@ -32,6 +32,12 @@ interface ResolvedPullRequest {
32 state: "open" | "closed" | "merged";32 state: "open" | "closed" | "merged";
33}33}
3434
35interface PullRequestHeadRemoteInfo {
36 isCrossRepository?: boolean;
37 headRepositoryNameWithOwner?: string | null;
38 headRepositoryOwnerLogin?: string | null;
39}
40
35function parsePullRequestList(raw: unknown): PullRequestInfo[] {41function parsePullRequestList(raw: unknown): PullRequestInfo[] {
36 if (!Array.isArray(raw)) return [];42 if (!Array.isArray(raw)) return [];
3743
...@@ -217,10 +223,126 @@ function toResolvedPullRequest(pr: {...@@ -217,10 +223,126 @@ function toResolvedPullRequest(pr: {
217 };223 };
218}224}
219225
226function shouldPreferSshRemote(url: string | null): boolean {
227 if (!url) return false;
228 const trimmed = url.trim();
229 return trimmed.startsWith("git@") || trimmed.startsWith("ssh://");
230}
231
232function toPullRequestHeadRemoteInfo(pr: {
233 isCrossRepository?: boolean;
234 headRepositoryNameWithOwner?: string | null;
235 headRepositoryOwnerLogin?: string | null;
236}): PullRequestHeadRemoteInfo {
237 return {
238 ...(pr.isCrossRepository !== undefined ? { isCrossRepository: pr.isCrossRepository } : {}),
239 ...(pr.headRepositoryNameWithOwner !== undefined
240 ? { headRepositoryNameWithOwner: pr.headRepositoryNameWithOwner }
241 : {}),
242 ...(pr.headRepositoryOwnerLogin !== undefined
243 ? { headRepositoryOwnerLogin: pr.headRepositoryOwnerLogin }
244 : {}),
245 };
246}
247
220export const makeGitManager = Effect.gen(function* () {248export const makeGitManager = Effect.gen(function* () {
221 const gitCore = yield* GitCore;249 const gitCore = yield* GitCore;
222 const gitHubCli = yield* GitHubCli;250 const gitHubCli = yield* GitHubCli;
223 const textGeneration = yield* TextGeneration;251 const textGeneration = yield* TextGeneration;
252
253 const configurePullRequestHeadUpstream = (
254 cwd: string,
255 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
256 ) =>
257 Effect.gen(function* () {
258 const repositoryNameWithOwner = pullRequest.headRepositoryNameWithOwner?.trim() ?? "";
259 if (repositoryNameWithOwner.length === 0) {
260 return;
261 }
262
263 const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({
264 cwd,
265 repository: repositoryNameWithOwner,
266 });
267 const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url");
268 const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url;
269 const preferredRemoteName =
270 pullRequest.headRepositoryOwnerLogin?.trim() ||
271 repositoryNameWithOwner.split("/")[0]?.trim() ||
272 "fork";
273 const remoteName = yield* gitCore.ensureRemote({
274 cwd,
275 preferredName: preferredRemoteName,
276 url: remoteUrl,
277 });
278
279 yield* gitCore.setBranchUpstream({
280 cwd,
281 branch: pullRequest.headBranch,
282 remoteName,
283 remoteBranch: pullRequest.headBranch,
284 });
285 }).pipe(
286 Effect.catch((error) =>
287 Effect.logWarning(
288 `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${pullRequest.headBranch} in ${cwd}: ${error.message}`,
289 ).pipe(Effect.asVoid),
290 ),
291 );
292
293 const materializePullRequestHeadBranch = (
294 cwd: string,
295 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
296 ) =>
297 Effect.gen(function* () {
298 const repositoryNameWithOwner = pullRequest.headRepositoryNameWithOwner?.trim() ?? "";
299
300 if (repositoryNameWithOwner.length === 0) {
301 yield* gitCore.fetchPullRequestBranch({
302 cwd,
303 prNumber: pullRequest.number,
304 branch: pullRequest.headBranch,
305 });
306 return;
307 }
308
309 const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({
310 cwd,
311 repository: repositoryNameWithOwner,
312 });
313 const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url");
314 const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url;
315 const preferredRemoteName =
316 pullRequest.headRepositoryOwnerLogin?.trim() ||
317 repositoryNameWithOwner.split("/")[0]?.trim() ||
318 "fork";
319 const remoteName = yield* gitCore.ensureRemote({
320 cwd,
321 preferredName: preferredRemoteName,
322 url: remoteUrl,
323 });
324
325 yield* gitCore.fetchRemoteBranch({
326 cwd,
327 remoteName,
328 remoteBranch: pullRequest.headBranch,
329 localBranch: pullRequest.headBranch,
330 });
331 yield* gitCore.setBranchUpstream({
332 cwd,
333 branch: pullRequest.headBranch,
334 remoteName,
335 remoteBranch: pullRequest.headBranch,
336 });
337 }).pipe(
338 Effect.catch(() =>
339 gitCore.fetchPullRequestBranch({
340 cwd,
341 prNumber: pullRequest.number,
342 branch: pullRequest.headBranch,
343 }),
344 ),
345 );
224 const fileSystem = yield* FileSystem.FileSystem;346 const fileSystem = yield* FileSystem.FileSystem;
225 const path = yield* Path.Path;347 const path = yield* Path.Path;
226348
...@@ -504,12 +626,12 @@ export const makeGitManager = Effect.gen(function* () {...@@ -504,12 +626,12 @@ export const makeGitManager = Effect.gen(function* () {
504 function* (input) {626 function* (input) {
505 const normalizedReference = normalizePullRequestReference(input.reference);627 const normalizedReference = normalizePullRequestReference(input.reference);
506 const rootWorktreePath = canonicalizeExistingPath(input.cwd);628 const rootWorktreePath = canonicalizeExistingPath(input.cwd);
507 const pullRequest = yield* gitHubCli629 const pullRequestSummary = yield* gitHubCli
508 .getPullRequest({630 .getPullRequest({
509 cwd: input.cwd,631 cwd: input.cwd,
510 reference: normalizedReference,632 reference: normalizedReference,
511 })633 });
512 .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved)));634 const pullRequest = toResolvedPullRequest(pullRequestSummary);
513635
514 if (input.mode === "local") {636 if (input.mode === "local") {
515 yield* gitHubCli.checkoutPullRequest({637 yield* gitHubCli.checkoutPullRequest({
...@@ -517,6 +639,10 @@ export const makeGitManager = Effect.gen(function* () {...@@ -517,6 +639,10 @@ export const makeGitManager = Effect.gen(function* () {
517 reference: normalizedReference,639 reference: normalizedReference,
518 force: true,640 force: true,
519 });641 });
642 yield* configurePullRequestHeadUpstream(input.cwd, {
643 ...pullRequest,
644 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
645 });
520 const details = yield* gitCore.statusDetails(input.cwd);646 const details = yield* gitCore.statusDetails(input.cwd);
521 return {647 return {
522 pullRequest,648 pullRequest,
...@@ -555,10 +681,9 @@ export const makeGitManager = Effect.gen(function* () {...@@ -555,10 +681,9 @@ export const makeGitManager = Effect.gen(function* () {
555 );681 );
556 }682 }
557683
558 yield* gitCore.fetchPullRequestBranch({684 yield* materializePullRequestHeadBranch(input.cwd, {
559 cwd: input.cwd,685 ...pullRequest,
560 prNumber: pullRequest.number,686 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
561 branch: pullRequest.headBranch,
562 });687 });
563688
564 const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd);689 const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd);
apps/server/src/git/Services/GitCore.ts+41
...@@ -62,6 +62,26 @@ export interface GitFetchPullRequestBranchInput {...@@ -62,6 +62,26 @@ export interface GitFetchPullRequestBranchInput {
62 branch: string;62 branch: string;
63}63}
6464
65export interface GitEnsureRemoteInput {
66 cwd: string;
67 preferredName: string;
68 url: string;
69}
70
71export interface GitFetchRemoteBranchInput {
72 cwd: string;
73 remoteName: string;
74 remoteBranch: string;
75 localBranch: string;
76}
77
78export interface GitSetBranchUpstreamInput {
79 cwd: string;
80 branch: string;
81 remoteName: string;
82 remoteBranch: string;
83}
84
65/**85/**
66 * GitCoreShape - Service API for low-level Git repository interactions.86 * GitCoreShape - Service API for low-level Git repository interactions.
67 */87 */
...@@ -142,6 +162,27 @@ export interface GitCoreShape {...@@ -142,6 +162,27 @@ export interface GitCoreShape {
142 input: GitFetchPullRequestBranchInput,162 input: GitFetchPullRequestBranchInput,
143 ) => Effect.Effect<void, GitCommandError>;163 ) => Effect.Effect<void, GitCommandError>;
144164
165 /**
166 * Ensure a named remote exists for the provided URL, returning the reused or created remote name.
167 */
168 readonly ensureRemote: (
169 input: GitEnsureRemoteInput,
170 ) => Effect.Effect<string, GitCommandError>;
171
172 /**
173 * Fetch a remote branch into a local branch without checkout.
174 */
175 readonly fetchRemoteBranch: (
176 input: GitFetchRemoteBranchInput,
177 ) => Effect.Effect<void, GitCommandError>;
178
179 /**
180 * Set the upstream tracking branch for a local branch.
181 */
182 readonly setBranchUpstream: (
183 input: GitSetBranchUpstreamInput,
184 ) => Effect.Effect<void, GitCommandError>;
185
145 /**186 /**
146 * Remove an existing worktree.187 * Remove an existing worktree.
147 */188 */
apps/server/src/git/Services/GitHubCli.ts+17
...@@ -18,6 +18,15 @@ export interface GitHubPullRequestSummary {...@@ -18,6 +18,15 @@ export interface GitHubPullRequestSummary {
18 readonly baseRefName: string;18 readonly baseRefName: string;
19 readonly headRefName: string;19 readonly headRefName: string;
20 readonly state?: "open" | "closed" | "merged";20 readonly state?: "open" | "closed" | "merged";
21 readonly isCrossRepository?: boolean;
22 readonly headRepositoryNameWithOwner?: string | null;
23 readonly headRepositoryOwnerLogin?: string | null;
24}
25
26export interface GitHubRepositoryCloneUrls {
27 readonly nameWithOwner: string;
28 readonly url: string;
29 readonly sshUrl: string;
21}30}
2231
23/**32/**
...@@ -50,6 +59,14 @@ export interface GitHubCliShape {...@@ -50,6 +59,14 @@ export interface GitHubCliShape {
50 readonly reference: string;59 readonly reference: string;
51 }) => Effect.Effect<GitHubPullRequestSummary, GitHubCliError>;60 }) => Effect.Effect<GitHubPullRequestSummary, GitHubCliError>;
5261
62 /**
63 * Resolve clone URLs for a GitHub repository.
64 */
65 readonly getRepositoryCloneUrls: (input: {
66 readonly cwd: string;
67 readonly repository: string;
68 }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;
69
53 /**70 /**
54 * Create a pull request from branch context and body file.71 * Create a pull request from branch context and body file.
55 */72 */