| author | |
| committer | |
| log | b36888e9cc3692f931081a4d09bd58438d5f06eb |
| tree | 3bcb418e640a59882e106610868644558d926457 |
| parent | 9bb9023009b0c45c66a609c386cd233855043caf |
- 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 remotes8 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 | } |
| 102 | 102 | ||
| 103 | function 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 | |||
| 108 | function normalizeRemoteUrl(value: string): string { | ||
| 109 | return value.trim().replace(/\.git$/i, "").replace(/\/+$/g, "").toLowerCase(); | ||
| 110 | } | ||
| 111 | |||
| 112 | function 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 | |||
| 103 | function parseRemoteRefWithRemoteNames( | 128 | function 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 | }); |
| 443 | 468 | ||
| 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); |
| 1086 | 1137 | ||
| 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 | }); |
| 56 | 72 | ||
| 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"; |
| 4 | import { GitHubCliError } from "../Errors.ts"; | 4 | import { GitHubCliError } from "../Errors.ts"; |
| 5 | import { | 5 | import { |
| 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 | |||
| 132 | function 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 | } |
| 108 | 156 | ||
| ... | @@ -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 | } |
| 36 | 40 | ||
| ... | @@ -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 | } |
| 61 | 65 | ||
| 66 | type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>; | ||
| 67 | |||
| 62 | function makeTempDir( | 68 | function 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 | } |
| 212 | 218 | ||
| 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 | } |
| 242 | 263 | ||
| 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 | ); |
| 1104 | 1154 | ||
| 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 | } |
| 34 | 34 | ||
| 35 | interface PullRequestHeadRemoteInfo { | ||
| 36 | isCrossRepository?: boolean; | ||
| 37 | headRepositoryNameWithOwner?: string | null; | ||
| 38 | headRepositoryOwnerLogin?: string | null; | ||
| 39 | } | ||
| 40 | |||
| 35 | function parsePullRequestList(raw: unknown): PullRequestInfo[] { | 41 | function parsePullRequestList(raw: unknown): PullRequestInfo[] { |
| 36 | if (!Array.isArray(raw)) return []; | 42 | if (!Array.isArray(raw)) return []; |
| 37 | 43 | ||
| ... | @@ -217,10 +223,126 @@ function toResolvedPullRequest(pr: { | ... | @@ -217,10 +223,126 @@ function toResolvedPullRequest(pr: { |
| 217 | }; | 223 | }; |
| 218 | } | 224 | } |
| 219 | 225 | ||
| 226 | function 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 | |||
| 232 | function 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 | |||
| 220 | export const makeGitManager = Effect.gen(function* () { | 248 | export 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; |
| 226 | 348 | ||
| ... | @@ -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* gitHubCli | 629 | 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); |
| 513 | 635 | ||
| 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 | } |
| 557 | 683 | ||
| 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 | }); |
| 563 | 688 | ||
| 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 | } |
| 64 | 64 | ||
| 65 | export interface GitEnsureRemoteInput { | ||
| 66 | cwd: string; | ||
| 67 | preferredName: string; | ||
| 68 | url: string; | ||
| 69 | } | ||
| 70 | |||
| 71 | export interface GitFetchRemoteBranchInput { | ||
| 72 | cwd: string; | ||
| 73 | remoteName: string; | ||
| 74 | remoteBranch: string; | ||
| 75 | localBranch: string; | ||
| 76 | } | ||
| 77 | |||
| 78 | export 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>; |
| 144 | 164 | ||
| 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 | |||
| 26 | export interface GitHubRepositoryCloneUrls { | ||
| 27 | readonly nameWithOwner: string; | ||
| 28 | readonly url: string; | ||
| 29 | readonly sshUrl: string; | ||
| 21 | } | 30 | } |
| 22 | 31 | ||
| 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>; |
| 52 | 61 | ||
| 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 | */ |