| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import fs from "node:fs"; | 1 | import fs from "node:fs"; |
| 2 | import path from "node:path"; | 2 | import path from "node:path"; |
| | 3 | import { spawnSync } from "node:child_process"; |
| 3 | | 4 | |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import { it } from "@effect/vitest"; | 6 | import { it } from "@effect/vitest"; |
| ... | @@ -65,6 +66,29 @@ interface FakeGitTextGeneration { | ... | @@ -65,6 +66,29 @@ interface FakeGitTextGeneration { |
| 65 | | 66 | |
| 66 | type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>; | 67 | type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>; |
| 67 | | 68 | |
| | 69 | function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { |
| | 70 | const result = spawnSync("git", args, { |
| | 71 | cwd, |
| | 72 | encoding: "utf8", |
| | 73 | }); |
| | 74 | if (result.status === 0) { |
| | 75 | return; |
| | 76 | } |
| | 77 | throw new GitHubCliError({ |
| | 78 | operation: "execute", |
| | 79 | detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, |
| | 80 | }); |
| | 81 | } |
| | 82 | |
| | 83 | function isGitHubCliError(error: unknown): error is GitHubCliError { |
| | 84 | return ( |
| | 85 | typeof error === "object" && |
| | 86 | error !== null && |
| | 87 | "_tag" in error && |
| | 88 | (error as { _tag?: unknown })._tag === "GitHubCliError" |
| | 89 | ); |
| | 90 | } |
| | 91 | |
| 68 | function makeTempDir( | 92 | function makeTempDir( |
| 69 | prefix: string, | 93 | prefix: string, |
| 70 | ): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> { | 94 | ): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> { |
| ... | @@ -252,12 +276,42 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | ... | @@ -252,12 +276,42 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 252 | } | 276 | } |
| 253 | | 277 | |
| 254 | if (args[0] === "pr" && args[1] === "checkout") { | 278 | if (args[0] === "pr" && args[1] === "checkout") { |
| 255 | return Effect.succeed({ | 279 | return Effect.try({ |
| 256 | stdout: "", | 280 | try: () => { |
| 257 | stderr: "", | 281 | const headBranch = scenario.pullRequest?.headRefName; |
| 258 | code: 0, | 282 | if (headBranch) { |
| 259 | signal: null, | 283 | const existingBranch = spawnSync( |
| 260 | timedOut: false, | 284 | "git", |
| | 285 | ["show-ref", "--verify", "--quiet", `refs/heads/${headBranch}`], |
| | 286 | { |
| | 287 | cwd: input.cwd, |
| | 288 | encoding: "utf8", |
| | 289 | }, |
| | 290 | ); |
| | 291 | if (existingBranch.status === 0) { |
| | 292 | runGitSyncForFakeGh(input.cwd, ["checkout", headBranch]); |
| | 293 | } else { |
| | 294 | runGitSyncForFakeGh(input.cwd, ["checkout", "-b", headBranch]); |
| | 295 | } |
| | 296 | } |
| | 297 | return { |
| | 298 | stdout: "", |
| | 299 | stderr: "", |
| | 300 | code: 0, |
| | 301 | signal: null, |
| | 302 | timedOut: false, |
| | 303 | }; |
| | 304 | }, |
| | 305 | catch: (error) => |
| | 306 | isGitHubCliError(error) |
| | 307 | ? error |
| | 308 | : new GitHubCliError({ |
| | 309 | operation: "execute", |
| | 310 | detail: |
| | 311 | error instanceof Error |
| | 312 | ? `Failed to simulate gh checkout: ${error.message}` |
| | 313 | : "Failed to simulate gh checkout.", |
| | 314 | }), |
| 261 | }); | 315 | }); |
| 262 | } | 316 | } |
| 263 | | 317 | |
| ... | @@ -1210,6 +1264,111 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -1210,6 +1264,111 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1210 | }), | 1264 | }), |
| 1211 | ); | 1265 | ); |
| 1212 | | 1266 | |
| | 1267 | it.effect("preserves fork upstream tracking when preparing a local PR thread", () => |
| | 1268 | Effect.gen(function* () { |
| | 1269 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| | 1270 | yield* initRepo(repoDir); |
| | 1271 | const originDir = yield* createBareRemote(); |
| | 1272 | const forkDir = yield* createBareRemote(); |
| | 1273 | yield* runGit(repoDir, ["remote", "add", "origin", originDir]); |
| | 1274 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); |
| | 1275 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); |
| | 1276 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-fork"]); |
| | 1277 | fs.writeFileSync(path.join(repoDir, "local-fork.txt"), "local fork\n"); |
| | 1278 | yield* runGit(repoDir, ["add", "local-fork.txt"]); |
| | 1279 | yield* runGit(repoDir, ["commit", "-m", "Local fork PR branch"]); |
| | 1280 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-local-fork"]); |
| | 1281 | yield* runGit(repoDir, ["checkout", "main"]); |
| | 1282 | yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-fork"]); |
| | 1283 | |
| | 1284 | const { manager } = yield* makeManager({ |
| | 1285 | ghScenario: { |
| | 1286 | pullRequest: { |
| | 1287 | number: 82, |
| | 1288 | title: "Local Fork PR", |
| | 1289 | url: "https://github.com/pingdotgg/codething-mvp/pull/82", |
| | 1290 | baseRefName: "main", |
| | 1291 | headRefName: "feature/pr-local-fork", |
| | 1292 | state: "open", |
| | 1293 | isCrossRepository: true, |
| | 1294 | headRepositoryNameWithOwner: "octocat/codething-mvp", |
| | 1295 | headRepositoryOwnerLogin: "octocat", |
| | 1296 | }, |
| | 1297 | repositoryCloneUrls: { |
| | 1298 | "octocat/codething-mvp": { |
| | 1299 | url: forkDir, |
| | 1300 | sshUrl: forkDir, |
| | 1301 | }, |
| | 1302 | }, |
| | 1303 | }, |
| | 1304 | }); |
| | 1305 | |
| | 1306 | const result = yield* preparePullRequestThread(manager, { |
| | 1307 | cwd: repoDir, |
| | 1308 | reference: "82", |
| | 1309 | mode: "local", |
| | 1310 | }); |
| | 1311 | |
| | 1312 | expect(result.worktreePath).toBeNull(); |
| | 1313 | expect(result.branch).toBe("feature/pr-local-fork"); |
| | 1314 | expect( |
| | 1315 | (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), |
| | 1316 | ).toBe("fork-seed/feature/pr-local-fork"); |
| | 1317 | }), |
| | 1318 | ); |
| | 1319 | |
| | 1320 | it.effect("derives fork repository identity from PR URL when GitHub omits nameWithOwner", () => |
| | 1321 | Effect.gen(function* () { |
| | 1322 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| | 1323 | yield* initRepo(repoDir); |
| | 1324 | const originDir = yield* createBareRemote(); |
| | 1325 | const forkDir = yield* createBareRemote(); |
| | 1326 | yield* runGit(repoDir, ["remote", "add", "origin", originDir]); |
| | 1327 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); |
| | 1328 | yield* runGit(repoDir, ["remote", "add", "binbandit-seed", forkDir]); |
| | 1329 | yield* runGit(repoDir, ["checkout", "-b", "fix/git-action-default-without-origin"]); |
| | 1330 | fs.writeFileSync(path.join(repoDir, "derived-fork.txt"), "derived fork\n"); |
| | 1331 | yield* runGit(repoDir, ["add", "derived-fork.txt"]); |
| | 1332 | yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]); |
| | 1333 | yield* runGit(repoDir, ["push", "-u", "binbandit-seed", "fix/git-action-default-without-origin"]); |
| | 1334 | yield* runGit(repoDir, ["checkout", "main"]); |
| | 1335 | yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]); |
| | 1336 | |
| | 1337 | const { manager } = yield* makeManager({ |
| | 1338 | ghScenario: { |
| | 1339 | pullRequest: { |
| | 1340 | number: 642, |
| | 1341 | title: "fix: use commit as the default git action without origin", |
| | 1342 | url: "https://github.com/pingdotgg/t3code/pull/642", |
| | 1343 | baseRefName: "main", |
| | 1344 | headRefName: "fix/git-action-default-without-origin", |
| | 1345 | state: "open", |
| | 1346 | isCrossRepository: true, |
| | 1347 | headRepositoryOwnerLogin: "binbandit", |
| | 1348 | }, |
| | 1349 | repositoryCloneUrls: { |
| | 1350 | "binbandit/t3code": { |
| | 1351 | url: forkDir, |
| | 1352 | sshUrl: forkDir, |
| | 1353 | }, |
| | 1354 | }, |
| | 1355 | }, |
| | 1356 | }); |
| | 1357 | |
| | 1358 | const result = yield* preparePullRequestThread(manager, { |
| | 1359 | cwd: repoDir, |
| | 1360 | reference: "642", |
| | 1361 | mode: "local", |
| | 1362 | }); |
| | 1363 | |
| | 1364 | expect(result.branch).toBe("fix/git-action-default-without-origin"); |
| | 1365 | expect(result.worktreePath).toBeNull(); |
| | 1366 | expect( |
| | 1367 | (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), |
| | 1368 | ).toBe("binbandit-seed/fix/git-action-default-without-origin"); |
| | 1369 | }), |
| | 1370 | ); |
| | 1371 | |
| 1213 | it.effect("reuses an existing dedicated worktree for the PR head branch", () => | 1372 | it.effect("reuses an existing dedicated worktree for the PR head branch", () => |
| 1214 | Effect.gen(function* () { | 1373 | Effect.gen(function* () { |
| 1215 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | 1374 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| ... | @@ -1248,6 +1407,62 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -1248,6 +1407,62 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1248 | }), | 1407 | }), |
| 1249 | ); | 1408 | ); |
| 1250 | | 1409 | |
| | 1410 | it.effect("reuses an existing PR worktree and restores fork upstream tracking", () => |
| | 1411 | Effect.gen(function* () { |
| | 1412 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| | 1413 | yield* initRepo(repoDir); |
| | 1414 | const originDir = yield* createBareRemote(); |
| | 1415 | const forkDir = yield* createBareRemote(); |
| | 1416 | yield* runGit(repoDir, ["remote", "add", "origin", originDir]); |
| | 1417 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); |
| | 1418 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); |
| | 1419 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-fork"]); |
| | 1420 | fs.writeFileSync(path.join(repoDir, "reused-fork.txt"), "reused fork\n"); |
| | 1421 | yield* runGit(repoDir, ["add", "reused-fork.txt"]); |
| | 1422 | yield* runGit(repoDir, ["commit", "-m", "Reused fork PR branch"]); |
| | 1423 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-reused-fork"]); |
| | 1424 | yield* runGit(repoDir, ["checkout", "main"]); |
| | 1425 | const worktreePath = path.join(repoDir, "..", `pr-reused-fork-${Date.now()}`); |
| | 1426 | yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-fork"]); |
| | 1427 | yield* runGit(worktreePath, ["branch", "--unset-upstream"], true); |
| | 1428 | |
| | 1429 | const { manager } = yield* makeManager({ |
| | 1430 | ghScenario: { |
| | 1431 | pullRequest: { |
| | 1432 | number: 83, |
| | 1433 | title: "Reused Fork PR", |
| | 1434 | url: "https://github.com/pingdotgg/codething-mvp/pull/83", |
| | 1435 | baseRefName: "main", |
| | 1436 | headRefName: "feature/pr-reused-fork", |
| | 1437 | state: "open", |
| | 1438 | isCrossRepository: true, |
| | 1439 | headRepositoryNameWithOwner: "octocat/codething-mvp", |
| | 1440 | headRepositoryOwnerLogin: "octocat", |
| | 1441 | }, |
| | 1442 | repositoryCloneUrls: { |
| | 1443 | "octocat/codething-mvp": { |
| | 1444 | url: forkDir, |
| | 1445 | sshUrl: forkDir, |
| | 1446 | }, |
| | 1447 | }, |
| | 1448 | }, |
| | 1449 | }); |
| | 1450 | |
| | 1451 | const result = yield* preparePullRequestThread(manager, { |
| | 1452 | cwd: repoDir, |
| | 1453 | reference: "83", |
| | 1454 | mode: "worktree", |
| | 1455 | }); |
| | 1456 | |
| | 1457 | expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( |
| | 1458 | fs.realpathSync.native(worktreePath), |
| | 1459 | ); |
| | 1460 | expect( |
| | 1461 | (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), |
| | 1462 | ).toBe("fork-seed/feature/pr-reused-fork"); |
| | 1463 | }), |
| | 1464 | ); |
| | 1465 | |
| 1251 | it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () => | 1466 | it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () => |
| 1252 | Effect.gen(function* () { | 1467 | Effect.gen(function* () { |
| 1253 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | 1468 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |