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) =>
106106 listBranches: (input) => core.listBranches(input),
107107 createWorktree: (input) => core.createWorktree(input),
108108 fetchPullRequestBranch: (input) => core.fetchPullRequestBranch(input),
109 ensureRemote: (input) => core.ensureRemote(input),
110 fetchRemoteBranch: (input) => core.fetchRemoteBranch(input),
111 setBranchUpstream: (input) => core.setBranchUpstream(input),
109112 removeWorktree: (input) => core.removeWorktree(input),
110113 renameBranch: (input) => core.renameBranch(input),
111114 createBranch: (input) => core.createBranch(input),
apps/server/src/git/Layers/GitCore.ts+83
......@@ -100,6 +100,31 @@ function parseRemoteNames(stdout: string): ReadonlyArray<string> {
100100 .toSorted((a, b) => b.length - a.length);
101101}
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
103128function parseRemoteRefWithRemoteNames(
104129 branchName: string,
105130 remoteNames: ReadonlyArray<string>,
......@@ -441,6 +466,32 @@ const makeGitCore = Effect.gen(function* () {
441466 );
442467 });
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
444495 const resolveBaseBranchForNoUpstream = (
445496 cwd: string,
446497 branch: string,
......@@ -1084,6 +1135,35 @@ const makeGitCore = Effect.gen(function* () {
10841135 );
10851136 }).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
10871167 const removeWorktree: GitCoreShape["removeWorktree"] = (input) =>
10881168 Effect.gen(function* () {
10891169 const args = ["worktree", "remove"];
......@@ -1244,6 +1324,9 @@ const makeGitCore = Effect.gen(function* () {
12441324 listBranches,
12451325 createWorktree,
12461326 fetchPullRequestBranch,
1327 ensureRemote,
1328 fetchRemoteBranch,
1329 setBranchUpstream,
12471330 removeWorktree,
12481331 renameBranch,
12491332 createBranch,
apps/server/src/git/Layers/GitHubCli.test.ts+47-1
......@@ -22,6 +22,13 @@ describe("GitHubCliLive", () => {
2222 headRefName: "feature/pr-threads",
2323 state: "OPEN",
2424 mergedAt: null,
25 isCrossRepository: true,
26 headRepository: {
27 nameWithOwner: "octocat/codething-mvp",
28 },
29 headRepositoryOwner: {
30 login: "octocat",
31 },
2532 }),
2633 stderr: "",
2734 code: 0,
......@@ -46,14 +53,53 @@ describe("GitHubCliLive", () => {
4653 baseRefName: "main",
4754 headRefName: "feature/pr-threads",
4855 state: "open",
56 isCrossRepository: true,
57 headRepositoryNameWithOwner: "octocat/codething-mvp",
58 headRepositoryOwnerLogin: "octocat",
4959 });
5060 expect(mockedRunProcess).toHaveBeenCalledWith(
5161 "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 ],
5369 expect.objectContaining({ cwd: "/repo" }),
5470 );
5571 });
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
57103 it("surfaces a friendly error when the pull request is not found", async () => {
58104 mockedRunProcess.mockRejectedValueOnce(
59105 new Error(
apps/server/src/git/Layers/GitHubCli.ts+83-1
......@@ -4,6 +4,7 @@ import { runProcess } from "../../processRunner";
44import { GitHubCliError } from "../Errors.ts";
55import {
66 GitHubCli,
7 type GitHubRepositoryCloneUrls,
78 type GitHubCliShape,
89 type GitHubPullRequestSummary,
910} from "../Services/GitHubCli.ts";
......@@ -85,6 +86,25 @@ function parsePullRequestSummary(
8586 const url = record.url;
8687 const baseRefName = record.baseRefName;
8788 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;
88108 if (
89109 typeof number !== "number" ||
90110 !Number.isInteger(number) ||
......@@ -103,6 +123,34 @@ function parsePullRequestSummary(
103123 baseRefName,
104124 headRefName,
105125 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,
106154 };
107155}
108156
......@@ -179,7 +227,7 @@ const makeGitHubCli = Effect.sync(() => {
179227 "view",
180228 input.reference,
181229 "--json",
182 "number,title,url,baseRefName,headRefName,state,mergedAt",
230 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
183231 ],
184232 }).pipe(
185233 Effect.map((result) => result.stdout.trim()),
......@@ -205,6 +253,40 @@ const makeGitHubCli = Effect.sync(() => {
205253 }),
206254 ),
207255 ),
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 ),
208290 createPullRequest: (input) =>
209291 execute({
210292 cwd: input.cwd,
apps/server/src/git/Layers/GitManager.test.ts+119-11
......@@ -30,7 +30,11 @@ interface FakeGhScenario {
3030 baseRefName: string;
3131 headRefName: string;
3232 state?: "open" | "closed" | "merged";
33 isCrossRepository?: boolean;
34 headRepositoryNameWithOwner?: string | null;
35 headRepositoryOwnerLogin?: string | null;
3336 };
37 repositoryCloneUrls?: Record<string, { url: string; sshUrl: string }>;
3438 failWith?: GitHubCliError;
3539}
3640
......@@ -59,6 +63,8 @@ interface FakeGitTextGeneration {
5963 }) => Effect.Effect<{ branch: string }, TextGenerationError>;
6064}
6165
66type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;
67
6268function makeTempDir(
6369 prefix: string,
6470): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> {
......@@ -211,18 +217,33 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
211217 }
212218
213219 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 };
214228 return Effect.succeed({
215229 stdout:
216 JSON.stringify(
217 scenario.pullRequest ?? {
218 number: 101,
219 title: "Pull request",
220 url: "https://github.com/pingdotgg/codething-mvp/pull/101",
221 baseRefName: "main",
222 headRefName: "feature/pull-request",
223 state: "OPEN",
224 },
225 ) + "\n",
230 JSON.stringify({
231 ...pullRequest,
232 ...(pullRequest.headRepositoryNameWithOwner
233 ? {
234 headRepository: {
235 nameWithOwner: pullRequest.headRepositoryNameWithOwner,
236 },
237 }
238 : {}),
239 ...(pullRequest.headRepositoryOwnerLogin
240 ? {
241 headRepositoryOwner: {
242 login: pullRequest.headRepositoryOwnerLogin,
243 },
244 }
245 : {}),
246 }) + "\n",
226247 stderr: "",
227248 code: 0,
228249 signal: null,
......@@ -241,6 +262,30 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
241262 }
242263
243264 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 }
244289 return Effect.succeed({
245290 stdout: `${scenario.defaultBranch ?? "main"}\n`,
246291 stderr: "",
......@@ -315,11 +360,16 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
315360 "view",
316361 input.reference,
317362 "--json",
318 "number,title,url,baseRefName,headRefName,state,mergedAt",
363 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
319364 ],
320365 }).pipe(
321366 Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary),
322367 ),
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))),
323373 checkoutPullRequest: (input) =>
324374 execute({
325375 cwd: input.cwd,
......@@ -1102,6 +1152,64 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
11021152 }),
11031153 );
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
11051213 it.effect("reuses an existing dedicated worktree for the PR head branch", () =>
11061214 Effect.gen(function* () {
11071215 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/Layers/GitManager.ts+132-7
......@@ -32,6 +32,12 @@ interface ResolvedPullRequest {
3232 state: "open" | "closed" | "merged";
3333}
3434
35interface PullRequestHeadRemoteInfo {
36 isCrossRepository?: boolean;
37 headRepositoryNameWithOwner?: string | null;
38 headRepositoryOwnerLogin?: string | null;
39}
40
3541function parsePullRequestList(raw: unknown): PullRequestInfo[] {
3642 if (!Array.isArray(raw)) return [];
3743
......@@ -217,10 +223,126 @@ function toResolvedPullRequest(pr: {
217223 };
218224}
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
220248export const makeGitManager = Effect.gen(function* () {
221249 const gitCore = yield* GitCore;
222250 const gitHubCli = yield* GitHubCli;
223251 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 );
224346 const fileSystem = yield* FileSystem.FileSystem;
225347 const path = yield* Path.Path;
226348
......@@ -504,12 +626,12 @@ export const makeGitManager = Effect.gen(function* () {
504626 function* (input) {
505627 const normalizedReference = normalizePullRequestReference(input.reference);
506628 const rootWorktreePath = canonicalizeExistingPath(input.cwd);
507 const pullRequest = yield* gitHubCli
629 const pullRequestSummary = yield* gitHubCli
508630 .getPullRequest({
509631 cwd: input.cwd,
510632 reference: normalizedReference,
511 })
512 .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved)));
633 });
634 const pullRequest = toResolvedPullRequest(pullRequestSummary);
513635
514636 if (input.mode === "local") {
515637 yield* gitHubCli.checkoutPullRequest({
......@@ -517,6 +639,10 @@ export const makeGitManager = Effect.gen(function* () {
517639 reference: normalizedReference,
518640 force: true,
519641 });
642 yield* configurePullRequestHeadUpstream(input.cwd, {
643 ...pullRequest,
644 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
645 });
520646 const details = yield* gitCore.statusDetails(input.cwd);
521647 return {
522648 pullRequest,
......@@ -555,10 +681,9 @@ export const makeGitManager = Effect.gen(function* () {
555681 );
556682 }
557683
558 yield* gitCore.fetchPullRequestBranch({
559 cwd: input.cwd,
560 prNumber: pullRequest.number,
561 branch: pullRequest.headBranch,
684 yield* materializePullRequestHeadBranch(input.cwd, {
685 ...pullRequest,
686 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
562687 });
563688
564689 const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd);
apps/server/src/git/Services/GitCore.ts+41
......@@ -62,6 +62,26 @@ export interface GitFetchPullRequestBranchInput {
6262 branch: string;
6363}
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
6585/**
6686 * GitCoreShape - Service API for low-level Git repository interactions.
6787 */
......@@ -142,6 +162,27 @@ export interface GitCoreShape {
142162 input: GitFetchPullRequestBranchInput,
143163 ) => 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
145186 /**
146187 * Remove an existing worktree.
147188 */
apps/server/src/git/Services/GitHubCli.ts+17
......@@ -18,6 +18,15 @@ export interface GitHubPullRequestSummary {
1818 readonly baseRefName: string;
1919 readonly headRefName: string;
2020 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;
2130}
2231
2332/**
......@@ -50,6 +59,14 @@ export interface GitHubCliShape {
5059 readonly reference: string;
5160 }) => 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
5370 /**
5471 * Create a pull request from branch context and body file.
5572 */