1import { SourceControlProviderKind } from "@t3tools/contracts";
2// @effect-diagnostics nodeBuiltinImport:off
3import * as NodeFS from "node:fs";
4import * as NodePath from "node:path";
5import * as NodeChildProcess from "node:child_process";
6
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { it } from "@effect/vitest";
9import * as Duration from "effect/Duration";
10import * as Context from "effect/Context";
11import * as Effect from "effect/Effect";
12import * as Deferred from "effect/Deferred";
13import * as Exit from "effect/Exit";
14import * as Fiber from "effect/Fiber";
15import * as FileSystem from "effect/FileSystem";
16import * as Layer from "effect/Layer";
17import * as Logger from "effect/Logger";
18import * as Option from "effect/Option";
19import * as PlatformError from "effect/PlatformError";
20import * as References from "effect/References";
21import * as Result from "effect/Result";
22import * as Schema from "effect/Schema";
23import * as Scope from "effect/Scope";
24import * as Stream from "effect/Stream";
25import { TestClock } from "effect/testing";
26import { ChildProcessSpawner } from "effect/process";
27import { expect } from "vite-plus/test";
28import type {
29 ChangeRequest,
30 MergebotPullRequestDetails,
31 GitActionProgressEvent,
32 GitManagerServiceError,
33 GitPreparePullRequestThreadInput,
34 ModelSelection,
35} from "@t3tools/contracts";
36
37import {
38 DEFAULT_SERVER_SETTINGS,
39 EventId,
40 GitCommandError,
41 ProjectId,
42 ProviderDriverKind,
43 ProviderInstanceId,
44 SourceControlProviderError as SourceControlProviderFailure,
45 TextGenerationError,
46 ThreadId,
47} from "@t3tools/contracts";
48import * as DateTime from "effect/DateTime";
49import { decodeGitHubPullRequestListJson } from "@t3tools/source-control-github/server/gitHubPullRequests";
50import * as GitHubChangeRequestTemplate from "@t3tools/source-control-github/server/gitHubChangeRequestTemplate";
51import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
52import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
53import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
54import * as TextGeneration from "../textGeneration/TextGeneration.ts";
55import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
56import * as VcsProcess from "../vcs/VcsProcess.ts";
57import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
58import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts";
59import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
60import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
61import * as GitWorkflowService from "./GitWorkflowService.ts";
62import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider";
63import * as GitLabSourceControlProvider from "@t3tools/source-control-gitlab/server/GitLabSourceControlProvider";
64import {
65 ForgejoPullRequestSchema,
66 toForgejoChangeRequest,
67} from "@t3tools/source-control-forgejo/server/forgejoPullRequests";
68import type { SourceControlProvider } from "@t3tools/source-control-core/server/SourceControlProvider";
69import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
70import * as ServerConfig from "../config.ts";
71import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
72import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
73import * as SqlitePersistence from "../persistence/Sqlite.ts";
74import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
75import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
76import * as ServerSettings from "../serverSettings.ts";
77import * as MergebotQueue from "../mergebot/MergebotQueue.ts";
78import * as GitManager from "./GitManager.ts";
79
80const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
81const decodeForgejoPullRequest = Schema.decodeEffect(ForgejoPullRequestSchema);
82
83interface FakeGhScenario {
84 prListSequence?: string[];
85 prListByHeadSelector?: Record<string, string>;
86 prListSequenceByHeadSelector?: Record<string, string[]>;
87 createdPrUrl?: string;
88 defaultBranch?: string;
89 pullRequest?: {
90 number: number;
91 title: string;
92 url: string;
93 baseRefName: string;
94 headRefName: string;
95 state?: "open" | "closed" | "merged";
96 isDraft?: boolean;
97 isCrossRepository?: boolean;
98 headRepositoryNameWithOwner?: string | null;
99 headRepositoryOwnerLogin?: string | null;
100 };
101 repositoryCloneUrls?: Record<string, { url: string; sshUrl: string }>;
102 /** The detail the provider fails with, the way a GitHub failure reaches GitManager. */
103 failWith?: string;
104 /** The raw failure behind `failWith`, which must stay out of anything shown or logged. */
105 failCause?: unknown;
106 /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */
107 failAfterCalls?: number;
108}
109
110function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput {
111 return {
112 exitCode: ChildProcessSpawner.ExitCode(0),
113 stdout,
114 stderr: "",
115 stdoutTruncated: false,
116 stderrTruncated: false,
117 };
118}
119
120type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"];
121
122type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;
123
124type FakePullRequestSummary = Omit<ChangeRequest, "provider" | "state" | "updatedAt"> & {
125 readonly state?: ChangeRequest["state"];
126 readonly updatedAt?: string;
127};
128
129function normalizeFakePullRequestSummary(raw: unknown): FakePullRequestSummary | null {
130 if (!raw || typeof raw !== "object") {
131 return null;
132 }
133
134 const record = raw as Record<string, unknown>;
135 const number = record.number;
136 const title = record.title;
137 const url = record.url;
138 const baseRefName = record.baseRefName;
139 const headRefName = record.headRefName;
140 const headRepository =
141 typeof record.headRepository === "object" && record.headRepository !== null
142 ? (record.headRepository as Record<string, unknown>)
143 : null;
144 const headRepositoryOwner =
145 typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null
146 ? (record.headRepositoryOwner as Record<string, unknown>)
147 : null;
148
149 if (
150 typeof number !== "number" ||
151 typeof title !== "string" ||
152 typeof url !== "string" ||
153 typeof baseRefName !== "string" ||
154 typeof headRefName !== "string"
155 ) {
156 return null;
157 }
158
159 const state =
160 typeof record.state === "string"
161 ? record.state === "OPEN" || record.state === "open"
162 ? "open"
163 : record.state === "CLOSED" || record.state === "closed"
164 ? "closed"
165 : "merged"
166 : undefined;
167 const isDraft = typeof record.isDraft === "boolean" ? record.isDraft : undefined;
168 const isCrossRepository =
169 typeof record.isCrossRepository === "boolean" ? record.isCrossRepository : undefined;
170 const headRepositoryNameWithOwner =
171 typeof record.headRepositoryNameWithOwner === "string"
172 ? record.headRepositoryNameWithOwner
173 : typeof headRepository?.nameWithOwner === "string"
174 ? headRepository.nameWithOwner
175 : undefined;
176 const headRepositoryOwnerLogin =
177 typeof record.headRepositoryOwnerLogin === "string"
178 ? record.headRepositoryOwnerLogin
179 : typeof headRepositoryOwner?.login === "string"
180 ? headRepositoryOwner.login
181 : undefined;
182
183 return {
184 number,
185 title,
186 url,
187 baseRefName,
188 headRefName,
189 ...(state ? { state } : {}),
190 ...(isDraft === true ? { isDraft: true } : {}),
191 ...(isCrossRepository !== undefined ? { isCrossRepository } : {}),
192 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
193 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
194 };
195}
196
197function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void {
198 const result = NodeChildProcess.spawnSync("git", args, {
199 cwd,
200 encoding: "utf8",
201 });
202 if (result.status === 0) {
203 return;
204 }
205 throw new Error(
206 `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`,
207 );
208}
209
210function makeTempDir(
211 prefix: string,
212): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> {
213 return Effect.gen(function* () {
214 const fileSystem = yield* FileSystem.FileSystem;
215 return yield* fileSystem.makeTempDirectoryScoped({ prefix });
216 });
217}
218
219function removePath(
220 targetPath: string,
221): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
222 return Effect.gen(function* () {
223 const fileSystem = yield* FileSystem.FileSystem;
224 yield* fileSystem.remove(targetPath, { recursive: true, force: true });
225 });
226}
227
228function makeDirectory(
229 dirPath: string,
230): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
231 return Effect.gen(function* () {
232 const fileSystem = yield* FileSystem.FileSystem;
233 yield* fileSystem.makeDirectory(dirPath, { recursive: true });
234 });
235}
236
237function runGit(
238 cwd: string,
239 args: readonly string[],
240 allowNonZeroExit = false,
241): Effect.Effect<
242 {
243 readonly exitCode: GitVcsDriver.ExecuteGitResult["exitCode"];
244 readonly stdout: string;
245 readonly stderr: string;
246 },
247 GitCommandError,
248 GitVcsDriver.GitVcsDriver
249> {
250 return Effect.gen(function* () {
251 const git = yield* GitVcsDriver.GitVcsDriver;
252 const result = yield* git.execute({
253 operation: "GitManager.test.runGit",
254 cwd,
255 args,
256 allowNonZeroExit,
257 });
258 return {
259 exitCode: result.exitCode,
260 stdout: result.stdout,
261 stderr: result.stderr,
262 };
263 });
264}
265
266function initRepo(
267 cwd: string,
268): Effect.Effect<
269 void,
270 PlatformError.PlatformError | GitCommandError,
271 FileSystem.FileSystem | Scope.Scope | GitVcsDriver.GitVcsDriver
272> {
273 return Effect.gen(function* () {
274 const fs = yield* FileSystem.FileSystem;
275 yield* runGit(cwd, ["init", "--initial-branch=main"]);
276 yield* runGit(cwd, ["config", "user.email", "test@example.com"]);
277 yield* runGit(cwd, ["config", "user.name", "Test User"]);
278 // Hermetic line endings: machine-global `core.autocrlf=true` on Windows
279 // makes Git check out CRLF, which breaks exact-content assertions that
280 // write LF with Node. Local config overrides the global so the fixture
281 // (and its linked worktrees, which share config) always checks out LF.
282 yield* runGit(cwd, ["config", "core.autocrlf", "false"]);
283 yield* fs.writeFileString(NodePath.join(cwd, "README.md"), "hello\n");
284 yield* runGit(cwd, ["add", "README.md"]);
285 yield* runGit(cwd, ["commit", "-m", "Initial commit"]);
286 });
287}
288
289function createBareRemote(): Effect.Effect<
290 string,
291 PlatformError.PlatformError | GitCommandError,
292 FileSystem.FileSystem | Scope.Scope | GitVcsDriver.GitVcsDriver
293> {
294 return Effect.gen(function* () {
295 const remoteDir = yield* makeTempDir("t3code-git-remote-");
296 yield* runGit(remoteDir, ["init", "--bare"]);
297 return remoteDir;
298 });
299}
300
301function configureRemote(
302 cwd: string,
303 remoteName: string,
304 remotePath: string,
305 fetchNamespace: string,
306): Effect.Effect<void, GitCommandError, GitVcsDriver.GitVcsDriver> {
307 return Effect.gen(function* () {
308 yield* runGit(cwd, ["config", `remote.${remoteName}.url`, remotePath]);
309 yield* runGit(cwd, [
310 "config",
311 "--replace-all",
312 `remote.${remoteName}.fetch`,
313 `+refs/heads/*:refs/remotes/${fetchNamespace}/*`,
314 ]);
315 });
316}
317
318function configureVisibleRemoteUrlWithLocalRewrite(
319 cwd: string,
320 remoteName: string,
321 visibleUrl: string,
322 localRemotePath: string,
323): Effect.Effect<void, GitCommandError, GitVcsDriver.GitVcsDriver> {
324 return Effect.gen(function* () {
325 yield* runGit(cwd, ["config", `remote.${remoteName}.url`, visibleUrl]);
326 yield* runGit(cwd, ["config", `url.${localRemotePath}.insteadOf`, visibleUrl]);
327 });
328}
329
330function createTextGeneration(
331 overrides: Partial<FakeGitTextGeneration> = {},
332): TextGeneration.TextGeneration["Service"] {
333 const implementation: FakeGitTextGeneration = {
334 generateCommitMessage: (input) =>
335 Effect.succeed({
336 subject: "Implement stacked git actions",
337 body: "",
338 ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}),
339 }),
340 generatePrContent: () =>
341 Effect.succeed({
342 title: "Add stacked git actions",
343 body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run",
344 }),
345 generateBranchName: () =>
346 Effect.succeed({
347 branch: "update-workflow",
348 }),
349 generateThreadTitle: () =>
350 Effect.succeed({
351 title: "Update workflow",
352 }),
353 generateReviewGuide: () => Effect.succeed({ stops: [] }),
354 ...overrides,
355 };
356
357 return {
358 generateCommitMessage: (input) =>
359 implementation.generateCommitMessage(input).pipe(
360 Effect.mapError(
361 (cause) =>
362 new TextGenerationError({
363 operation: "generateCommitMessage",
364 detail: "fake text generation failed",
365 ...(cause !== undefined ? { cause } : {}),
366 }),
367 ),
368 ),
369 generatePrContent: (input) =>
370 implementation.generatePrContent(input).pipe(
371 Effect.mapError(
372 (cause) =>
373 new TextGenerationError({
374 operation: "generatePrContent",
375 detail: "fake text generation failed",
376 ...(cause !== undefined ? { cause } : {}),
377 }),
378 ),
379 ),
380 generateBranchName: (input) =>
381 implementation.generateBranchName(input).pipe(
382 Effect.mapError(
383 (cause) =>
384 new TextGenerationError({
385 operation: "generateBranchName",
386 detail: "fake text generation failed",
387 ...(cause !== undefined ? { cause } : {}),
388 }),
389 ),
390 ),
391 generateThreadTitle: (input) =>
392 implementation.generateThreadTitle(input).pipe(
393 Effect.mapError(
394 (cause) =>
395 new TextGenerationError({
396 operation: "generateThreadTitle",
397 detail: "fake text generation failed",
398 ...(cause !== undefined ? { cause } : {}),
399 }),
400 ),
401 ),
402 generateReviewGuide: (input) => implementation.generateReviewGuide(input),
403 };
404}
405
406/**
407 * A GitHub source control provider over a fake `gh`. The fake still speaks gh's command shapes,
408 * which is what `ghCalls` records and the tests assert on; the provider methods translate to them.
409 */
410function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {
411 service: SourceControlProvider["Service"];
412 ghCalls: string[];
413} {
414 const prListQueue = [...(scenario.prListSequence ?? [])];
415 const prListQueueByHeadSelector = new Map(
416 Object.entries(scenario.prListSequenceByHeadSelector ?? {}).map(([headSelector, values]) => [
417 headSelector,
418 [...values],
419 ]),
420 );
421 const ghCalls: string[] = [];
422
423 const fail = (cwd: string, detail: string, cause?: unknown, operation = "fakeGh") =>
424 new SourceControlProviderFailure({
425 provider: SourceControlProviderKind.make("github"),
426 operation,
427 cwd,
428 detail,
429 cause,
430 });
431
432 /** `operation` is the provider method the call answers, which a failure reports. */
433 const execute = (input: {
434 readonly cwd: string;
435 readonly args: ReadonlyArray<string>;
436 readonly operation: string;
437 }): Effect.Effect<VcsProcess.VcsProcessOutput, SourceControlProviderFailure> => {
438 const args = [...input.args];
439 ghCalls.push(args.join(" "));
440
441 if (scenario.failWith && ghCalls.length > (scenario.failAfterCalls ?? 0)) {
442 return Effect.fail(fail(input.cwd, scenario.failWith, scenario.failCause, input.operation));
443 }
444
445 if (args[0] === "pr" && args[1] === "list") {
446 const headSelectorIndex = args.findIndex((value) => value === "--head");
447 const headSelector =
448 headSelectorIndex >= 0 && headSelectorIndex < args.length - 1
449 ? args[headSelectorIndex + 1]
450 : undefined;
451 const mappedQueue =
452 typeof headSelector === "string"
453 ? prListQueueByHeadSelector.get(headSelector)?.shift()
454 : undefined;
455 const mappedStdout =
456 typeof headSelector === "string"
457 ? scenario.prListByHeadSelector?.[headSelector]
458 : undefined;
459 const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n";
460 return Effect.succeed(fakeGhOutput(stdout));
461 }
462
463 if (args[0] === "pr" && args[1] === "create") {
464 return Effect.succeed(
465 fakeGhOutput(
466 (scenario.createdPrUrl ?? "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n",
467 ),
468 );
469 }
470
471 if (args[0] === "pr" && args[1] === "view") {
472 const pullRequest: FakePullRequest = scenario.pullRequest ?? {
473 number: 101,
474 title: "Pull request",
475 url: "https://github.com/pingdotgg/codething-mvp/pull/101",
476 baseRefName: "main",
477 headRefName: "feature/pull-request",
478 state: "open",
479 };
480 return Effect.succeed(
481 fakeGhOutput(
482 JSON.stringify({
483 ...pullRequest,
484 ...(pullRequest.headRepositoryNameWithOwner
485 ? {
486 headRepository: {
487 nameWithOwner: pullRequest.headRepositoryNameWithOwner,
488 },
489 }
490 : {}),
491 ...(pullRequest.headRepositoryOwnerLogin
492 ? {
493 headRepositoryOwner: {
494 login: pullRequest.headRepositoryOwnerLogin,
495 },
496 }
497 : {}),
498 }) + "\n",
499 ),
500 );
501 }
502
503 if (args[0] === "pr" && args[1] === "checkout") {
504 return Effect.try({
505 try: () => {
506 const headBranch = scenario.pullRequest?.headRefName;
507 if (headBranch) {
508 const existingBranch = NodeChildProcess.spawnSync(
509 "git",
510 ["show-ref", "--verify", "--quiet", `refs/heads/${headBranch}`],
511 {
512 cwd: input.cwd,
513 encoding: "utf8",
514 },
515 );
516 if (existingBranch.status === 0) {
517 runGitSyncForFakeGh(input.cwd, ["checkout", headBranch]);
518 } else {
519 runGitSyncForFakeGh(input.cwd, ["checkout", "-b", headBranch]);
520 }
521 }
522 return fakeGhOutput("");
523 },
524 catch: (error) => fail(input.cwd, "GitHub request failed.", error),
525 });
526 }
527
528 if (args[0] === "repo" && args[1] === "view") {
529 const repository = args[2];
530 if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) {
531 const cloneUrls = scenario.repositoryCloneUrls?.[repository];
532 if (!cloneUrls) {
533 return Effect.fail(fail(input.cwd, `Unexpected repository lookup: ${repository}`));
534 }
535 return Effect.succeed(
536 fakeGhOutput(
537 JSON.stringify({
538 nameWithOwner: repository,
539 url: cloneUrls.url,
540 sshUrl: cloneUrls.sshUrl,
541 }) + "\n",
542 ),
543 );
544 }
545 return Effect.succeed(fakeGhOutput(`${scenario.defaultBranch ?? "main"}\n`));
546 }
547
548 return Effect.fail(fail(input.cwd, `Unexpected gh command: ${args.join(" ")}`));
549 };
550
551 /** What GitHubSourceControlProvider makes of a pull request it read. */
552 const toChangeRequest = (summary: FakePullRequestSummary): ChangeRequest => ({
553 ...summary,
554 provider: SourceControlProviderKind.make("github"),
555 state: summary.state ?? "open",
556 closedAt: summary.closedAt ?? null,
557 mergedAt: summary.mergedAt ?? null,
558 updatedAt:
559 summary.updatedAt === undefined
560 ? Option.none()
561 : Option.some(DateTime.makeUnsafe(summary.updatedAt)),
562 });
563
564 return {
565 service: {
566 kind: SourceControlProviderKind.make("github"),
567 // The GitHub provider's own lookup rule and template convention, which GitManager reads
568 // instead of the kind.
569 headBranchProbe: ({ headSelectors }) => ({
570 headSelectors: headSelectors.filter((selector) => !selector.includes(":")),
571 limit: 100,
572 }),
573
574 listChangeRequests: (input) =>
575 input.state === "open"
576 ? execute({
577 operation: "listChangeRequests",
578 cwd: input.cwd,
579 args: [
580 "pr",
581 "list",
582 "--head",
583 input.headSelector,
584 "--state",
585 "open",
586 "--limit",
587 String(input.limit ?? 1),
588 "--json",
589 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner",
590 ],
591 }).pipe(
592 Effect.map((result) => JSON.parse(result.stdout) as unknown[]),
593 Effect.map((raw) =>
594 raw
595 .map((entry) => normalizeFakePullRequestSummary(entry))
596 .filter((entry): entry is FakePullRequestSummary => entry !== null)
597 .map(toChangeRequest),
598 ),
599 )
600 : // The fake answers the CLI shape, so batched lookups read it the way the fallback does.
601 execute({
602 operation: "listChangeRequests",
603 cwd: input.cwd,
604 args: [
605 "pr",
606 "list",
607 "--head",
608 input.headSelector,
609 "--state",
610 input.state,
611 "--limit",
612 String(input.limit ?? 20),
613 "--json",
614 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
615 ],
616 }).pipe(
617 Effect.map((result) => {
618 const raw = result.stdout.trim();
619 if (raw.length === 0) return [];
620 const decoded = decodeGitHubPullRequestListJson(raw);
621 return Result.isSuccess(decoded)
622 ? decoded.success.map((record) => ({
623 provider: SourceControlProviderKind.make("github"),
624 ...record,
625 }))
626 : [];
627 }),
628 ),
629 createChangeRequest: (input) =>
630 execute({
631 operation: "createChangeRequest",
632 cwd: input.cwd,
633 args: [
634 "pr",
635 "create",
636 "--base",
637 input.baseRefName,
638 "--head",
639 input.headSelector,
640 "--title",
641 input.title,
642 "--body-file",
643 input.bodyFile,
644 ],
645 }).pipe(Effect.asVoid),
646 getDefaultBranch: (input) =>
647 execute({
648 operation: "getDefaultBranch",
649 cwd: input.cwd,
650 args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"],
651 }).pipe(
652 Effect.map((result) => {
653 const value = result.stdout.trim();
654 return value.length > 0 ? value : null;
655 }),
656 ),
657 getChangeRequest: (input) =>
658 execute({
659 operation: "getChangeRequest",
660 cwd: input.cwd,
661 args: [
662 "pr",
663 "view",
664 input.reference,
665 "--json",
666 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner",
667 ],
668 }).pipe(
669 Effect.map((result) =>
670 toChangeRequest(JSON.parse(result.stdout) as FakePullRequestSummary),
671 ),
672 ),
673 getRepositoryCloneUrls: (input) =>
674 execute({
675 operation: "getRepositoryCloneUrls",
676 cwd: input.cwd,
677 args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"],
678 }).pipe(Effect.map((result) => JSON.parse(result.stdout))),
679 createRepository: (input) =>
680 Effect.fail(fail(input.cwd, `Unexpected repository create: ${input.repository}`)),
681 checkoutChangeRequest: (input) =>
682 execute({
683 operation: "checkoutChangeRequest",
684 cwd: input.cwd,
685 args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])],
686 }).pipe(Effect.asVoid),
687 },
688 ghCalls,
689 };
690}
691
692function runStackedAction(
693 manager: GitManager.GitManager["Service"],
694 input: {
695 cwd: string;
696 action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr";
697 actionId?: string;
698 commitMessage?: string;
699 featureBranch?: boolean;
700 filePaths?: readonly string[];
701 },
702 options?: Parameters<GitManager.GitManager["Service"]["runStackedAction"]>[1],
703) {
704 return manager.runStackedAction(
705 {
706 ...input,
707 actionId: input.actionId ?? "test-action-id",
708 },
709 options,
710 );
711}
712
713function resolvePullRequest(
714 manager: GitManager.GitManager["Service"],
715 input: { cwd: string; reference: string },
716) {
717 return manager.resolvePullRequest(input);
718}
719
720function preparePullRequestThread(
721 manager: GitManager.GitManager["Service"],
722 input: GitPreparePullRequestThreadInput,
723) {
724 return manager.preparePullRequestThread(input);
725}
726
727function makeManager(input?: {
728 mergebot?: MergebotQueue.MergebotQueue["Service"];
729 ghScenario?: FakeGhScenario;
730 sourceControlProvider?: SourceControlProvider["Service"];
731 textGeneration?: Partial<FakeGitTextGeneration>;
732 serverSettings?: Parameters<typeof ServerSettings.layerTest>[0];
733 setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];
734 gitConfigReads?: string[];
735 /** Records the arguments of every git process the manager starts. */
736 gitCommands?: string[];
737 /** Holds a matching git process's output until `release`, once it has exited. */
738 gitSpawnGate?: {
739 readonly matches: (args: ReadonlyArray<string>) => boolean;
740 readonly reached: Deferred.Deferred<void>;
741 readonly release: Deferred.Deferred<void>;
742 };
743 /** Seeds the V2 stores the per-project settings lookup reads. */
744 seed?: Effect.Effect<
745 void,
746 ProjectionStore.ProjectionStoreV2Error | ProjectStore.ProjectStoreV2Error,
747 ProjectionStore.ProjectionStoreV2 | ProjectStore.ProjectStoreV2
748 >;
749}) {
750 const { service: fakeGitHubProvider, ghCalls } = createGitHubProviderWithFakeGh(
751 input?.ghScenario,
752 );
753 const textGeneration = createTextGeneration(input?.textGeneration);
754 const layerServerConfig = ServerConfig.layerTest(process.cwd(), {
755 prefix: "t3-git-manager-test-",
756 });
757
758 const layerServerSettings = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings);
759
760 const gitCommands = input?.gitCommands;
761 const gitSpawnGate = input?.gitSpawnGate;
762 const watchesGitSpawns = gitCommands !== undefined || gitSpawnGate !== undefined;
763 const layerGitDriverSpawner = watchesGitSpawns
764 ? Layer.effect(
765 ChildProcessSpawner.ChildProcessSpawner,
766 Effect.gen(function* () {
767 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
768 return ChildProcessSpawner.make((command) => {
769 // On Windows the driver starts git by its full path to git.exe.
770 const args =
771 command._tag === "StandardCommand" &&
772 NodePath.parse(command.command).name.toLowerCase() === "git"
773 ? command.args
774 : null;
775 if (args !== null) gitCommands?.push(args.join(" "));
776 if (args === null || gitSpawnGate === undefined || !gitSpawnGate.matches(args)) {
777 return spawner.spawn(command);
778 }
779 return spawner
780 .spawn(command)
781 .pipe(
782 Effect.tap((handle) =>
783 handle.exitCode.pipe(
784 Effect.andThen(Deferred.succeed(gitSpawnGate.reached, undefined)),
785 Effect.andThen(Deferred.await(gitSpawnGate.release)),
786 ),
787 ),
788 );
789 });
790 }),
791 ).pipe(Layer.provide(NodeServices.layer))
792 : Layer.empty;
793 const gitVcsDriver = input?.gitConfigReads
794 ? Layer.effect(
795 GitVcsDriver.GitVcsDriver,
796 GitVcsDriver.make.pipe(
797 Effect.map((service) =>
798 GitVcsDriver.GitVcsDriver.of({
799 ...service,
800 readConfigValue: (cwd, key) =>
801 Effect.sync(() => input.gitConfigReads?.push(key)).pipe(
802 Effect.andThen(service.readConfigValue(cwd, key)),
803 ),
804 }),
805 ),
806 ),
807 )
808 : GitVcsDriver.layer;
809 // Fresh so the counting spawner is not swapped for the suite's shared driver.
810 const layerVcsDriver = (watchesGitSpawns ? Layer.fresh(gitVcsDriver) : gitVcsDriver).pipe(
811 Layer.provide(layerGitDriverSpawner),
812 Layer.provideMerge(VcsProcess.layer),
813 Layer.provideMerge(NodeServices.layer),
814 Layer.provideMerge(layerServerConfig),
815 );
816 const layerSourceControlRegistry = Layer.effect(
817 SourceControlProviderRegistry.SourceControlProviderRegistry,
818 Effect.gen(function* () {
819 // GitHub reads its PR template with git, which the fake gh cannot answer; give the fake
820 // the package's real reader over the test repository's git.
821 const git = yield* GitVcsDriver.GitVcsDriver;
822 return (
823 input?.sourceControlProvider ?? {
824 ...fakeGitHubProvider,
825 readChangeRequestTemplate: ({ cwd, treeish }: { cwd: string; treeish: string }) =>
826 GitHubChangeRequestTemplate.detect(cwd, treeish, git.execute),
827 }
828 );
829 }).pipe(
830 Effect.map((provider) =>
831 SourceControlProviderRegistry.SourceControlProviderRegistry.of({
832 resolveLink: (input) => provider.resolveLink?.(input),
833 get: () => Effect.succeed(provider),
834 resolveHandle: () => Effect.succeed({ provider, context: null }),
835 resolve: () => Effect.succeed(provider),
836 discover: Effect.succeed([]),
837 }),
838 ),
839 ),
840 );
841
842 const layerManager = Layer.mergeAll(
843 input?.mergebot
844 ? Layer.succeed(MergebotQueue.MergebotQueue, input.mergebot)
845 : Layer.mock(MergebotQueue.MergebotQueue)({ configuration: null }),
846 Layer.succeed(TextGeneration.TextGeneration, textGeneration),
847 Layer.mock(ProviderRegistry.ProviderRegistry)({
848 getProviders: Effect.succeed([]),
849 }),
850 Layer.succeed(
851 ProjectSetupScriptRunner.ProjectSetupScriptRunner,
852 input?.setupScriptRunner ?? {
853 runForThread: () => Effect.succeed({ status: "no-script" as const }),
854 },
855 ),
856 layerServerSettings,
857 ).pipe(
858 Layer.provideMerge(layerSourceControlRegistry),
859 Layer.provideMerge(layerVcsDriver),
860 Layer.provideMerge(NodeServices.layer),
861 );
862 // Built into the test's scope: the manager reads these stores after this returns.
863 const layerStores = Layer.merge(ProjectionStore.layer, ProjectStore.layer).pipe(
864 Layer.provideMerge(SqlitePersistence.layerMemory),
865 );
866
867 return Effect.gen(function* () {
868 const stores = yield* Layer.build(layerStores);
869 if (input?.seed !== undefined) {
870 yield* input.seed.pipe(Effect.provideContext(stores), Effect.orDie);
871 }
872 const manager = yield* GitManager.make.pipe(
873 Effect.provide(layerManager),
874 Effect.provideContext(stores),
875 );
876 return { manager, ghCalls };
877 });
878}
879
880const asThreadId = (threadId: string) => threadId as ThreadId;
881
882const layerGitManagerTest = GitVcsDriver.layer.pipe(
883 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })),
884 Layer.provideMerge(VcsProcess.layer),
885 Layer.provideMerge(NodeServices.layer),
886);
887
888it.layer(layerGitManagerTest)("GitManager", (it) => {
889 it.effect(
890 "work branch lookups and PR resolution use live Mergebot without GitHub cache fallback",
891 () =>
892 Effect.gen(function* () {
893 const repoDir = yield* makeTempDir("t3code-work-pr-");
894 yield* initRepo(repoDir);
895 yield* runGit(repoDir, ["remote", "add", "origin", "https://github.com/work/app.git"]);
896 yield* runGit(repoDir, ["checkout", "-b", "feature"]);
897 yield* runGit(repoDir, ["update-ref", "refs/remotes/origin/feature", "HEAD"]);
898 yield* runGit(repoDir, ["config", "branch.feature.remote", "origin"]);
899 yield* runGit(repoDir, ["config", "branch.feature.merge", "refs/heads/feature"]);
900 let value: MergebotPullRequestDetails | null = {
901 number: 42,
902 headSha: "head",
903 lifecycle: "open",
904 queueStatus: "waiting_ci",
905 position: 1,
906 mergeEtaAt: null,
907 title: "Ship change",
908 body: null,
909 url: "https://mergebot.example/work/app/pull/42",
910 createdAt: "2026-10-14T10:00:00Z",
911 updatedAt: "2026-10-14T10:01:00Z",
912 author: null,
913 additions: 3,
914 deletions: 1,
915 changedFilesCount: 1,
916 labels: [],
917 comments: [],
918 observedAt: 1791972060000,
919 draft: false,
920 baseBranch: "main",
921 headBranch: "feature",
922 ciStatus: "pending",
923 hasConflicts: null,
924 checks: [],
925 };
926 const { manager, ghCalls } = yield* makeManager({
927 mergebot: {
928 configuration: { origin: "https://mergebot.example", repositories: ["work/app"] },
929 read: () => Effect.succeed(null),
930 watchPullRequestStatus: () => Stream.empty,
931 watchDeployments: () => Stream.empty,
932 openPullRequest: () => Effect.die("unused"),
933 setAutoMerge: () => Effect.die("unused"),
934 updatePullRequest: () => Effect.die("unused"),
935 deployProduction: () => Effect.die("unused"),
936 watchQueue: () => Stream.empty,
937 streamChanges: Stream.empty,
938 readPullRequest: () => Effect.sync(() => value),
939 findPullRequest: () => Effect.sync(() => value),
940 },
941 });
942 expect(yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature" })).toMatchObject(
943 { number: 42, title: "Ship change", repositoryKey: "github.com/work/app" },
944 );
945 value = {
946 ...value!,
947 title: "Live update",
948 lifecycle: "merged",
949 mergedAt: "2026-10-14T10:03:00Z",
950 };
951 expect(yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature" })).toMatchObject(
952 { title: "Live update", state: "merged" },
953 );
954 expect(
955 (yield* manager.resolvePullRequest({ cwd: repoDir, reference: "42" })).pullRequest.title,
956 ).toBe("Live update");
957 expect(
958 (yield* manager
959 .preparePullRequestThread({ cwd: repoDir, reference: "42", mode: "local" })
960 .pipe(Effect.flip)).message,
961 ).toContain("Mergebot");
962 value = null;
963 expect(yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature" })).toBeNull();
964 expect(ghCalls).toHaveLength(0);
965 }),
966 );
967
968 it.effect("passive worktree status streams do not start remote refreshes", () =>
969 Effect.gen(function* () {
970 const repoDir = yield* makeTempDir("t3code-passive-vcs-");
971 yield* initRepo(repoDir);
972 const remoteDir = yield* createBareRemote();
973 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
974 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
975 const worktreeDir = NodePath.join(repoDir, "worktree");
976 yield* runGit(repoDir, ["worktree", "add", "-b", "feature/passive", worktreeDir]);
977 yield* runGit(worktreeDir, ["push", "-u", "origin", "feature/passive"]);
978
979 const { manager } = yield* makeManager();
980 let remoteReads = 0;
981 const workflowContext = yield* Layer.build(
982 GitWorkflowService.layer.pipe(
983 Layer.provide(
984 Layer.succeed(GitManager.GitManager, {
985 ...manager,
986 remoteStatus: (input, options) =>
987 manager.remoteStatus(input, options).pipe(
988 Effect.tap(() =>
989 Effect.sync(() => {
990 remoteReads += 1;
991 }),
992 ),
993 ),
994 }),
995 ),
996 Layer.provide(
997 VcsDriverRegistry.layer.pipe(
998 Layer.provide(VcsProjectConfig.layer),
999 Layer.provide(VcsProcess.layer),
1000 ),
1001 ),
1002 ),
1003 );
1004 const broadcasterContext = yield* Layer.build(
1005 VcsStatusBroadcaster.layer.pipe(
1006 Layer.provide(
1007 Layer.succeed(
1008 GitWorkflowService.GitWorkflowService,
1009 Context.get(workflowContext, GitWorkflowService.GitWorkflowService),
1010 ),
1011 ),
1012 Layer.provide(
1013 Layer.mock(BackgroundPolicy.BackgroundPolicy)({
1014 hasDemand: () => Effect.succeed(true),
1015 shouldRunScopeWork: () => Effect.succeed(true),
1016 }),
1017 ),
1018 ),
1019 );
1020 const broadcaster = Context.get(
1021 broadcasterContext,
1022 VcsStatusBroadcaster.VcsStatusBroadcaster,
1023 );
1024 const passiveScope = yield* Scope.make();
1025 const snapshots = yield* Deferred.make<void, GitManagerServiceError>();
1026 const localUpdated = yield* Deferred.make<void>();
1027 const remoteUpdated = yield* Deferred.make<void>();
1028 let snapshotCount = 0;
1029 for (const cwd of [repoDir, worktreeDir]) {
1030 yield* Stream.runForEach(
1031 broadcaster.streamStatus(
1032 { cwd, includeRemote: false },
1033 { automaticRemoteRefreshInterval: Effect.succeed(Duration.seconds(1)) },
1034 ),
1035 (event) => {
1036 if (
1037 cwd === worktreeDir &&
1038 event._tag === "localUpdated" &&
1039 event.local.hasWorkingTreeChanges
1040 ) {
1041 return Deferred.succeed(localUpdated, undefined);
1042 }
1043 if (cwd === worktreeDir && event._tag === "remoteUpdated") {
1044 expect(event.remote?.hasUpstream).toBe(true);
1045 return Deferred.succeed(remoteUpdated, undefined);
1046 }
1047 if (event._tag !== "snapshot") return Effect.void;
1048 expect(event.local.isRepo).toBe(true);
1049 expect(event.local.refName).toBe(cwd === repoDir ? "main" : "feature/passive");
1050 snapshotCount += 1;
1051 return snapshotCount === 2 ? Deferred.succeed(snapshots, undefined) : Effect.void;
1052 },
1053 ).pipe(
1054 Effect.catchCause((cause) => Deferred.failCause(snapshots, cause)),
1055 Effect.forkIn(passiveScope),
1056 );
1057 }
1058 yield* Deferred.await(snapshots);
1059 expect(remoteReads).toBe(0);
1060 yield* TestClock.adjust("1 minute");
1061 expect(remoteReads).toBe(0);
1062
1063 const fs = yield* FileSystem.FileSystem;
1064 yield* fs.writeFileString(NodePath.join(worktreeDir, "README.md"), "changed\n");
1065 yield* broadcaster.refreshLocalStatus(worktreeDir);
1066 yield* Deferred.await(localUpdated);
1067 expect(remoteReads).toBe(0);
1068
1069 const activeScope = yield* Scope.make();
1070 yield* Stream.runDrain(broadcaster.streamStatus({ cwd: worktreeDir })).pipe(
1071 Effect.forkIn(activeScope),
1072 );
1073 yield* Deferred.await(remoteUpdated);
1074 expect(remoteReads).toBe(1);
1075 yield* Scope.close(activeScope, Exit.void);
1076 yield* TestClock.adjust("1 minute");
1077 expect(remoteReads).toBe(1);
1078 yield* Scope.close(passiveScope, Exit.void);
1079 }),
1080 );
1081
1082 it.effect("status includes draft PR metadata when branch already has a draft PR", () =>
1083 Effect.gen(function* () {
1084 const repoDir = yield* makeTempDir("t3code-git-manager-");
1085 yield* initRepo(repoDir);
1086 yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]);
1087 const remoteDir = yield* createBareRemote();
1088 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1089 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]);
1090
1091 const { manager } = yield* makeManager({
1092 ghScenario: {
1093 prListSequence: [
1094 JSON.stringify([
1095 {
1096 number: 13,
1097 title: "Existing PR",
1098 url: "https://github.com/pingdotgg/codething-mvp/pull/13",
1099 baseRefName: "main",
1100 headRefName: "feature/status-open-pr",
1101 isDraft: true,
1102 },
1103 ]),
1104 ],
1105 },
1106 });
1107
1108 const status = yield* manager.status({ cwd: repoDir });
1109 expect(status.isRepo).toBe(true);
1110 expect(status.hasPrimaryRemote).toBe(true);
1111 expect(status.isDefaultRef).toBe(false);
1112 expect(status.refName).toBe("feature/status-open-pr");
1113 expect(status.pr).toEqual({
1114 number: 13,
1115 title: "Existing PR",
1116 url: "https://github.com/pingdotgg/codething-mvp/pull/13",
1117 baseRef: "main",
1118 headRef: "feature/status-open-pr",
1119 state: "open",
1120 isDraft: true,
1121 updatedAt: null,
1122 });
1123 }),
1124 );
1125
1126 it.effect("status trims PR metadata returned by gh before publishing it", () =>
1127 Effect.gen(function* () {
1128 const repoDir = yield* makeTempDir("t3code-git-manager-");
1129 yield* initRepo(repoDir);
1130 yield* runGit(repoDir, ["checkout", "-b", "feature/status-trimmed-pr"]);
1131 const remoteDir = yield* createBareRemote();
1132 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1133 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-trimmed-pr"]);
1134
1135 const { manager } = yield* makeManager({
1136 ghScenario: {
1137 prListSequence: [
1138 JSON.stringify([
1139 {
1140 number: 14,
1141 title: " Existing PR title \n",
1142 url: " https://github.com/pingdotgg/codething-mvp/pull/14 ",
1143 baseRefName: " main ",
1144 headRefName: "\tfeature/status-trimmed-pr\t",
1145 },
1146 ]),
1147 ],
1148 },
1149 });
1150
1151 const status = yield* manager.status({ cwd: repoDir });
1152
1153 expect(status.pr).toEqual({
1154 number: 14,
1155 title: "Existing PR title",
1156 url: "https://github.com/pingdotgg/codething-mvp/pull/14",
1157 baseRef: "main",
1158 headRef: "feature/status-trimmed-pr",
1159 state: "open",
1160 updatedAt: null,
1161 });
1162 }),
1163 );
1164
1165 it.effect("status ignores invalid gh pr list entries and keeps valid ones", () =>
1166 Effect.gen(function* () {
1167 const repoDir = yield* makeTempDir("t3code-git-manager-");
1168 yield* initRepo(repoDir);
1169 yield* runGit(repoDir, ["checkout", "-b", "feature/status-valid-pr-entry"]);
1170 const remoteDir = yield* createBareRemote();
1171 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1172 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-valid-pr-entry"]);
1173
1174 const { manager } = yield* makeManager({
1175 ghScenario: {
1176 prListSequence: [
1177 JSON.stringify([
1178 {
1179 number: 0,
1180 title: "invalid",
1181 url: "https://github.com/pingdotgg/codething-mvp/pull/0",
1182 baseRefName: "main",
1183 headRefName: "feature/invalid",
1184 },
1185 {
1186 number: 15,
1187 title: " Valid PR title ",
1188 url: " https://github.com/pingdotgg/codething-mvp/pull/15 ",
1189 baseRefName: " main ",
1190 headRefName: "\tfeature/status-valid-pr-entry\t",
1191 headRepository: {
1192 nameWithOwner: " ",
1193 },
1194 headRepositoryOwner: {
1195 login: " ",
1196 },
1197 },
1198 ]),
1199 ],
1200 },
1201 });
1202
1203 const status = yield* manager.status({ cwd: repoDir });
1204
1205 expect(status.pr).toEqual({
1206 number: 15,
1207 title: "Valid PR title",
1208 url: "https://github.com/pingdotgg/codething-mvp/pull/15",
1209 baseRef: "main",
1210 headRef: "feature/status-valid-pr-entry",
1211 state: "open",
1212 updatedAt: null,
1213 });
1214 }),
1215 );
1216
1217 it.effect("status preserves lowercase merged and closed PR states from gh json", () =>
1218 Effect.gen(function* () {
1219 const repoDir = yield* makeTempDir("t3code-git-manager-");
1220 yield* initRepo(repoDir);
1221 yield* runGit(repoDir, ["checkout", "-b", "feature/status-lowercase-state"]);
1222 const remoteDir = yield* createBareRemote();
1223 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1224 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-lowercase-state"]);
1225
1226 const { manager } = yield* makeManager({
1227 ghScenario: {
1228 prListSequence: [
1229 JSON.stringify([
1230 {
1231 number: 16,
1232 title: "Closed PR",
1233 url: "https://github.com/pingdotgg/codething-mvp/pull/16",
1234 baseRefName: "main",
1235 headRefName: "feature/status-lowercase-state",
1236 state: "closed",
1237 updatedAt: "2026-01-01T00:00:00.000Z",
1238 },
1239 {
1240 number: 17,
1241 title: "Merged PR",
1242 url: "https://github.com/pingdotgg/codething-mvp/pull/17",
1243 baseRefName: "main",
1244 headRefName: "feature/status-lowercase-state",
1245 state: "merged",
1246 updatedAt: "2026-01-02T00:00:00.000Z",
1247 },
1248 ]),
1249 ],
1250 },
1251 });
1252
1253 const status = yield* manager.status({ cwd: repoDir });
1254
1255 expect(status.pr).toEqual({
1256 number: 17,
1257 title: "Merged PR",
1258 url: "https://github.com/pingdotgg/codething-mvp/pull/17",
1259 baseRef: "main",
1260 headRef: "feature/status-lowercase-state",
1261 state: "merged",
1262 updatedAt: "2026-01-02T00:00:00.000Z",
1263 });
1264 }),
1265 );
1266
1267 it.effect("status returns an explicit non-repo result for non-git directories", () =>
1268 Effect.gen(function* () {
1269 const cwd = yield* makeTempDir("t3code-git-manager-non-repo-");
1270 const { manager } = yield* makeManager();
1271
1272 const status = yield* manager.status({ cwd });
1273
1274 expect(status).toEqual({
1275 isRepo: false,
1276 hasPrimaryRemote: false,
1277 isDefaultRef: false,
1278 refName: null,
1279 hasWorkingTreeChanges: false,
1280 workingTree: {
1281 files: [],
1282 insertions: 0,
1283 deletions: 0,
1284 },
1285 hasUpstream: false,
1286 aheadCount: 0,
1287 behindCount: 0,
1288 aheadOfDefaultCount: 0,
1289 pr: null,
1290 });
1291 }),
1292 );
1293
1294 it.effect("status returns an explicit non-repo result for deleted directories", () =>
1295 Effect.gen(function* () {
1296 const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-");
1297 const cwd = NodePath.join(rootDir, "deleted-repo");
1298 yield* makeDirectory(cwd);
1299 yield* removePath(cwd);
1300 const { manager } = yield* makeManager();
1301
1302 const status = yield* manager.status({ cwd });
1303
1304 expect(status).toEqual({
1305 isRepo: false,
1306 hasPrimaryRemote: false,
1307 isDefaultRef: false,
1308 refName: null,
1309 hasWorkingTreeChanges: false,
1310 workingTree: {
1311 files: [],
1312 insertions: 0,
1313 deletions: 0,
1314 },
1315 hasUpstream: false,
1316 aheadCount: 0,
1317 behindCount: 0,
1318 aheadOfDefaultCount: 0,
1319 pr: null,
1320 });
1321 }),
1322 );
1323
1324 it.effect("status briefly caches repeated lookups for the same cwd", () =>
1325 Effect.gen(function* () {
1326 const repoDir = yield* makeTempDir("t3code-git-manager-");
1327 yield* initRepo(repoDir);
1328 yield* runGit(repoDir, ["checkout", "-b", "feature/status-cache"]);
1329 const remoteDir = yield* createBareRemote();
1330 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1331 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-cache"]);
1332
1333 const existingPr = {
1334 number: 113,
1335 title: "Cached PR",
1336 url: "https://github.com/pingdotgg/codething-mvp/pull/113",
1337 baseRefName: "main",
1338 headRefName: "feature/status-cache",
1339 };
1340 const { manager, ghCalls } = yield* makeManager({
1341 ghScenario: {
1342 prListSequence: [JSON.stringify([existingPr]), JSON.stringify([existingPr])],
1343 },
1344 });
1345
1346 const first = yield* manager.status({ cwd: repoDir });
1347 const second = yield* manager.status({ cwd: repoDir });
1348
1349 expect(first.pr?.number).toBe(113);
1350 expect(second.pr?.number).toBe(113);
1351 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);
1352 }),
1353 );
1354
1355 it.effect("a warm PR cache does not reread repository identity for status", () =>
1356 Effect.gen(function* () {
1357 const repoDir = yield* makeTempDir("t3code-git-manager-");
1358 yield* initRepo(repoDir);
1359 yield* runGit(repoDir, ["checkout", "-b", "feature/status-identity-cache"]);
1360 const remoteDir = yield* createBareRemote();
1361 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1362 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-identity-cache"]);
1363
1364 const gitConfigReads: string[] = [];
1365 const { manager } = yield* makeManager({ gitConfigReads });
1366
1367 yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false });
1368 gitConfigReads.length = 0;
1369 yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false });
1370
1371 const identityReads = gitConfigReads.filter(
1372 (key) =>
1373 key === "branch.feature/status-identity-cache.remote" || key === "remote.origin.url",
1374 );
1375 expect(identityReads).toHaveLength(0);
1376 }),
1377 );
1378
1379 it.effect("a warm branch PR lookup starts no git process", () =>
1380 Effect.gen(function* () {
1381 const repoDir = yield* makeTempDir("t3code-git-manager-");
1382 yield* initRepo(repoDir);
1383 const remoteDir = yield* createBareRemote();
1384 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1385 for (const branch of ["feature/warm-pr", "feature/warm-no-pr"]) {
1386 yield* runGit(repoDir, ["checkout", "-b", branch, "main"]);
1387 yield* runGit(repoDir, ["push", "-u", "origin", branch]);
1388 }
1389 const gitCommands: string[] = [];
1390 const { manager, ghCalls } = yield* makeManager({
1391 gitCommands,
1392 ghScenario: {
1393 prListByHeadSelector: {
1394 "feature/warm-pr": encodeCliJson([
1395 {
1396 number: 230,
1397 title: "Warm PR",
1398 url: "https://github.com/pingdotgg/codething-mvp/pull/230",
1399 baseRefName: "main",
1400 headRefName: "feature/warm-pr",
1401 state: "OPEN",
1402 updatedAt: "2026-04-07T15:00:00Z",
1403 },
1404 ]),
1405 },
1406 },
1407 });
1408 const lookupAll = Effect.forEach(["feature/warm-pr", "feature/warm-no-pr"], (branch) =>
1409 manager.branchPullRequest({ cwd: repoDir, branch }),
1410 );
1411
1412 const cold = yield* lookupAll;
1413 expect(cold.map((pullRequest) => pullRequest?.number ?? null)).toEqual([230, null]);
1414 expect(gitCommands.length).toBeGreaterThan(0);
1415
1416 gitCommands.length = 0;
1417 const warm = yield* lookupAll;
1418 expect(warm).toEqual(cold);
1419 expect(gitCommands).toEqual([]);
1420 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);
1421 }),
1422 );
1423
1424 it.effect(
1425 "a branch PR lookup sees an upstream change on request, after a T3 git action, or after the TTL",
1426 () =>
1427 Effect.gen(function* () {
1428 const repoDir = yield* makeTempDir("t3code-git-manager-");
1429 yield* initRepo(repoDir);
1430 const remoteDir = yield* createBareRemote();
1431 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1432 yield* runGit(repoDir, ["checkout", "-b", "feature/moving-upstream"]);
1433 yield* runGit(repoDir, ["push", "-u", "origin", "feature/moving-upstream"]);
1434 yield* runGit(repoDir, ["push", "origin", "HEAD:refs/heads/feature/other-head"]);
1435 const pullRequest = (number: number, headRefName: string) =>
1436 encodeCliJson([
1437 {
1438 number,
1439 title: headRefName,
1440 url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`,
1441 baseRefName: "main",
1442 headRefName,
1443 state: "OPEN",
1444 updatedAt: "2026-04-07T15:00:00Z",
1445 },
1446 ]);
1447 const { manager } = yield* makeManager({
1448 ghScenario: {
1449 prListByHeadSelector: {
1450 "feature/moving-upstream": pullRequest(235, "feature/moving-upstream"),
1451 "feature/other-head": pullRequest(236, "feature/other-head"),
1452 },
1453 },
1454 });
1455 const lookup = manager.branchPullRequest({
1456 cwd: repoDir,
1457 branch: "feature/moving-upstream",
1458 });
1459 expect((yield* lookup)?.number).toBe(235);
1460
1461 // Discovery rereads git before it persists an answer.
1462 yield* runGit(repoDir, ["branch", "--set-upstream-to=origin/feature/other-head"]);
1463 const reread = yield* manager.branchPullRequest(
1464 { cwd: repoDir, branch: "feature/moving-upstream" },
1465 { freshGitState: true },
1466 );
1467 expect(reread?.number).toBe(236);
1468
1469 // T3's own git actions end in invalidateStatus.
1470 yield* runGit(repoDir, ["branch", "--set-upstream-to=origin/feature/moving-upstream"]);
1471 yield* manager.invalidateStatus(repoDir);
1472 expect((yield* lookup)?.number).toBe(235);
1473
1474 // A change made outside T3 shows once the remembered state expires.
1475 yield* runGit(repoDir, ["branch", "--set-upstream-to=origin/feature/other-head"]);
1476 yield* TestClock.adjust("5 minutes");
1477 expect((yield* lookup)?.number).toBe(236);
1478 }),
1479 );
1480
1481 it.effect("a git read in flight during a T3 git action does not outlive it", () =>
1482 Effect.gen(function* () {
1483 const repoDir = yield* makeTempDir("t3code-git-manager-");
1484 yield* initRepo(repoDir);
1485 const remoteDir = yield* createBareRemote();
1486 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1487 yield* runGit(repoDir, ["checkout", "-b", "feature/racing-upstream"]);
1488 yield* runGit(repoDir, ["push", "-u", "origin", "feature/racing-upstream"]);
1489 yield* runGit(repoDir, ["push", "origin", "HEAD:refs/heads/feature/raced-head"]);
1490 const pullRequest = (number: number, headRefName: string) =>
1491 encodeCliJson([
1492 {
1493 number,
1494 title: headRefName,
1495 url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`,
1496 baseRefName: "main",
1497 headRefName,
1498 state: "OPEN",
1499 updatedAt: "2026-04-07T15:00:00Z",
1500 },
1501 ]);
1502 const gate = {
1503 matches: (args: ReadonlyArray<string>) =>
1504 args[0] === "for-each-ref" && args.at(-1) === "refs/heads/feature/racing-upstream",
1505 reached: yield* Deferred.make<void>(),
1506 release: yield* Deferred.make<void>(),
1507 };
1508 const { manager } = yield* makeManager({
1509 gitSpawnGate: gate,
1510 ghScenario: {
1511 prListByHeadSelector: {
1512 "feature/racing-upstream": pullRequest(237, "feature/racing-upstream"),
1513 "feature/raced-head": pullRequest(238, "feature/raced-head"),
1514 },
1515 },
1516 });
1517 const lookup = manager.branchPullRequest({
1518 cwd: repoDir,
1519 branch: "feature/racing-upstream",
1520 });
1521
1522 // The branch read has exited with the old upstream; a T3 git action
1523 // changes it before the lookup gets that output.
1524 const inFlight = yield* Effect.forkChild(lookup);
1525 yield* Deferred.await(gate.reached);
1526 yield* runGit(repoDir, ["branch", "--set-upstream-to=origin/feature/raced-head"]);
1527 yield* manager.invalidateStatus(repoDir);
1528 yield* Deferred.succeed(gate.release, undefined);
1529 yield* Fiber.join(inFlight);
1530
1531 expect((yield* lookup)?.number).toBe(238);
1532 }),
1533 );
1534
1535 it.effect("turn-end refresh finds a new PR and keeps known PRs cached", () =>
1536 Effect.gen(function* () {
1537 const repoDir = yield* makeTempDir("t3code-git-manager-");
1538 yield* initRepo(repoDir);
1539 const remoteDir = yield* createBareRemote();
1540 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1541 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1542 yield* runGit(repoDir, ["checkout", "-b", "feature/turn-refresh", "origin/main"]);
1543 yield* runGit(repoDir, ["push", "origin", "feature/turn-refresh"]);
1544
1545 const { manager, ghCalls } = yield* makeManager({
1546 ghScenario: {
1547 prListSequence: [
1548 "[]",
1549 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
1550 JSON.stringify([
1551 {
1552 number: 114,
1553 title: "Opened during the turn",
1554 url: "https://github.com/pingdotgg/codething-mvp/pull/114",
1555 baseRefName: "main",
1556 headRefName: "feature/turn-refresh",
1557 },
1558 ]),
1559 ],
1560 },
1561 });
1562 expect((yield* manager.remoteStatus({ cwd: repoDir }))?.pr).toBeNull();
1563 expect(
1564 (yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }))?.pr,
1565 ).toBeNull();
1566
1567 const refreshed = yield* manager.remoteStatus(
1568 { cwd: repoDir },
1569 { refreshUpstream: false, refreshMissingPullRequest: true },
1570 );
1571 expect(refreshed?.pr?.number).toBe(114);
1572 yield* manager.remoteStatus(
1573 { cwd: repoDir },
1574 { refreshUpstream: false, refreshMissingPullRequest: true },
1575 );
1576 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);
1577 }),
1578 );
1579
1580 it.effect("turn-end refresh preserves failed PR lookup backoff", () =>
1581 Effect.gen(function* () {
1582 const repoDir = yield* makeTempDir("t3code-git-manager-");
1583 yield* initRepo(repoDir);
1584 const remoteDir = yield* createBareRemote();
1585 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1586 yield* runGit(repoDir, ["checkout", "-b", "feature/rate-limited"]);
1587 yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]);
1588 const { manager, ghCalls } = yield* makeManager({
1589 ghScenario: {
1590 failWith:
1591 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
1592 },
1593 });
1594 yield* manager.remoteStatus({ cwd: repoDir });
1595 const callsAfterFailure = ghCalls.length;
1596 yield* manager.remoteStatus(
1597 { cwd: repoDir },
1598 { refreshUpstream: false, refreshMissingPullRequest: true },
1599 );
1600 expect(callsAfterFailure).toBeGreaterThan(0);
1601 expect(ghCalls).toHaveLength(callsAfterFailure);
1602 }),
1603 );
1604
1605 it.effect("status skips the provider lookup for a branch that was never pushed", () =>
1606 Effect.gen(function* () {
1607 const repoDir = yield* makeTempDir("t3code-git-manager-");
1608 yield* initRepo(repoDir);
1609 const remoteDir = yield* createBareRemote();
1610 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1611 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1612 yield* runGit(repoDir, ["checkout", "-b", "feature/never-pushed"]);
1613
1614 const { manager, ghCalls } = yield* makeManager();
1615
1616 const status = yield* manager.status({ cwd: repoDir });
1617
1618 expect(status.refName).toBe("feature/never-pushed");
1619 expect(status.pr).toBeNull();
1620 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(0);
1621 }),
1622 );
1623
1624 it.effect("branch PR lookup returns null when the repository has no remotes", () =>
1625 Effect.gen(function* () {
1626 const repoDir = yield* makeTempDir("t3code-git-manager-");
1627 yield* initRepo(repoDir);
1628 const { manager, ghCalls } = yield* makeManager();
1629
1630 const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" });
1631
1632 expect(pullRequest).toBeNull();
1633 expect(ghCalls).toHaveLength(0);
1634 }),
1635 );
1636
1637 it.effect("branch PR lookup uses a saved tracked branch without changing checkout", () =>
1638 Effect.gen(function* () {
1639 const repoDir = yield* makeTempDir("t3code-git-manager-");
1640 yield* initRepo(repoDir);
1641 const remoteDir = yield* createBareRemote();
1642 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1643 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1644 yield* runGit(repoDir, ["checkout", "-b", "feature/saved-branch"]);
1645 yield* runGit(repoDir, ["push", "-u", "origin", "feature/saved-branch"]);
1646 yield* runGit(repoDir, ["checkout", "main"]);
1647
1648 const { manager } = yield* makeManager({
1649 ghScenario: {
1650 prListSequence: [
1651 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
1652 JSON.stringify([
1653 {
1654 number: 216,
1655 title: "Saved branch PR",
1656 url: "https://github.com/pingdotgg/t3code/pull/216",
1657 baseRefName: "main",
1658 headRefName: "feature/saved-branch",
1659 headRefOid: "a".repeat(40),
1660 state: "OPEN",
1661 updatedAt: "2026-04-03T15:00:00Z",
1662 },
1663 ]),
1664 ],
1665 },
1666 });
1667
1668 const pullRequest = yield* manager.branchPullRequest({
1669 cwd: repoDir,
1670 branch: "feature/saved-branch",
1671 });
1672
1673 expect(pullRequest).toMatchObject({
1674 number: 216,
1675 title: "Saved branch PR",
1676 url: "https://github.com/pingdotgg/t3code/pull/216",
1677 baseRef: "main",
1678 headRef: "feature/saved-branch",
1679 headSha: "a".repeat(40),
1680 state: "open",
1681 closedAt: null,
1682 mergedAt: null,
1683 updatedAt: "2026-04-03T15:00:00.000Z",
1684 });
1685 expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main");
1686 }),
1687 );
1688
1689 it.effect("branch PR lookup uses the default branch from a non-origin remote", () =>
1690 Effect.gen(function* () {
1691 const repoDir = yield* makeTempDir("t3code-git-manager-");
1692 yield* initRepo(repoDir);
1693 const remoteDir = yield* createBareRemote();
1694 yield* runGit(repoDir, ["remote", "add", "upstream", remoteDir]);
1695 yield* runGit(repoDir, ["push", "-u", "upstream", "main"]);
1696 yield* runGit(repoDir, ["checkout", "-b", "develop"]);
1697 yield* runGit(repoDir, ["push", "-u", "upstream", "develop"]);
1698 yield* runGit(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/develop"]);
1699 yield* runGit(repoDir, ["remote", "set-head", "upstream", "develop"]);
1700
1701 const { manager } = yield* makeManager({
1702 ghScenario: {
1703 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
1704 prListSequence: [
1705 JSON.stringify([
1706 {
1707 number: 221,
1708 title: "Merged main PR",
1709 url: "https://github.com/pingdotgg/codething-mvp/pull/221",
1710 baseRefName: "develop",
1711 headRefName: "main",
1712 state: "MERGED",
1713 mergedAt: "2026-04-07T15:00:00Z",
1714 updatedAt: "2026-04-08T15:00:00Z",
1715 },
1716 ]),
1717 ],
1718 },
1719 });
1720
1721 const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" });
1722
1723 expect(pullRequest).toMatchObject({
1724 state: "merged",
1725 closedAt: null,
1726 mergedAt: "2026-04-07T15:00:00Z",
1727 updatedAt: "2026-04-08T15:00:00.000Z",
1728 });
1729 }),
1730 );
1731
1732 it.effect("branch PR lookup uses the saved name after the local branch is deleted", () =>
1733 Effect.gen(function* () {
1734 const repoDir = yield* makeTempDir("t3code-git-manager-");
1735 yield* initRepo(repoDir);
1736 const remoteDir = yield* createBareRemote();
1737 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1738 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1739 yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-local-branch"]);
1740 yield* runGit(repoDir, ["push", "-u", "origin", "feature/deleted-local-branch"]);
1741 yield* runGit(repoDir, ["checkout", "main"]);
1742 yield* runGit(repoDir, ["branch", "-D", "feature/deleted-local-branch"]);
1743 yield* runGit(repoDir, ["branch", "feature/deleted-local-branch/child"]);
1744 yield* runGit(repoDir, [
1745 "branch",
1746 "--set-upstream-to",
1747 "origin/main",
1748 "feature/deleted-local-branch/child",
1749 ]);
1750
1751 const { manager, ghCalls } = yield* makeManager({
1752 ghScenario: {
1753 prListSequence: [
1754 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
1755 JSON.stringify([
1756 {
1757 number: 217,
1758 title: "Deleted local branch PR",
1759 url: "https://github.com/pingdotgg/t3code/pull/217",
1760 baseRefName: "main",
1761 headRefName: "feature/deleted-local-branch",
1762 state: "MERGED",
1763 updatedAt: "2026-04-04T15:00:00Z",
1764 },
1765 ]),
1766 ],
1767 },
1768 });
1769
1770 const pullRequest = yield* manager.branchPullRequest({
1771 cwd: repoDir,
1772 branch: "feature/deleted-local-branch",
1773 });
1774
1775 expect(pullRequest).toMatchObject({
1776 state: "merged",
1777 closedAt: null,
1778 mergedAt: null,
1779 updatedAt: "2026-04-04T15:00:00.000Z",
1780 });
1781 expect(ghCalls.some((call) => call.includes("--head feature/deleted-local-branch"))).toBe(
1782 true,
1783 );
1784 }),
1785 );
1786
1787 it.effect("branch PR lookup recovers a deleted fork branch from its remote-tracking ref", () =>
1788 Effect.gen(function* () {
1789 const repoDir = yield* makeTempDir("t3code-git-manager-");
1790 yield* initRepo(repoDir);
1791 const originDir = yield* createBareRemote();
1792 const forkDir = yield* createBareRemote();
1793 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1794 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1795 yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork");
1796 yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-fork-branch"]);
1797 yield* runGit(repoDir, ["push", "-u", "team/fork", "feature/deleted-fork-branch"]);
1798 yield* runGit(repoDir, ["checkout", "main"]);
1799 yield* runGit(repoDir, ["branch", "-D", "feature/deleted-fork-branch"]);
1800 yield* configureVisibleRemoteUrlWithLocalRewrite(
1801 repoDir,
1802 "origin",
1803 "git@github.com:pingdotgg/codething-mvp.git",
1804 originDir,
1805 );
1806 yield* configureVisibleRemoteUrlWithLocalRewrite(
1807 repoDir,
1808 "team/fork",
1809 "git@github.com:contributor/codething-mvp.git",
1810 forkDir,
1811 );
1812
1813 const { manager, ghCalls } = yield* makeManager({
1814 ghScenario: {
1815 prListByHeadSelector: {
1816 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
1817 "feature/deleted-fork-branch": JSON.stringify([
1818 {
1819 number: 218,
1820 title: "Deleted fork branch PR",
1821 url: "https://github.com/pingdotgg/codething-mvp/pull/218",
1822 baseRefName: "main",
1823 headRefName: "feature/deleted-fork-branch",
1824 state: "MERGED",
1825 updatedAt: "2026-04-05T15:00:00Z",
1826 isCrossRepository: true,
1827 headRepository: { nameWithOwner: "contributor/codething-mvp" },
1828 headRepositoryOwner: { login: "contributor" },
1829 },
1830 ]),
1831 },
1832 },
1833 });
1834
1835 const pullRequest = yield* manager.branchPullRequest({
1836 cwd: repoDir,
1837 branch: "feature/deleted-fork-branch",
1838 });
1839
1840 expect(pullRequest).toMatchObject({
1841 state: "merged",
1842 closedAt: null,
1843 mergedAt: null,
1844 updatedAt: "2026-04-05T15:00:00.000Z",
1845 });
1846 expect(
1847 ghCalls.some((call) => call.includes("--head feature/deleted-fork-branch --state all")),
1848 ).toBe(true);
1849 expect(ghCalls.some((call) => call.includes("--head contributor:"))).toBe(false);
1850 }),
1851 );
1852
1853 it.effect("branch PR lookup rejects ambiguous deleted-branch remote refs", () =>
1854 Effect.gen(function* () {
1855 const repoDir = yield* makeTempDir("t3code-git-manager-");
1856 yield* initRepo(repoDir);
1857 const originDir = yield* createBareRemote();
1858 const forkDir = yield* createBareRemote();
1859 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1860 yield* runGit(repoDir, ["remote", "add", "fork", forkDir]);
1861 yield* runGit(repoDir, ["checkout", "-b", "feature/ambiguous-remote"]);
1862 yield* runGit(repoDir, ["push", "origin", "feature/ambiguous-remote"]);
1863 yield* runGit(repoDir, ["push", "fork", "feature/ambiguous-remote"]);
1864 yield* runGit(repoDir, ["checkout", "main"]);
1865 yield* runGit(repoDir, ["branch", "-D", "feature/ambiguous-remote"]);
1866 const { manager, ghCalls } = yield* makeManager();
1867
1868 const error = yield* manager
1869 .branchPullRequest({ cwd: repoDir, branch: "feature/ambiguous-remote" })
1870 .pipe(Effect.flip);
1871
1872 expect(error).toMatchObject({
1873 _tag: "GitManagerError",
1874 detail: "Multiple remotes track feature/ambiguous-remote. Its pull request is ambiguous.",
1875 });
1876 expect(ghCalls).toHaveLength(0);
1877 }),
1878 );
1879
1880 it.effect("finds a fork PR for a branch pushed without an upstream", () =>
1881 Effect.gen(function* () {
1882 const repoDir = yield* makeTempDir("t3code-git-manager-");
1883 yield* initRepo(repoDir);
1884 const originDir = yield* createBareRemote();
1885 const forkDir = yield* createBareRemote();
1886 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1887 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1888 yield* runGit(repoDir, ["remote", "add", "fork", forkDir]);
1889 yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-fork"]);
1890 yield* runGit(repoDir, ["push", "fork", "feature/no-upstream-fork"]);
1891 yield* configureVisibleRemoteUrlWithLocalRewrite(
1892 repoDir,
1893 "origin",
1894 "git@github.com:pingdotgg/codething-mvp.git",
1895 originDir,
1896 );
1897 yield* configureVisibleRemoteUrlWithLocalRewrite(
1898 repoDir,
1899 "fork",
1900 "git@github.com:contributor/codething-mvp.git",
1901 forkDir,
1902 );
1903
1904 const ghScenario = {
1905 prListByHeadSelector: {
1906 "feature/no-upstream-fork": JSON.stringify([
1907 {
1908 number: 40,
1909 title: "Another fork's PR",
1910 url: "https://github.com/pingdotgg/codething-mvp/pull/40",
1911 baseRefName: "main",
1912 headRefName: "feature/no-upstream-fork",
1913 state: "OPEN",
1914 isCrossRepository: true,
1915 headRepository: { nameWithOwner: "someone-else/codething-mvp" },
1916 headRepositoryOwner: { login: "someone-else" },
1917 },
1918 {
1919 number: 41,
1920 title: "Our fork's PR",
1921 url: "https://github.com/pingdotgg/codething-mvp/pull/41",
1922 baseRefName: "main",
1923 headRefName: "feature/no-upstream-fork",
1924 state: "OPEN",
1925 isCrossRepository: true,
1926 headRepository: { nameWithOwner: "contributor/codething-mvp" },
1927 headRepositoryOwner: { login: "contributor" },
1928 },
1929 ]),
1930 },
1931 };
1932 const { manager } = yield* makeManager({ ghScenario });
1933
1934 const pullRequest = yield* manager.branchPullRequest({
1935 cwd: repoDir,
1936 branch: "feature/no-upstream-fork",
1937 });
1938 expect(pullRequest?.number).toBe(41);
1939 expect((yield* manager.status({ cwd: repoDir })).pr?.number).toBe(41);
1940
1941 yield* runGit(repoDir, ["commit", "--allow-empty", "-m", "Continue feature"]);
1942 const localHead = yield* runGit(repoDir, ["rev-parse", "HEAD"]);
1943 const pushedHead = yield* runGit(repoDir, [
1944 "rev-parse",
1945 "refs/remotes/fork/feature/no-upstream-fork",
1946 ]);
1947 expect(localHead.stdout.trim()).not.toBe(pushedHead.stdout.trim());
1948 const { manager: aheadManager } = yield* makeManager({ ghScenario });
1949 expect(
1950 (yield* aheadManager.branchPullRequest({
1951 cwd: repoDir,
1952 branch: "feature/no-upstream-fork",
1953 }))?.number,
1954 ).toBe(41);
1955 expect((yield* aheadManager.status({ cwd: repoDir })).pr?.number).toBe(41);
1956
1957 yield* runGit(repoDir, ["config", "branch.feature/no-upstream-fork.remote", "origin"]);
1958 const upstream = yield* runGit(repoDir, [
1959 "for-each-ref",
1960 "--format=%(upstream:short)",
1961 "refs/heads/feature/no-upstream-fork",
1962 ]);
1963 expect(upstream.stdout.trim()).toBe("");
1964 const { manager: staleRemoteManager } = yield* makeManager({ ghScenario });
1965 expect(
1966 (yield* staleRemoteManager.branchPullRequest({
1967 cwd: repoDir,
1968 branch: "feature/no-upstream-fork",
1969 }))?.number,
1970 ).toBe(41);
1971
1972 yield* runGit(repoDir, ["config", "--unset", "branch.feature/no-upstream-fork.remote"]);
1973 yield* runGit(repoDir, ["checkout", "-b", "diverged-push", "main"]);
1974 yield* runGit(repoDir, ["commit", "--allow-empty", "-m", "Rewrite fork head"]);
1975 yield* runGit(repoDir, [
1976 "push",
1977 "--force",
1978 "fork",
1979 "HEAD:refs/heads/feature/no-upstream-fork",
1980 ]);
1981 yield* runGit(repoDir, ["checkout", "feature/no-upstream-fork"]);
1982 const { manager: divergedManager } = yield* makeManager({ ghScenario });
1983 expect(
1984 yield* divergedManager.branchPullRequest({
1985 cwd: repoDir,
1986 branch: "feature/no-upstream-fork",
1987 }),
1988 ).toBeNull();
1989 }),
1990 );
1991
1992 it.effect("prefers an exact fork ref but does not guess between equal refs", () =>
1993 Effect.gen(function* () {
1994 const repoDir = yield* makeTempDir("t3code-git-manager-");
1995 yield* initRepo(repoDir);
1996 const originDir = yield* createBareRemote();
1997 const firstForkDir = yield* createBareRemote();
1998 const secondForkDir = yield* createBareRemote();
1999 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
2000 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2001 yield* runGit(repoDir, ["remote", "add", "first-fork", firstForkDir]);
2002 yield* runGit(repoDir, ["remote", "add", "second-fork", secondForkDir]);
2003 yield* runGit(repoDir, ["checkout", "-b", "feature/ambiguous-fork"]);
2004 yield* runGit(repoDir, ["push", "first-fork", "feature/ambiguous-fork"]);
2005 yield* configureVisibleRemoteUrlWithLocalRewrite(
2006 repoDir,
2007 "origin",
2008 "git@github.com:pingdotgg/codething-mvp.git",
2009 originDir,
2010 );
2011 yield* configureVisibleRemoteUrlWithLocalRewrite(
2012 repoDir,
2013 "first-fork",
2014 "git@github.com:contributor/codething-mvp.git",
2015 firstForkDir,
2016 );
2017 yield* configureVisibleRemoteUrlWithLocalRewrite(
2018 repoDir,
2019 "second-fork",
2020 "git@github.com:someone-else/codething-mvp.git",
2021 secondForkDir,
2022 );
2023
2024 const ghScenario = {
2025 prListByHeadSelector: {
2026 "feature/ambiguous-fork": JSON.stringify([
2027 {
2028 number: 41,
2029 title: "One fork's PR",
2030 url: "https://github.com/pingdotgg/codething-mvp/pull/41",
2031 baseRefName: "main",
2032 headRefName: "feature/ambiguous-fork",
2033 state: "OPEN",
2034 isCrossRepository: true,
2035 headRepository: { nameWithOwner: "contributor/codething-mvp" },
2036 headRepositoryOwner: { login: "contributor" },
2037 },
2038 ]),
2039 },
2040 };
2041 const { manager: singleRemoteManager } = yield* makeManager({ ghScenario });
2042 expect(
2043 (yield* singleRemoteManager.branchPullRequest({
2044 cwd: repoDir,
2045 branch: "feature/ambiguous-fork",
2046 }))?.number,
2047 ).toBe(41);
2048
2049 yield* runGit(repoDir, ["push", "second-fork", "feature/ambiguous-fork"]);
2050 const { manager } = yield* makeManager({ ghScenario });
2051 expect(
2052 yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/ambiguous-fork" }),
2053 ).toBeNull();
2054
2055 yield* runGit(repoDir, ["commit", "--allow-empty", "-m", "Continue feature"]);
2056 yield* runGit(repoDir, ["push", "first-fork", "feature/ambiguous-fork"]);
2057 const { manager: exactManager } = yield* makeManager({ ghScenario });
2058 expect(
2059 (yield* exactManager.branchPullRequest({
2060 cwd: repoDir,
2061 branch: "feature/ambiguous-fork",
2062 }))?.number,
2063 ).toBe(41);
2064 }),
2065 );
2066
2067 it.effect("does not guess a fork when an ancestry probe fails", () =>
2068 Effect.gen(function* () {
2069 const repoDir = yield* makeTempDir("t3code-git-manager-");
2070 yield* initRepo(repoDir);
2071 const originDir = yield* createBareRemote();
2072 const forkDir = yield* createBareRemote();
2073 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
2074 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2075 yield* runGit(repoDir, ["remote", "add", "fork", forkDir]);
2076 yield* runGit(repoDir, ["checkout", "-b", "feature/failed-probe"]);
2077 yield* runGit(repoDir, ["push", "fork", "feature/failed-probe"]);
2078 yield* runGit(repoDir, ["push", "origin", "feature/failed-probe"]);
2079 // Local work past both tips, so no exact ref matches and the ancestor
2080 // tier decides.
2081 yield* runGit(repoDir, ["commit", "--allow-empty", "-m", "Local work"]);
2082 // Simulate a failed ancestry probe: point origin's tracking ref at a
2083 // non-commit object so `merge-base --is-ancestor` exits 128 — neither
2084 // a match (0) nor a negative result (1).
2085 const treeOid = (yield* runGit(repoDir, ["rev-parse", "HEAD^{tree}"])).stdout.trim();
2086 yield* runGit(repoDir, ["update-ref", "refs/remotes/origin/feature/failed-probe", treeOid]);
2087 const forkProbe = yield* runGit(
2088 repoDir,
2089 ["merge-base", "--is-ancestor", "refs/remotes/fork/feature/failed-probe", "HEAD"],
2090 true,
2091 );
2092 expect(forkProbe.exitCode).toBe(0);
2093 const originProbe = yield* runGit(
2094 repoDir,
2095 ["merge-base", "--is-ancestor", "refs/remotes/origin/feature/failed-probe", "HEAD"],
2096 true,
2097 );
2098 expect(originProbe.exitCode).not.toBe(0);
2099 expect(originProbe.exitCode).not.toBe(1);
2100 yield* configureVisibleRemoteUrlWithLocalRewrite(
2101 repoDir,
2102 "origin",
2103 "git@github.com:pingdotgg/codething-mvp.git",
2104 originDir,
2105 );
2106 yield* configureVisibleRemoteUrlWithLocalRewrite(
2107 repoDir,
2108 "fork",
2109 "git@github.com:contributor/codething-mvp.git",
2110 forkDir,
2111 );
2112
2113 const ghScenario = {
2114 prListByHeadSelector: {
2115 "feature/failed-probe": JSON.stringify([
2116 {
2117 number: 43,
2118 title: "Failed probe PR",
2119 url: "https://github.com/pingdotgg/codething-mvp/pull/43",
2120 baseRefName: "main",
2121 headRefName: "feature/failed-probe",
2122 state: "OPEN",
2123 isCrossRepository: true,
2124 headRepository: { nameWithOwner: "contributor/codething-mvp" },
2125 headRepositoryOwner: { login: "contributor" },
2126 },
2127 ]),
2128 },
2129 };
2130 const { manager } = yield* makeManager({ ghScenario });
2131 // The fork probe matches, but the failed origin probe rejects the
2132 // whole inference: unknown, invisible rather than wrong.
2133 expect(
2134 yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/failed-probe" }),
2135 ).toBeNull();
2136 }),
2137 );
2138
2139 it.effect("finds a fork PR when the remote name contains a slash", () =>
2140 Effect.gen(function* () {
2141 const repoDir = yield* makeTempDir("t3code-git-manager-");
2142 yield* initRepo(repoDir);
2143 const originDir = yield* createBareRemote();
2144 const forkDir = yield* createBareRemote();
2145 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
2146 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2147 yield* runGit(repoDir, ["remote", "add", "team/fork", forkDir]);
2148 yield* runGit(repoDir, ["checkout", "-b", "feature/slash-remote"]);
2149 yield* runGit(repoDir, ["push", "team/fork", "feature/slash-remote"]);
2150 yield* configureVisibleRemoteUrlWithLocalRewrite(
2151 repoDir,
2152 "origin",
2153 "git@github.com:pingdotgg/codething-mvp.git",
2154 originDir,
2155 );
2156 yield* configureVisibleRemoteUrlWithLocalRewrite(
2157 repoDir,
2158 "team/fork",
2159 "git@github.com:contributor/codething-mvp.git",
2160 forkDir,
2161 );
2162
2163 const { manager } = yield* makeManager({
2164 ghScenario: {
2165 prListByHeadSelector: {
2166 "feature/slash-remote": JSON.stringify([
2167 {
2168 number: 42,
2169 title: "Slash remote PR",
2170 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
2171 baseRefName: "main",
2172 headRefName: "feature/slash-remote",
2173 state: "OPEN",
2174 isCrossRepository: true,
2175 headRepository: { nameWithOwner: "contributor/codething-mvp" },
2176 headRepositoryOwner: { login: "contributor" },
2177 },
2178 ]),
2179 },
2180 },
2181 });
2182
2183 expect(
2184 (yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/slash-remote" }))
2185 ?.number,
2186 ).toBe(42);
2187 }),
2188 );
2189
2190 it.effect("branch PR lookup does not reuse a cached PR after the remote is repointed", () =>
2191 Effect.gen(function* () {
2192 const repoDir = yield* makeTempDir("t3code-git-manager-");
2193 yield* initRepo(repoDir);
2194 const originalRemoteDir = yield* createBareRemote();
2195 yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]);
2196 yield* runGit(repoDir, ["checkout", "-b", "feature/repointed-lookup"]);
2197 yield* runGit(repoDir, ["push", "-u", "origin", "feature/repointed-lookup"]);
2198 yield* configureVisibleRemoteUrlWithLocalRewrite(
2199 repoDir,
2200 "origin",
2201 "git@github.com:old-owner/old-repository.git",
2202 originalRemoteDir,
2203 );
2204 const { manager, ghCalls } = yield* makeManager({
2205 ghScenario: {
2206 prListSequence: [
2207 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
2208 JSON.stringify([
2209 {
2210 number: 219,
2211 title: "Old repository PR",
2212 url: "https://github.com/old-owner/old-repository/pull/219",
2213 baseRefName: "main",
2214 headRefName: "feature/repointed-lookup",
2215 state: "MERGED",
2216 updatedAt: "2026-04-06T15:00:00Z",
2217 },
2218 ]),
2219 "[]",
2220 ],
2221 },
2222 });
2223
2224 const first = yield* manager.branchPullRequest({
2225 cwd: repoDir,
2226 branch: "feature/repointed-lookup",
2227 });
2228 expect(first?.state).toBe("merged");
2229
2230 const replacementRemoteDir = yield* createBareRemote();
2231 yield* configureVisibleRemoteUrlWithLocalRewrite(
2232 repoDir,
2233 "origin",
2234 "git@github.com:new-owner/new-repository.git",
2235 replacementRemoteDir,
2236 );
2237
2238 const second = yield* manager.branchPullRequest({
2239 cwd: repoDir,
2240 branch: "feature/repointed-lookup",
2241 });
2242
2243 expect(second).toBeNull();
2244 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);
2245 }),
2246 );
2247
2248 it.effect("branch PR lookup shares the status cache for the same repository identity", () =>
2249 Effect.gen(function* () {
2250 const repoDir = yield* makeTempDir("t3code-git-manager-");
2251 yield* initRepo(repoDir);
2252 const remoteDir = yield* createBareRemote();
2253 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2254 yield* runGit(repoDir, ["checkout", "-b", "feature/shared-pr-cache"]);
2255 yield* runGit(repoDir, ["push", "-u", "origin", "feature/shared-pr-cache"]);
2256 const { manager, ghCalls } = yield* makeManager({
2257 ghScenario: {
2258 prListSequence: [
2259 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
2260 JSON.stringify([
2261 {
2262 number: 220,
2263 title: "Shared cache PR",
2264 url: "https://github.com/pingdotgg/codething-mvp/pull/220",
2265 baseRefName: "main",
2266 headRefName: "feature/shared-pr-cache",
2267 headRefOid: "a".repeat(40),
2268 state: "MERGED",
2269 updatedAt: "2026-04-07T15:00:00Z",
2270 },
2271 ]),
2272 encodeCliJson([
2273 {
2274 number: 221,
2275 title: "New PR on the same branch",
2276 url: "https://github.com/pingdotgg/codething-mvp/pull/221",
2277 baseRefName: "main",
2278 headRefName: "feature/shared-pr-cache",
2279 headRefOid: "b".repeat(40),
2280 state: "OPEN",
2281 updatedAt: "2026-04-08T15:00:00Z",
2282 },
2283 ]),
2284 ],
2285 },
2286 });
2287
2288 const status = yield* manager.status({ cwd: repoDir });
2289 const pullRequest = yield* manager.branchPullRequest({
2290 cwd: repoDir,
2291 branch: "feature/shared-pr-cache",
2292 });
2293
2294 expect(status.pr?.state).toBe("merged");
2295 expect(pullRequest?.state).toBe("merged");
2296 expect(pullRequest?.headSha).toBe("a".repeat(40));
2297 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);
2298 const refreshed = yield* manager.branchPullRequest(
2299 { cwd: repoDir, branch: "feature/shared-pr-cache" },
2300 { refresh: true },
2301 );
2302 expect(refreshed).toMatchObject({
2303 number: 221,
2304 state: "open",
2305 headSha: "b".repeat(40),
2306 repositoryKey: "github.com/pingdotgg/codething-mvp",
2307 });
2308 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);
2309 }),
2310 );
2311
2312 it.effect("branch PR lookup rechecks open PRs every minute and settled answers less often", () =>
2313 Effect.gen(function* () {
2314 const repoDir = yield* makeTempDir("t3code-git-manager-");
2315 yield* initRepo(repoDir);
2316 const remoteDir = yield* createBareRemote();
2317 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2318 for (const branch of ["feature/open-pr", "feature/merged-pr", "feature/no-pr"]) {
2319 yield* runGit(repoDir, ["checkout", "-b", branch, "main"]);
2320 yield* runGit(repoDir, ["push", "-u", "origin", branch]);
2321 }
2322 const pullRequest = (number: number, headRefName: string, state: string) => ({
2323 number,
2324 title: headRefName,
2325 url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`,
2326 baseRefName: "main",
2327 headRefName,
2328 state,
2329 updatedAt: "2026-04-07T15:00:00Z",
2330 });
2331 const { manager, ghCalls } = yield* makeManager({
2332 ghScenario: {
2333 prListByHeadSelector: {
2334 "feature/open-pr": encodeCliJson([pullRequest(301, "feature/open-pr", "OPEN")]),
2335 "feature/merged-pr": encodeCliJson([pullRequest(302, "feature/merged-pr", "MERGED")]),
2336 },
2337 },
2338 });
2339 const lookupAll = Effect.forEach(
2340 ["feature/open-pr", "feature/merged-pr", "feature/no-pr"],
2341 (branch) => manager.branchPullRequest({ cwd: repoDir, branch }),
2342 );
2343 const prListCalls = () => ghCalls.filter((call) => call.startsWith("pr list "));
2344
2345 yield* lookupAll;
2346 expect(prListCalls()).toHaveLength(3);
2347
2348 yield* TestClock.adjust("61 seconds");
2349 yield* lookupAll;
2350 expect(prListCalls()).toHaveLength(4);
2351 expect(prListCalls().at(-1)).toContain("--head feature/open-pr");
2352
2353 // Just inside the 5-minute window only the open PR is asked again.
2354 yield* TestClock.adjust("238 seconds");
2355 yield* lookupAll;
2356 expect(prListCalls()).toHaveLength(5);
2357 expect(prListCalls().at(-1)).toContain("--head feature/open-pr");
2358
2359 // Just past it the settled answers expire too.
2360 yield* TestClock.adjust("2 seconds");
2361 yield* lookupAll;
2362 expect(prListCalls()).toHaveLength(7);
2363 expect(prListCalls().slice(-2).join("\n")).not.toContain("--head feature/open-pr");
2364 }),
2365 );
2366
2367 it.effect("branch PR lookup announces a pull request when it reads it merged", () =>
2368 Effect.scoped(
2369 Effect.gen(function* () {
2370 const repoDir = yield* makeTempDir("t3code-git-manager-");
2371 yield* initRepo(repoDir);
2372 const remoteDir = yield* createBareRemote();
2373 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2374 for (const branch of ["feature/merges-later", "feature/already-merged"]) {
2375 yield* runGit(repoDir, ["checkout", "-b", branch, "main"]);
2376 yield* runGit(repoDir, ["push", "-u", "origin", branch]);
2377 }
2378 const pullRequest = (number: number, headRefName: string, state: string) =>
2379 encodeCliJson([
2380 {
2381 number,
2382 title: headRefName,
2383 url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`,
2384 baseRefName: "main",
2385 headRefName,
2386 state,
2387 updatedAt: "2026-04-07T15:00:00Z",
2388 },
2389 ]);
2390 const { manager } = yield* makeManager({
2391 ghScenario: {
2392 prListSequenceByHeadSelector: {
2393 "feature/merges-later": [
2394 pullRequest(401, "feature/merges-later", "OPEN"),
2395 pullRequest(401, "feature/merges-later", "MERGED"),
2396 ],
2397 "feature/already-merged": [pullRequest(402, "feature/already-merged", "MERGED")],
2398 },
2399 },
2400 });
2401 const changes = yield* manager.subscribePullRequestStateChanges;
2402 const lookup = (branch: string) => manager.branchPullRequest({ cwd: repoDir, branch });
2403
2404 yield* lookup("feature/merges-later");
2405 yield* lookup("feature/already-merged");
2406 // Open answers are re-read after a minute.
2407 yield* TestClock.adjust("61 seconds");
2408 expect((yield* lookup("feature/merges-later"))?.state).toBe("merged");
2409
2410 const announced = yield* Stream.runCollect(Stream.take(changes, 2));
2411 expect(announced).toEqual([
2412 { host: "github.com", repository: "pingdotgg/codething-mvp", number: 402 },
2413 { host: "github.com", repository: "pingdotgg/codething-mvp", number: 401 },
2414 ]);
2415 }),
2416 ),
2417 );
2418
2419 it.effect("branch PR lookup propagates provider failures", () =>
2420 Effect.gen(function* () {
2421 const repoDir = yield* makeTempDir("t3code-git-manager-");
2422 yield* initRepo(repoDir);
2423 const remoteDir = yield* createBareRemote();
2424 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2425 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2426 yield* runGit(repoDir, ["checkout", "-b", "feature/lookup-failure"]);
2427 yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-failure"]);
2428 yield* runGit(repoDir, ["checkout", "main"]);
2429
2430 const { manager, ghCalls } = yield* makeManager({
2431 ghScenario: {
2432 failWith:
2433 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
2434 },
2435 });
2436
2437 const error = yield* manager
2438 .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" })
2439 .pipe(Effect.flip);
2440
2441 expect(error._tag).toBe("SourceControlProviderError");
2442 const refreshError = yield* manager
2443 .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" }, { refresh: true })
2444 .pipe(Effect.flip);
2445 expect(refreshError._tag).toBe("SourceControlProviderError");
2446 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);
2447 }),
2448 );
2449
2450 it.effect("status finds a merged PR after its remote branch was deleted", () =>
2451 Effect.gen(function* () {
2452 const repoDir = yield* makeTempDir("t3code-git-manager-");
2453 yield* initRepo(repoDir);
2454 const remoteDir = yield* createBareRemote();
2455 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2456 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2457 yield* runGit(repoDir, ["checkout", "-b", "feature/merged-branch-deleted"]);
2458 yield* runGit(repoDir, ["push", "-u", "origin", "feature/merged-branch-deleted"]);
2459
2460 // GitHub commonly deletes a pull request's head branch after merge. Git
2461 // removes the remote-tracking ref, but preserves the local branch's
2462 // remote and merge configuration as evidence that it was published.
2463 yield* runGit(repoDir, ["push", "origin", "--delete", "feature/merged-branch-deleted"]);
2464 const configuredRemote = yield* runGit(repoDir, [
2465 "config",
2466 "--get",
2467 "branch.feature/merged-branch-deleted.remote",
2468 ]);
2469 const configuredMerge = yield* runGit(repoDir, [
2470 "config",
2471 "--get",
2472 "branch.feature/merged-branch-deleted.merge",
2473 ]);
2474 const trackingRef = yield* runGit(repoDir, [
2475 "for-each-ref",
2476 "--format=%(refname)",
2477 "refs/remotes/origin/feature/merged-branch-deleted",
2478 ]);
2479 expect(configuredRemote.stdout.trim()).toBe("origin");
2480 expect(configuredMerge.stdout.trim()).toBe("refs/heads/feature/merged-branch-deleted");
2481 expect(trackingRef.stdout.trim()).toBe("");
2482
2483 const { manager, ghCalls } = yield* makeManager({
2484 ghScenario: {
2485 prListSequence: [
2486 JSON.stringify([
2487 {
2488 number: 215,
2489 title: "Merged branch was deleted",
2490 url: "https://github.com/pingdotgg/t3code/pull/215",
2491 baseRefName: "main",
2492 headRefName: "feature/merged-branch-deleted",
2493 state: "MERGED",
2494 mergedAt: "2026-04-02T15:00:00Z",
2495 updatedAt: "2026-04-02T15:00:00Z",
2496 },
2497 ]),
2498 ],
2499 },
2500 });
2501
2502 const status = yield* manager.status({ cwd: repoDir });
2503
2504 expect(status.hasUpstream).toBe(false);
2505 expect(status.pr).toEqual({
2506 number: 215,
2507 title: "Merged branch was deleted",
2508 url: "https://github.com/pingdotgg/t3code/pull/215",
2509 baseRef: "main",
2510 headRef: "feature/merged-branch-deleted",
2511 state: "merged",
2512 updatedAt: "2026-04-02T15:00:00.000Z",
2513 });
2514 expect(ghCalls.filter((call) => call.startsWith("pr list ")).length).toBeGreaterThan(0);
2515 }),
2516 );
2517
2518 it.effect("status still looks up PRs for a branch pushed without --set-upstream", () =>
2519 Effect.gen(function* () {
2520 const repoDir = yield* makeTempDir("t3code-git-manager-");
2521 yield* initRepo(repoDir);
2522 const remoteDir = yield* createBareRemote();
2523 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2524 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2525 yield* runGit(repoDir, ["checkout", "-b", "feature/pushed-no-upstream"]);
2526 // No `-u`, so the remote-tracking ref exists but branch.<name>.merge does
2527 // not. Most terminal and agent pushes land this way, and they can still
2528 // have a PR, so the skip must not trigger here.
2529 yield* runGit(repoDir, ["push", "origin", "feature/pushed-no-upstream"]);
2530
2531 const { manager, ghCalls } = yield* makeManager({
2532 ghScenario: {
2533 prListSequence: [
2534 JSON.stringify([
2535 {
2536 number: 214,
2537 title: "Pushed without upstream",
2538 url: "https://github.com/pingdotgg/t3code/pull/214",
2539 baseRefName: "main",
2540 headRefName: "feature/pushed-no-upstream",
2541 state: "OPEN",
2542 updatedAt: "2026-04-01T15:00:00Z",
2543 },
2544 ]),
2545 ],
2546 },
2547 });
2548
2549 const status = yield* manager.status({ cwd: repoDir });
2550
2551 expect(status.pr?.number).toBe(214);
2552 expect(ghCalls.filter((call) => call.startsWith("pr list ")).length).toBeGreaterThan(0);
2553 }),
2554 );
2555
2556 it("backs off repeated PR lookup failures past the healthy refresh cadence", () => {
2557 expect(Duration.toMillis(GitManager.prLookupFailureTtl(1))).toBe(20_000);
2558 expect(Duration.toMillis(GitManager.prLookupFailureTtl(2))).toBe(40_000);
2559 // The point of the backoff: by the third retry a failing branch must not be
2560 // asking more often than a healthy one, which refreshes every 2 minutes.
2561 expect(Duration.toMillis(GitManager.prLookupFailureTtl(4))).toBeGreaterThan(120_000);
2562 expect(Duration.toMillis(GitManager.prLookupFailureTtl(20))).toBe(900_000);
2563 });
2564
2565 it.each([
2566 [
2567 "https://github.example.com/team/repository/pull/42?tab=files",
2568 "github.example.com/team/repository",
2569 ],
2570 [
2571 "https://gitlab.example.com/group/subgroup/repository/-/merge_requests/42",
2572 "gitlab.example.com/group/subgroup/repository",
2573 ],
2574 ["https://bitbucket.org/team/repository/pull-requests/42", "bitbucket.org/team/repository"],
2575 [
2576 "https://dev.azure.com/org/project/_git/repository/pullrequest/42",
2577 "dev.azure.com/org/project/_git/repository",
2578 ],
2579 [
2580 "https://org.visualstudio.com/project/_git/repository/pullrequest/42",
2581 "org.visualstudio.com/project/_git/repository",
2582 ],
2583 [
2584 "https://gitlab.example/group/pull/123/repository/-/merge_requests/42",
2585 "gitlab.example/group/pull/123/repository",
2586 ],
2587 ["https://github.example.com/team/repository/issues/42", null],
2588 ] as const)("reads the repository from the returned PR URL %s", (url, expected) => {
2589 expect(GitManager.pullRequestRepositoryKey(url)).toBe(expected);
2590 });
2591
2592 it.effect("distinguishes Enterprise forks with the same head branch", () =>
2593 Effect.gen(function* () {
2594 const repoDir = yield* makeTempDir("t3code-git-manager-");
2595 yield* initRepo(repoDir);
2596 const originDir = yield* createBareRemote();
2597 const forkDir = yield* createBareRemote();
2598 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
2599 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2600 yield* runGit(repoDir, ["remote", "add", "fork", forkDir]);
2601 yield* runGit(repoDir, ["checkout", "-b", "feature"]);
2602 yield* runGit(repoDir, ["push", "-u", "fork", "feature"]);
2603 yield* configureVisibleRemoteUrlWithLocalRewrite(
2604 repoDir,
2605 "origin",
2606 "git@github.example.com:team/repository.git",
2607 originDir,
2608 );
2609 yield* configureVisibleRemoteUrlWithLocalRewrite(
2610 repoDir,
2611 "fork",
2612 "git@github.example.com:alice/repository.git",
2613 forkDir,
2614 );
2615 const output = encodeCliJson([
2616 {
2617 number: 2,
2618 title: "Another fork",
2619 url: "https://github.example.com/team/repository/pull/2",
2620 baseRefName: "main",
2621 headRefName: "feature",
2622 state: "OPEN",
2623 updatedAt: "2026-04-08T15:00:00Z",
2624 isCrossRepository: true,
2625 headRepository: { nameWithOwner: "bob/repository" },
2626 headRepositoryOwner: { login: "bob" },
2627 },
2628 {
2629 number: 1,
2630 title: "This fork",
2631 url: "https://github.example.com/team/repository/pull/1",
2632 baseRefName: "main",
2633 headRefName: "feature",
2634 state: "OPEN",
2635 updatedAt: "2026-04-07T15:00:00Z",
2636 isCrossRepository: true,
2637 headRepository: { nameWithOwner: "alice/repository" },
2638 headRepositoryOwner: { login: "alice" },
2639 },
2640 ]);
2641 const { manager } = yield* makeManager({
2642 ghScenario: {
2643 prListByHeadSelector: {
2644 feature: output,
2645 },
2646 },
2647 });
2648 expect(yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature" })).toMatchObject({
2649 number: 1,
2650 repositoryKey: "github.example.com/team/repository",
2651 });
2652 }),
2653 );
2654
2655 it.effect.each([
2656 { remoteUrl: "git@gitlab.com:Group/Subgroup/Fork.git", setUpstream: true },
2657 { remoteUrl: "git@gitlab.com:Group/Subgroup/Fork.git", setUpstream: false },
2658 { remoteUrl: "https://gitlab.com/Group/Subgroup/Fork.git", setUpstream: true },
2659 { remoteUrl: "https://gitlab.com/Group/Subgroup/Fork.git", setUpstream: false },
2660 ] as const)("matches GitLab forks for $remoteUrl/$setUpstream", ({ remoteUrl, setUpstream }) =>
2661 Effect.gen(function* () {
2662 const repoDir = yield* makeTempDir("t3code-git-manager-");
2663 yield* initRepo(repoDir);
2664 const originDir = yield* createBareRemote();
2665 const forkDir = yield* createBareRemote();
2666 const branch = "feature/NestedGroups";
2667 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
2668 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2669 yield* runGit(repoDir, ["remote", "add", "fork", forkDir]);
2670 yield* runGit(repoDir, ["checkout", "-b", branch]);
2671 yield* runGit(repoDir, ["push", ...(setUpstream ? ["-u"] : []), "fork", branch]);
2672 const upstream = yield* runGit(repoDir, [
2673 "for-each-ref",
2674 "--format=%(upstream:short)",
2675 `refs/heads/${branch}`,
2676 ]);
2677 expect(upstream.stdout.trim()).toBe(setUpstream ? `fork/${branch}` : "");
2678 yield* configureVisibleRemoteUrlWithLocalRewrite(
2679 repoDir,
2680 "origin",
2681 "git@gitlab.com:Group/Upstream/Repository.git",
2682 originDir,
2683 );
2684 yield* configureVisibleRemoteUrlWithLocalRewrite(repoDir, "fork", remoteUrl, forkDir);
2685 const output = encodeCliJson([
2686 {
2687 iid: 2,
2688 title: "Another subgroup's fork",
2689 web_url: "https://gitlab.com/Group/Upstream/Repository/-/merge_requests/2",
2690 target_branch: "main",
2691 source_branch: branch,
2692 state: "opened",
2693 updated_at: "2026-04-08T15:00:00Z",
2694 source_project_id: 102,
2695 target_project_id: 100,
2696 source_project: { path_with_namespace: "Group/Other/Fork" },
2697 },
2698 {
2699 iid: 1,
2700 title: "This subgroup's fork",
2701 web_url: "https://gitlab.com/Group/Upstream/Repository/-/merge_requests/1",
2702 target_branch: "main",
2703 source_branch: branch,
2704 state: "opened",
2705 updated_at: "2026-04-07T15:00:00Z",
2706 source_project_id: 101,
2707 target_project_id: 100,
2708 source_project: { path_with_namespace: "Group/Subgroup/Fork" },
2709 },
2710 ]);
2711 const calls: SourceControlHost.SourceControlProcessInput[] = [];
2712 const provider = yield* GitLabSourceControlProvider.make.pipe(
2713 Effect.provide(
2714 GitLabCli.layer.pipe(
2715 Layer.provide(
2716 TestSourceControlHost.layer({
2717 process: {
2718 run: (input) =>
2719 Effect.sync(() => {
2720 calls.push(input);
2721 return fakeGhOutput(output);
2722 }),
2723 },
2724 }),
2725 ),
2726 ),
2727 ),
2728 );
2729 const { manager } = yield* makeManager({ sourceControlProvider: provider });
2730
2731 expect(yield* manager.branchPullRequest({ cwd: repoDir, branch })).toMatchObject({
2732 number: 1,
2733 repositoryKey: "gitlab.com/group/upstream/repository",
2734 });
2735 expect(calls.length).toBeGreaterThan(0);
2736 for (const call of calls) {
2737 expect(call.command).toBe("glab");
2738 expect(call.args).toEqual([
2739 "mr",
2740 "list",
2741 "--source-branch",
2742 branch,
2743 "--all",
2744 "--per-page",
2745 "20",
2746 "--output",
2747 "json",
2748 ]);
2749 }
2750 }),
2751 );
2752
2753 it.effect(
2754 "status ignores unrelated fork PRs when the current branch tracks the same repository",
2755 () =>
2756 Effect.gen(function* () {
2757 const repoDir = yield* makeTempDir("t3code-git-manager-");
2758 yield* initRepo(repoDir);
2759 const remoteDir = yield* createBareRemote();
2760 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2761 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2762
2763 const { manager } = yield* makeManager({
2764 ghScenario: {
2765 prListSequence: [
2766 JSON.stringify([
2767 {
2768 number: 1661,
2769 title: "Fork PR from main",
2770 url: "https://github.com/pingdotgg/t3code/pull/1661",
2771 baseRefName: "main",
2772 headRefName: "main",
2773 state: "OPEN",
2774 updatedAt: "2026-04-01T15:00:00Z",
2775 isCrossRepository: true,
2776 headRepository: {
2777 nameWithOwner: "lnieuwenhuis/t3code",
2778 },
2779 headRepositoryOwner: {
2780 login: "lnieuwenhuis",
2781 },
2782 },
2783 ]),
2784 ],
2785 },
2786 });
2787
2788 const status = yield* manager.status({ cwd: repoDir });
2789 expect(status.refName).toBe("main");
2790 expect(status.pr).toBeNull();
2791 }),
2792 );
2793
2794 it.effect(
2795 "status detects cross-repo PRs from the upstream remote URL owner",
2796 () =>
2797 Effect.gen(function* () {
2798 const repoDir = yield* makeTempDir("t3code-git-manager-");
2799 yield* initRepo(repoDir);
2800 const forkDir = yield* createBareRemote();
2801 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
2802 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
2803 NodeFS.writeFileSync(NodePath.join(repoDir, "fork-pr.txt"), "fork pr\n");
2804 yield* runGit(repoDir, ["add", "fork-pr.txt"]);
2805 yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]);
2806 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
2807 yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-488/statemachine"]);
2808 yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]);
2809 yield* configureVisibleRemoteUrlWithLocalRewrite(
2810 repoDir,
2811 "fork-seed",
2812 "git@github.com:jasonLaster/codething-mvp.git",
2813 forkDir,
2814 );
2815
2816 const { manager, ghCalls } = yield* makeManager({
2817 ghScenario: {
2818 prListSequence: [
2819 JSON.stringify([
2820 {
2821 number: 488,
2822 title: "Rebase this PR on latest main",
2823 url: "https://github.com/pingdotgg/codething-mvp/pull/488",
2824 baseRefName: "main",
2825 headRefName: "statemachine",
2826 state: "OPEN",
2827 updatedAt: "2026-03-10T07:00:00Z",
2828 isCrossRepository: true,
2829 headRepository: {
2830 nameWithOwner: "jasonLaster/codething-mvp",
2831 },
2832 headRepositoryOwner: {
2833 login: "jasonLaster",
2834 },
2835 },
2836 ]),
2837 ],
2838 },
2839 });
2840
2841 const status = yield* manager.status({ cwd: repoDir });
2842 expect(status.refName).toBe("t3code/pr-488/statemachine");
2843 expect(status.pr).toEqual({
2844 number: 488,
2845 title: "Rebase this PR on latest main",
2846 url: "https://github.com/pingdotgg/codething-mvp/pull/488",
2847 baseRef: "main",
2848 headRef: "statemachine",
2849 state: "open",
2850 updatedAt: "2026-03-10T07:00:00.000Z",
2851 });
2852 expect(ghCalls).toContain(
2853 "pr list --head statemachine --state all --limit 100 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
2854 );
2855 }),
2856 20_000,
2857 );
2858
2859 it.effect(
2860 "status preserves a fork PR whose head is named after the default branch",
2861 () =>
2862 Effect.gen(function* () {
2863 const repoDir = yield* makeTempDir("t3code-git-manager-");
2864 yield* initRepo(repoDir);
2865 const originDir = yield* createBareRemote();
2866 const forkDir = yield* createBareRemote();
2867 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
2868 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2869 yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]);
2870 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
2871 yield* runGit(repoDir, ["push", "fork-seed", "main"]);
2872 yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-777/main"]);
2873 yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/main"]);
2874 yield* configureVisibleRemoteUrlWithLocalRewrite(
2875 repoDir,
2876 "fork-seed",
2877 "git@github.com:contributor/codething-mvp.git",
2878 forkDir,
2879 );
2880
2881 const { manager, ghCalls } = yield* makeManager({
2882 ghScenario: {
2883 prListByHeadSelector: {
2884 main: JSON.stringify([
2885 {
2886 number: 777,
2887 title: "Fork PR from main",
2888 url: "https://github.com/pingdotgg/codething-mvp/pull/777",
2889 baseRefName: "main",
2890 headRefName: "main",
2891 state: "OPEN",
2892 updatedAt: "2026-03-10T07:00:00Z",
2893 isCrossRepository: true,
2894 headRepository: {
2895 nameWithOwner: "contributor/codething-mvp",
2896 },
2897 headRepositoryOwner: {
2898 login: "contributor",
2899 },
2900 },
2901 ]),
2902 },
2903 },
2904 });
2905
2906 const status = yield* manager.status({ cwd: repoDir });
2907 expect(status.refName).toBe("t3code/pr-777/main");
2908 expect(status.pr).toEqual({
2909 number: 777,
2910 title: "Fork PR from main",
2911 url: "https://github.com/pingdotgg/codething-mvp/pull/777",
2912 baseRef: "main",
2913 headRef: "main",
2914 state: "open",
2915 updatedAt: "2026-03-10T07:00:00.000Z",
2916 });
2917 expect(ghCalls).toContain(
2918 "pr list --head main --state all --limit 100 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
2919 );
2920 }),
2921 20_000,
2922 );
2923
2924 it.effect(
2925 "status ignores synthetic local branch aliases when the upstream remote name contains slashes",
2926 () =>
2927 Effect.gen(function* () {
2928 const repoDir = yield* makeTempDir("t3code-git-manager-");
2929 yield* initRepo(repoDir);
2930 const originDir = yield* createBareRemote();
2931 const upstreamDir = yield* createBareRemote();
2932 yield* configureRemote(repoDir, "origin", originDir, "origin");
2933 yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream");
2934
2935 yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]);
2936 yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]);
2937 yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]);
2938 yield* configureVisibleRemoteUrlWithLocalRewrite(
2939 repoDir,
2940 "origin",
2941 "git@github.com:pingdotgg/codething-mvp.git",
2942 originDir,
2943 );
2944 yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]);
2945 yield* configureVisibleRemoteUrlWithLocalRewrite(
2946 repoDir,
2947 "my-org/upstream",
2948 "ssh://git@github.com/pingdotgg/codething-mvp.git",
2949 upstreamDir,
2950 );
2951 yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]);
2952 yield* runGit(repoDir, ["checkout", "main"]);
2953 yield* runGit(repoDir, ["branch", "-D", "effect-atom"]);
2954 yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]);
2955
2956 const { manager, ghCalls } = yield* makeManager({
2957 ghScenario: {
2958 prListByHeadSelector: {
2959 "effect-atom": JSON.stringify([
2960 {
2961 number: 1618,
2962 title: "Correct PR",
2963 url: "https://github.com/pingdotgg/t3code/pull/1618",
2964 baseRefName: "main",
2965 headRefName: "effect-atom",
2966 state: "OPEN",
2967 updatedAt: "2026-03-01T10:00:00Z",
2968 },
2969 ]),
2970 "upstream/effect-atom": JSON.stringify([
2971 {
2972 number: 1518,
2973 title: "Wrong PR",
2974 url: "https://github.com/pingdotgg/t3code/pull/1518",
2975 baseRefName: "main",
2976 headRefName: "upstream/effect-atom",
2977 state: "OPEN",
2978 updatedAt: "2026-04-01T10:00:00Z",
2979 },
2980 ]),
2981 },
2982 },
2983 });
2984
2985 const status = yield* manager.status({ cwd: repoDir });
2986 expect(status.refName).toBe("upstream/effect-atom");
2987 expect(status.pr).toEqual({
2988 number: 1618,
2989 title: "Correct PR",
2990 url: "https://github.com/pingdotgg/t3code/pull/1618",
2991 baseRef: "main",
2992 headRef: "effect-atom",
2993 state: "open",
2994 updatedAt: "2026-03-01T10:00:00.000Z",
2995 });
2996 expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe(
2997 false,
2998 );
2999 expect(
3000 ghCalls.some((call) => call.includes("pr list --head pingdotgg:upstream/effect-atom ")),
3001 ).toBe(false);
3002 expect(
3003 ghCalls.some((call) =>
3004 call.includes("pr list --head my-org/upstream:upstream/effect-atom "),
3005 ),
3006 ).toBe(false);
3007 }),
3008 20_000,
3009 );
3010
3011 it.effect("status returns merged PR state when latest PR was merged", () =>
3012 Effect.gen(function* () {
3013 const repoDir = yield* makeTempDir("t3code-git-manager-");
3014 yield* initRepo(repoDir);
3015 yield* runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]);
3016
3017 const { manager } = yield* makeManager({
3018 ghScenario: {
3019 prListSequence: [
3020 JSON.stringify([
3021 {
3022 number: 22,
3023 title: "Merged PR",
3024 url: "https://github.com/pingdotgg/codething-mvp/pull/22",
3025 baseRefName: "main",
3026 headRefName: "feature/status-merged-pr",
3027 state: "MERGED",
3028 mergedAt: "2026-01-30T10:00:00Z",
3029 updatedAt: "2026-01-30T10:00:00Z",
3030 },
3031 ]),
3032 ],
3033 },
3034 });
3035
3036 const status = yield* manager.status({ cwd: repoDir });
3037 expect(status.refName).toBe("feature/status-merged-pr");
3038 expect(status.pr).toEqual({
3039 number: 22,
3040 title: "Merged PR",
3041 url: "https://github.com/pingdotgg/codething-mvp/pull/22",
3042 baseRef: "main",
3043 headRef: "feature/status-merged-pr",
3044 state: "merged",
3045 updatedAt: "2026-01-30T10:00:00.000Z",
3046 });
3047 }),
3048 );
3049
3050 it.effect("status hides merged PRs on the default branch", () =>
3051 Effect.gen(function* () {
3052 const repoDir = yield* makeTempDir("t3code-git-manager-");
3053 yield* initRepo(repoDir);
3054
3055 const { manager } = yield* makeManager({
3056 ghScenario: {
3057 prListSequence: [
3058 JSON.stringify([
3059 {
3060 number: 23,
3061 title: "Merged PR",
3062 url: "https://github.com/pingdotgg/codething-mvp/pull/23",
3063 baseRefName: "feature/status-default-branch-target",
3064 headRefName: "main",
3065 state: "MERGED",
3066 mergedAt: "2026-01-30T10:00:00Z",
3067 updatedAt: "2026-01-30T10:00:00Z",
3068 },
3069 ]),
3070 ],
3071 },
3072 });
3073
3074 const status = yield* manager.status({ cwd: repoDir });
3075 expect(status.refName).toBe("main");
3076 expect(status.pr).toBeNull();
3077 }),
3078 );
3079
3080 it.effect("status does not inherit a merged PR from a feature branch's default upstream", () =>
3081 Effect.gen(function* () {
3082 const repoDir = yield* makeTempDir("t3code-git-manager-");
3083 yield* initRepo(repoDir);
3084 const remoteDir = yield* createBareRemote();
3085 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
3086 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
3087 yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]);
3088 yield* runGit(repoDir, ["checkout", "-b", "feature/from-main", "origin/main"]);
3089
3090 const { manager, ghCalls } = yield* makeManager({
3091 ghScenario: {
3092 prListSequence: [
3093 JSON.stringify([
3094 {
3095 number: 54,
3096 title: "Reverse merge from main",
3097 url: "https://github.com/pingdotgg/codething-mvp/pull/54",
3098 baseRefName: "je-filter-list",
3099 headRefName: "main",
3100 state: "MERGED",
3101 mergedAt: "2023-09-28T03:21:10Z",
3102 updatedAt: "2023-09-28T03:21:10Z",
3103 },
3104 ]),
3105 ],
3106 },
3107 });
3108
3109 const status = yield* manager.status({ cwd: repoDir });
3110 expect(status.refName).toBe("feature/from-main");
3111 expect(status.pr).toBeNull();
3112 expect(ghCalls.some((call) => call.includes("pr list"))).toBe(false);
3113 }),
3114 );
3115
3116 it.effect("status finds a PR pushed under the branch's own name despite a default upstream", () =>
3117 Effect.gen(function* () {
3118 const repoDir = yield* makeTempDir("t3code-git-manager-");
3119 yield* initRepo(repoDir);
3120 const remoteDir = yield* createBareRemote();
3121 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
3122 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
3123 yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]);
3124 yield* runGit(repoDir, ["checkout", "-b", "feature/pushed-plain", "origin/main"]);
3125 // A plain push (no -u) leaves the upstream on origin/main.
3126 yield* runGit(repoDir, ["push", "origin", "feature/pushed-plain"]);
3127
3128 const { manager, ghCalls } = yield* makeManager({
3129 ghScenario: {
3130 prListByHeadSelector: {
3131 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
3132 "feature/pushed-plain": JSON.stringify([
3133 {
3134 number: 88,
3135 title: "Pushed without -u",
3136 url: "https://github.com/pingdotgg/codething-mvp/pull/88",
3137 baseRefName: "main",
3138 headRefName: "feature/pushed-plain",
3139 state: "OPEN",
3140 updatedAt: "2026-05-01T10:00:00Z",
3141 },
3142 ]),
3143 },
3144 },
3145 });
3146
3147 const status = yield* manager.status({ cwd: repoDir });
3148 expect(status.refName).toBe("feature/pushed-plain");
3149 expect(status.pr?.number).toBe(88);
3150 expect(ghCalls.some((call) => call.includes("--head main"))).toBe(false);
3151 }),
3152 );
3153
3154 it.effect(
3155 "status finds a fork PR pushed under the branch's own name despite a default upstream",
3156 () =>
3157 Effect.gen(function* () {
3158 const repoDir = yield* makeTempDir("t3code-git-manager-");
3159 yield* initRepo(repoDir);
3160 const originDir = yield* createBareRemote();
3161 const forkDir = yield* createBareRemote();
3162 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
3163 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
3164 yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]);
3165 yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork");
3166 yield* runGit(repoDir, ["checkout", "-b", "feature/fork-plain", "origin/main"]);
3167 // Pushed to the fork without -u: upstream stays origin/main.
3168 yield* runGit(repoDir, ["push", "team/fork", "feature/fork-plain"]);
3169 yield* configureVisibleRemoteUrlWithLocalRewrite(
3170 repoDir,
3171 "origin",
3172 "git@github.com:pingdotgg/codething-mvp.git",
3173 originDir,
3174 );
3175 yield* configureVisibleRemoteUrlWithLocalRewrite(
3176 repoDir,
3177 "team/fork",
3178 "git@github.com:contributor/codething-mvp.git",
3179 forkDir,
3180 );
3181
3182 const { manager, ghCalls } = yield* makeManager({
3183 ghScenario: {
3184 prListByHeadSelector: {
3185 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
3186 "feature/fork-plain": JSON.stringify([
3187 {
3188 number: 89,
3189 title: "Fork PR pushed without -u",
3190 url: "https://github.com/pingdotgg/codething-mvp/pull/89",
3191 baseRefName: "main",
3192 headRefName: "feature/fork-plain",
3193 state: "OPEN",
3194 updatedAt: "2026-05-01T10:00:00Z",
3195 isCrossRepository: true,
3196 headRepository: { nameWithOwner: "contributor/codething-mvp" },
3197 headRepositoryOwner: { login: "contributor" },
3198 },
3199 ]),
3200 },
3201 },
3202 });
3203
3204 const status = yield* manager.status({ cwd: repoDir });
3205 expect(status.pr?.number).toBe(89);
3206 expect(ghCalls.some((call) => call.includes("--head feature/fork-plain"))).toBe(true);
3207 expect(ghCalls.some((call) => call.includes("--head contributor:"))).toBe(false);
3208 expect(ghCalls.some((call) => call.includes("--head main"))).toBe(false);
3209 }),
3210 );
3211
3212 it.effect("branch PR lookup verifies identity on the fork that holds the own-name ref", () =>
3213 Effect.gen(function* () {
3214 const repoDir = yield* makeTempDir("t3code-git-manager-");
3215 yield* initRepo(repoDir);
3216 const originDir = yield* createBareRemote();
3217 const forkDir = yield* createBareRemote();
3218 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
3219 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
3220 yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]);
3221 yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork");
3222 yield* runGit(repoDir, ["checkout", "-b", "feature/fork-settle", "origin/main"]);
3223 yield* runGit(repoDir, ["push", "team/fork", "feature/fork-settle"]);
3224 yield* configureVisibleRemoteUrlWithLocalRewrite(
3225 repoDir,
3226 "origin",
3227 "git@github.com:pingdotgg/codething-mvp.git",
3228 originDir,
3229 );
3230 yield* configureVisibleRemoteUrlWithLocalRewrite(
3231 repoDir,
3232 "team/fork",
3233 "git@github.com:contributor/codething-mvp.git",
3234 forkDir,
3235 );
3236
3237 const { manager } = yield* makeManager({
3238 ghScenario: {
3239 prListByHeadSelector: {
3240 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
3241 "feature/fork-settle": JSON.stringify([
3242 {
3243 number: 91,
3244 title: "Fork PR to settle",
3245 url: "https://github.com/pingdotgg/codething-mvp/pull/91",
3246 baseRefName: "main",
3247 headRefName: "feature/fork-settle",
3248 state: "MERGED",
3249 updatedAt: "2026-05-02T10:00:00Z",
3250 isCrossRepository: true,
3251 headRepository: { nameWithOwner: "contributor/codething-mvp" },
3252 headRepositoryOwner: { login: "contributor" },
3253 },
3254 ]),
3255 },
3256 },
3257 });
3258
3259 const pullRequest = yield* manager.branchPullRequest({
3260 cwd: repoDir,
3261 branch: "feature/fork-settle",
3262 });
3263
3264 expect(pullRequest).toMatchObject({
3265 state: "merged",
3266 closedAt: null,
3267 mergedAt: null,
3268 updatedAt: "2026-05-02T10:00:00.000Z",
3269 });
3270 }),
3271 );
3272
3273 it.effect("status keeps an own-name PR when a later lookup fails on a default upstream", () =>
3274 Effect.gen(function* () {
3275 const repoDir = yield* makeTempDir("t3code-git-manager-");
3276 yield* initRepo(repoDir);
3277 const remoteDir = yield* createBareRemote();
3278 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
3279 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
3280 yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]);
3281 yield* runGit(repoDir, ["checkout", "-b", "feature/sticky-plain", "origin/main"]);
3282 yield* runGit(repoDir, ["push", "origin", "feature/sticky-plain"]);
3283
3284 const { manager } = yield* makeManager({
3285 ghScenario: {
3286 prListByHeadSelector: {
3287 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
3288 "feature/sticky-plain": JSON.stringify([
3289 {
3290 number: 90,
3291 title: "Sticky own-name PR",
3292 url: "https://github.com/pingdotgg/codething-mvp/pull/90",
3293 baseRefName: "main",
3294 headRefName: "feature/sticky-plain",
3295 state: "OPEN",
3296 updatedAt: "2026-05-01T10:00:00Z",
3297 },
3298 ]),
3299 },
3300 failWith:
3301 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
3302 failAfterCalls: 1,
3303 },
3304 });
3305
3306 const first = yield* manager.status({ cwd: repoDir });
3307 expect(first.pr?.number).toBe(90);
3308
3309 yield* manager.invalidateStatus(repoDir);
3310 const second = yield* manager.status({ cwd: repoDir });
3311 expect(second.pr?.number).toBe(90);
3312 }),
3313 );
3314
3315 it.effect("status prefers open PR when merged PR has newer updatedAt", () =>
3316 Effect.gen(function* () {
3317 const repoDir = yield* makeTempDir("t3code-git-manager-");
3318 yield* initRepo(repoDir);
3319 yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-over-merged"]);
3320
3321 const { manager } = yield* makeManager({
3322 ghScenario: {
3323 prListSequence: [
3324 JSON.stringify([
3325 {
3326 number: 45,
3327 title: "Merged PR",
3328 url: "https://github.com/pingdotgg/codething-mvp/pull/45",
3329 baseRefName: "main",
3330 headRefName: "feature/status-open-over-merged",
3331 state: "MERGED",
3332 mergedAt: "2026-01-31T10:00:00Z",
3333 updatedAt: "2026-02-01T10:00:00Z",
3334 },
3335 {
3336 number: 46,
3337 title: "Open PR",
3338 url: "https://github.com/pingdotgg/codething-mvp/pull/46",
3339 baseRefName: "main",
3340 headRefName: "feature/status-open-over-merged",
3341 state: "OPEN",
3342 updatedAt: "2026-01-30T10:00:00Z",
3343 },
3344 ]),
3345 ],
3346 },
3347 });
3348
3349 const status = yield* manager.status({ cwd: repoDir });
3350 expect(status.refName).toBe("feature/status-open-over-merged");
3351 expect(status.pr).toEqual({
3352 number: 46,
3353 title: "Open PR",
3354 url: "https://github.com/pingdotgg/codething-mvp/pull/46",
3355 baseRef: "main",
3356 headRef: "feature/status-open-over-merged",
3357 state: "open",
3358 updatedAt: "2026-01-30T10:00:00.000Z",
3359 });
3360 }),
3361 );
3362
3363 it.effect("status is resilient to gh lookup failures and returns pr null", () =>
3364 Effect.gen(function* () {
3365 const repoDir = yield* makeTempDir("t3code-git-manager-");
3366 yield* initRepo(repoDir);
3367 yield* runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]);
3368 const remoteDir = yield* createBareRemote();
3369 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
3370 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]);
3371
3372 const { manager } = yield* makeManager({
3373 ghScenario: {
3374 failWith:
3375 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
3376 },
3377 });
3378
3379 const status = yield* manager.status({ cwd: repoDir });
3380 expect(status.refName).toBe("feature/status-no-gh");
3381 expect(status.pr).toBeNull();
3382 }),
3383 );
3384
3385 it.effect("status logs actionable provider detail without exposing the upstream cause", () =>
3386 Effect.gen(function* () {
3387 const repoDir = yield* makeTempDir("t3code-git-manager-");
3388 yield* initRepo(repoDir);
3389 yield* runGit(repoDir, ["checkout", "-b", "feature/status-rate-limited"]);
3390 const remoteDir = yield* createBareRemote();
3391 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
3392 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-rate-limited"]);
3393
3394 const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value";
3395 const { manager } = yield* makeManager({
3396 ghScenario: {
3397 failWith: "GitHub API rate limit exceeded. Requests resume when the limit resets.",
3398 failCause: new Error(upstreamCause),
3399 },
3400 });
3401 const logs: Array<{ message: string; annotations: Record<string, unknown> }> = [];
3402 const logger = Logger.make<unknown, void>(({ fiber, message }) => {
3403 logs.push({
3404 message: String(message),
3405 annotations: { ...fiber.getRef(References.CurrentLogAnnotations) },
3406 });
3407 });
3408
3409 const status = yield* manager
3410 .status({ cwd: repoDir })
3411 .pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));
3412
3413 expect(status.pr).toBeNull();
3414 const warning = logs.find((entry) => entry.message.includes("PR lookup failed"));
3415 expect(warning?.annotations).toMatchObject({
3416 operation: "lookupStatusPr",
3417 branch: "feature/status-rate-limited",
3418 errorTag: "SourceControlProviderError",
3419 provider: SourceControlProviderKind.make("github"),
3420 providerOperation: "listChangeRequests",
3421 errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.",
3422 });
3423 const loggedText = [
3424 warning?.message ?? "",
3425 ...Object.values(warning?.annotations ?? {}).map(String),
3426 ].join("\n");
3427 expect(loggedText).not.toContain(upstreamCause);
3428 expect(loggedText).not.toContain("secret-value");
3429 }),
3430 );
3431
3432 it.effect("status keeps the last known PR when a later lookup fails", () =>
3433 Effect.gen(function* () {
3434 const repoDir = yield* makeTempDir("t3code-git-manager-");
3435 yield* initRepo(repoDir);
3436 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-sticky"]);
3437 const remoteDir = yield* createBareRemote();
3438 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
3439 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-sticky"]);
3440
3441 const existingPr = {
3442 number: 214,
3443 title: "Sticky PR",
3444 url: "https://github.com/pingdotgg/codething-mvp/pull/214",
3445 baseRefName: "main",
3446 headRefName: "feature/pr-sticky",
3447 };
3448 const { manager } = yield* makeManager({
3449 ghScenario: {
3450 prListSequence: [JSON.stringify([existingPr])],
3451 failWith:
3452 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
3453 failAfterCalls: 1,
3454 },
3455 });
3456
3457 const first = yield* manager.status({ cwd: repoDir });
3458 expect(first.pr?.number).toBe(214);
3459
3460 // An explicit invalidation (user refresh, git action) bypasses the PR
3461 // cache and forces a live lookup — which now fails. The badge must keep
3462 // the last known PR instead of blanking out.
3463 yield* manager.invalidateStatus(repoDir);
3464 const second = yield* manager.status({ cwd: repoDir });
3465 expect(second.pr?.number).toBe(214);
3466 }),
3467 );
3468
3469 it.effect(
3470 "status does not reuse a stale PR after the branch is retargeted to a different upstream",
3471 () =>
3472 Effect.gen(function* () {
3473 const repoDir = yield* makeTempDir("t3code-git-manager-");
3474 yield* initRepo(repoDir);
3475 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-retarget"]);
3476
3477 const originRemote = yield* createBareRemote();
3478 yield* runGit(repoDir, ["remote", "add", "origin", originRemote]);
3479 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-retarget"]);
3480
3481 const existingPr = {
3482 number: 214,
3483 title: "Sticky PR",
3484 url: "https://github.com/pingdotgg/codething-mvp/pull/214",
3485 baseRefName: "main",
3486 headRefName: "feature/pr-retarget",
3487 };
3488 const { manager } = yield* makeManager({
3489 ghScenario: {
3490 prListSequence: [JSON.stringify([existingPr])],
3491 failWith:
3492 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
3493 failAfterCalls: 1,
3494 },
3495 });
3496
3497 const first = yield* manager.status({ cwd: repoDir });
3498 expect(first.pr?.number).toBe(214);
3499
3500 // Retarget the branch to a different remote/upstream (e.g. the PR was
3501 // reopened against a fork). The previously cached PR belonged to the
3502 // old upstream and must not be shown against the new one.
3503 const forkRemote = yield* createBareRemote();
3504 yield* runGit(repoDir, ["remote", "add", "fork", forkRemote]);
3505 yield* runGit(repoDir, ["push", "fork", "feature/pr-retarget"]);
3506 yield* runGit(repoDir, [
3507 "branch",
3508 "--set-upstream-to=fork/feature/pr-retarget",
3509 "feature/pr-retarget",
3510 ]);
3511
3512 yield* manager.invalidateStatus(repoDir);
3513 const second = yield* manager.status({ cwd: repoDir });
3514 expect(second.pr).toBeNull();
3515 }),
3516 );
3517
3518 it.effect("status keeps the last known PR when the branch gains its first upstream", () =>
3519 Effect.gen(function* () {
3520 const repoDir = yield* makeTempDir("t3code-git-manager-");
3521 yield* initRepo(repoDir);
3522 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-sticky-first-push"]);
3523 const remoteDir = yield* createBareRemote();
3524 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
3525
3526 const existingPr = {
3527 number: 215,
3528 title: "Sticky first-push PR",
3529 url: "https://github.com/pingdotgg/codething-mvp/pull/215",
3530 baseRefName: "main",
3531 headRefName: "feature/pr-sticky-first-push",
3532 };
3533 const { manager } = yield* makeManager({
3534 ghScenario: {
3535 prListSequence: [JSON.stringify([existingPr])],
3536 failWith:
3537 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
3538 failAfterCalls: 1,
3539 },
3540 });
3541
3542 const first = yield* manager.status({ cwd: repoDir });
3543 expect(first.pr?.number).toBe(215);
3544
3545 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-sticky-first-push"]);
3546 yield* manager.invalidateStatus(repoDir);
3547
3548 const second = yield* manager.status({ cwd: repoDir });
3549 expect(second.pr?.number).toBe(215);
3550 }),
3551 );
3552
3553 it.effect("status drops the last known PR when the tracked remote is repointed", () =>
3554 Effect.gen(function* () {
3555 const repoDir = yield* makeTempDir("t3code-git-manager-");
3556 yield* initRepo(repoDir);
3557 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-repointed"]);
3558 const originalRemoteDir = yield* createBareRemote();
3559 yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]);
3560 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-repointed"]);
3561
3562 const existingPr = {
3563 number: 216,
3564 title: "Old remote PR",
3565 url: "https://github.com/pingdotgg/codething-mvp/pull/216",
3566 baseRefName: "main",
3567 headRefName: "feature/pr-repointed",
3568 };
3569 const { manager } = yield* makeManager({
3570 ghScenario: {
3571 prListSequence: [JSON.stringify([existingPr])],
3572 failWith:
3573 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
3574 failAfterCalls: 1,
3575 },
3576 });
3577
3578 const first = yield* manager.status({ cwd: repoDir });
3579 expect(first.pr?.number).toBe(216);
3580
3581 const replacementRemoteDir = yield* createBareRemote();
3582 yield* runGit(repoDir, ["remote", "add", "replacement", replacementRemoteDir]);
3583 yield* runGit(repoDir, ["push", "replacement", "feature/pr-repointed"]);
3584 yield* runGit(repoDir, ["remote", "set-url", "origin", replacementRemoteDir]);
3585 yield* manager.invalidateStatus(repoDir);
3586
3587 const second = yield* manager.status({ cwd: repoDir });
3588 expect(second.pr).toBeNull();
3589 }),
3590 );
3591
3592 it.effect("status keeps the last known PR when the current remote URL can't be resolved", () =>
3593 Effect.gen(function* () {
3594 const repoDir = yield* makeTempDir("t3code-git-manager-");
3595 yield* initRepo(repoDir);
3596 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-config-hiccup"]);
3597 const remoteDir = yield* createBareRemote();
3598 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
3599 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-config-hiccup"]);
3600
3601 const existingPr = {
3602 number: 217,
3603 title: "Config hiccup PR",
3604 url: "https://github.com/pingdotgg/codething-mvp/pull/217",
3605 baseRefName: "main",
3606 headRefName: "feature/pr-config-hiccup",
3607 };
3608 const { manager } = yield* makeManager({
3609 ghScenario: {
3610 prListSequence: [JSON.stringify([existingPr])],
3611 failWith:
3612 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
3613 failAfterCalls: 1,
3614 },
3615 });
3616
3617 const first = yield* manager.status({ cwd: repoDir });
3618 expect(first.pr?.number).toBe(217);
3619
3620 // `remote.origin.url` reads go through readConfigValueNullable, which
3621 // maps ANY failed read (a real "no remote configured" state or a
3622 // transient git-config hiccup) to null the same way. Unsetting the
3623 // key here reproduces that ambiguity without touching branch
3624 // tracking (refs/remotes/origin/* and branch.<b>.remote are
3625 // untouched) — the remote identity has not actually changed, so the
3626 // sticky PR must survive even though the current lookup can no
3627 // longer resolve a remote URL to compare against.
3628 yield* runGit(repoDir, ["config", "--unset", "remote.origin.url"]);
3629 yield* manager.invalidateStatus(repoDir);
3630
3631 const second = yield* manager.status({ cwd: repoDir });
3632 expect(second.pr?.number).toBe(217);
3633 }),
3634 );
3635
3636 it.effect("creates a commit when working tree is dirty", () =>
3637 Effect.gen(function* () {
3638 const repoDir = yield* makeTempDir("t3code-git-manager-");
3639 yield* initRepo(repoDir);
3640 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n");
3641 let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined;
3642
3643 const { manager } = yield* makeManager({
3644 serverSettings: {
3645 sourceControlWritingStyle: {
3646 mode: "custom" as const,
3647 customInstructions: "Use a direct tone.",
3648 },
3649 },
3650 textGeneration: {
3651 generateCommitMessage: (input) => {
3652 generatedPolicy = input.policy;
3653 return Effect.succeed({ subject: "Implement stacked git actions", body: "" });
3654 },
3655 },
3656 });
3657 const result = yield* runStackedAction(manager, {
3658 cwd: repoDir,
3659 action: "commit",
3660 });
3661
3662 expect(result.branch.status).toBe("skipped_not_requested");
3663 expect(result.commit.status).toBe("created");
3664 expect(result.push.status).toBe("skipped_not_requested");
3665 expect(result.pr.status).toBe("skipped_not_requested");
3666 expect(generatedPolicy).toMatchObject({ commitInstructions: "Use a direct tone." });
3667 expect(result.toast).toMatchObject({
3668 description: "Implement stacked git actions",
3669 cta: {
3670 kind: "run_action",
3671 label: "Push",
3672 action: {
3673 kind: "push",
3674 },
3675 },
3676 });
3677 expect(result.toast.title).toMatch(/^Committed [0-9a-f]{7}$/);
3678 expect(
3679 yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe(
3680 Effect.map((result) => result.stdout.trim()),
3681 ),
3682 ).toBe("Implement stacked git actions");
3683 }),
3684 );
3685
3686 it.effect("preserves custom style when instructions are empty", () =>
3687 Effect.gen(function* () {
3688 const repoDir = yield* makeTempDir("t3code-git-manager-");
3689 yield* initRepo(repoDir);
3690 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n");
3691 let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined;
3692
3693 const { manager } = yield* makeManager({
3694 serverSettings: {
3695 sourceControlWritingStyle: {
3696 mode: "custom" as const,
3697 customInstructions: "",
3698 },
3699 },
3700 textGeneration: {
3701 generateCommitMessage: (input) => {
3702 generatedPolicy = input.policy;
3703 return Effect.succeed({ subject: "Preserve custom style", body: "" });
3704 },
3705 },
3706 });
3707 yield* runStackedAction(manager, {
3708 cwd: repoDir,
3709 action: "commit",
3710 });
3711
3712 expect(generatedPolicy).toEqual({
3713 kind: "custom",
3714 inferRepositoryConventions: false,
3715 });
3716 }),
3717 );
3718
3719 it.effect("falls back when the dedicated source control writer is unavailable", () =>
3720 Effect.gen(function* () {
3721 const repoDir = yield* makeTempDir("t3code-git-manager-");
3722 yield* initRepo(repoDir);
3723 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n");
3724 const missingInstanceId = ProviderInstanceId.make("missing_writer");
3725 let generatedModelSelection:
3726 | TextGeneration.CommitMessageGenerationInput["modelSelection"]
3727 | undefined;
3728
3729 const { manager } = yield* makeManager({
3730 serverSettings: {
3731 providerInstances: {
3732 [missingInstanceId]: {
3733 driver: ProviderDriverKind.make("missing-driver"),
3734 config: {},
3735 },
3736 },
3737 sourceControlWriterModelSelection: {
3738 instanceId: missingInstanceId,
3739 model: "missing-model",
3740 },
3741 },
3742 textGeneration: {
3743 generateCommitMessage: (input) => {
3744 generatedModelSelection = input.modelSelection;
3745 return Effect.succeed({ subject: "Use the available writer", body: "" });
3746 },
3747 },
3748 });
3749
3750 yield* runStackedAction(manager, {
3751 cwd: repoDir,
3752 action: "commit",
3753 });
3754
3755 expect(generatedModelSelection).toEqual(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection);
3756 }),
3757 );
3758
3759 it.effect("applies a V2-native thread's project settings to generated commit text", () =>
3760 Effect.gen(function* () {
3761 const repoDir = yield* makeTempDir("t3code-git-manager-");
3762 yield* initRepo(repoDir);
3763 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nproject\n");
3764 const projectId = ProjectId.make("project:git-settings");
3765 const threadId = ThreadId.make("thread:git-settings");
3766 const projectModel: ModelSelection = {
3767 instanceId: ProviderInstanceId.make("codex"),
3768 model: "gpt-project-writer",
3769 };
3770 const providerInstanceId = ProviderInstanceId.make("codex");
3771 const createdAt = DateTime.makeUnsafe("2026-09-27T00:00:00.000Z");
3772 let generatedModelSelection:
3773 | TextGeneration.CommitMessageGenerationInput["modelSelection"]
3774 | undefined;
3775
3776 const { manager } = yield* makeManager({
3777 serverSettings: {
3778 projectSettingsOverrides: { [projectId]: { textGenerationModelSelection: projectModel } },
3779 },
3780 textGeneration: {
3781 generateCommitMessage: (input) => {
3782 generatedModelSelection = input.modelSelection;
3783 return Effect.succeed({ subject: "Use the project writer", body: "" });
3784 },
3785 },
3786 // The thread exists only in V2; no V1 projection row names its project.
3787 seed: Effect.gen(function* () {
3788 yield* (yield* ProjectStore.ProjectStoreV2).apply({
3789 sequence: 1,
3790 eventId: EventId.make("event:git-settings:project"),
3791 aggregateKind: "project",
3792 aggregateId: projectId,
3793 occurredAt: DateTime.formatIso(createdAt),
3794 commandId: null,
3795 causationEventId: null,
3796 correlationId: null,
3797 metadata: {},
3798 type: "project.created",
3799 payload: {
3800 projectId,
3801 title: "Git settings",
3802 workspaceRoot: "/elsewhere",
3803 defaultModelSelection: null,
3804 scripts: [],
3805 createdAt: DateTime.formatIso(createdAt),
3806 updatedAt: DateTime.formatIso(createdAt),
3807 },
3808 });
3809 yield* (yield* ProjectionStore.ProjectionStoreV2).apply({
3810 id: EventId.make("event:git-settings:thread"),
3811 type: "thread.created",
3812 threadId,
3813 providerInstanceId,
3814 occurredAt: createdAt,
3815 payload: {
3816 createdBy: "user",
3817 creationSource: "web",
3818 id: threadId,
3819 projectId,
3820 title: "Git settings",
3821 providerInstanceId,
3822 modelSelection: { instanceId: providerInstanceId, model: "gpt-5" },
3823 runtimeMode: "full-access",
3824 interactionMode: "default",
3825 branch: null,
3826 worktreePath: repoDir,
3827 activeProviderThreadId: null,
3828 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
3829 forkedFrom: null,
3830 createdAt,
3831 updatedAt: createdAt,
3832 archivedAt: null,
3833 settledOverride: null,
3834 settledAt: null,
3835 lastVisitedAt: null,
3836 deletedAt: null,
3837 },
3838 });
3839 }),
3840 });
3841
3842 // The checkout is not the project's root, so only the thread can name the project.
3843 yield* manager.runStackedAction({
3844 actionId: "test-action-id",
3845 cwd: repoDir,
3846 action: "commit",
3847 threadId,
3848 });
3849
3850 expect(generatedModelSelection).toEqual(projectModel);
3851 }),
3852 );
3853
3854 it.effect("includes local agent instructions when recent history is empty", () =>
3855 Effect.gen(function* () {
3856 const repoDir = yield* makeTempDir("t3code-git-manager-");
3857 yield* runGit(repoDir, ["init", "--initial-branch=main"]);
3858 yield* runGit(repoDir, ["config", "user.email", "test@example.com"]);
3859 yield* runGit(repoDir, ["config", "user.name", "Test User"]);
3860 const agentInstructions = "Use lowercase source control text.";
3861 const claudeInstructions = "Keep pull request bodies brief.";
3862 NodeFS.writeFileSync(NodePath.join(repoDir, "AGENTS.md"), agentInstructions);
3863 NodeFS.writeFileSync(NodePath.join(repoDir, "CLAUDE.md"), claudeInstructions);
3864 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\n");
3865 yield* runGit(repoDir, ["add", "README.md"]);
3866 let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined;
3867
3868 const { manager } = yield* makeManager({
3869 serverSettings: {
3870 textGenerationModelSelection: {
3871 instanceId: ProviderInstanceId.make("claudeAgent"),
3872 model: "claude-sonnet-4-6",
3873 },
3874 sourceControlWritingStyle: {
3875 mode: "repo_conventions" as const,
3876 },
3877 },
3878 textGeneration: {
3879 generateCommitMessage: (input) => {
3880 generatedPolicy = input.policy;
3881 return Effect.succeed({ subject: "Create initial commit", body: "" });
3882 },
3883 },
3884 });
3885 yield* runStackedAction(manager, {
3886 cwd: repoDir,
3887 action: "commit",
3888 });
3889
3890 expect(generatedPolicy).toEqual({
3891 kind: "repo_conventions",
3892 commitInstructions: `Follow the repository's established commit message style when examples are available.\n\nLocal AGENTS.md:\n${agentInstructions}\n\nLocal CLAUDE.md:\n${claudeInstructions}`,
3893 changeRequestInstructions: `Follow the repository's established change request title and body style when examples are available.\n\nLocal AGENTS.md:\n${agentInstructions}\n\nLocal CLAUDE.md:\n${claudeInstructions}`,
3894 inferRepositoryConventions: true,
3895 });
3896 }),
3897 );
3898
3899 it.effect.each([undefined, ["README.md"]])(
3900 "a failed generation preserves staging changed while generating (paths: %s)",
3901 (filePaths) =>
3902 Effect.gen(function* () {
3903 const repoDir = yield* makeTempDir("t3code-git-manager-");
3904 yield* initRepo(repoDir);
3905 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nstaged\n");
3906 yield* runGit(repoDir, ["add", "README.md"]);
3907 NodeFS.appendFileSync(NodePath.join(repoDir, "README.md"), "unstaged\n");
3908 NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked\n");
3909 const indexBefore = NodeFS.readFileSync(NodePath.join(repoDir, ".git/index"));
3910 const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout;
3911 const gitDriver = yield* GitVcsDriver.GitVcsDriver;
3912 let indexDuring: Buffer | undefined;
3913 let indexAfterUserStage: Buffer | undefined;
3914 const { manager } = yield* makeManager({
3915 textGeneration: {
3916 generateCommitMessage: () =>
3917 Effect.gen(function* () {
3918 indexDuring = NodeFS.readFileSync(NodePath.join(repoDir, ".git/index"));
3919 yield* runGit(repoDir, ["add", "untracked.txt"]).pipe(
3920 Effect.provideService(GitVcsDriver.GitVcsDriver, gitDriver),
3921 Effect.orDie,
3922 );
3923 indexAfterUserStage = NodeFS.readFileSync(NodePath.join(repoDir, ".git/index"));
3924 return yield* new TextGenerationError({
3925 operation: "generateCommitMessage",
3926 detail: "Provider rejected generation",
3927 });
3928 }),
3929 },
3930 });
3931 const result = yield* runStackedAction(manager, {
3932 cwd: repoDir,
3933 action: "commit",
3934 ...(filePaths ? { filePaths } : {}),
3935 }).pipe(Effect.result);
3936 expect(Result.isFailure(result)).toBe(true);
3937 expect(indexDuring).toEqual(indexBefore);
3938 expect(NodeFS.readFileSync(NodePath.join(repoDir, ".git/index"))).toEqual(
3939 indexAfterUserStage,
3940 );
3941 expect((yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout).toBe(headBefore);
3942 expect((yield* runGit(repoDir, ["diff"])).stdout).toContain("+unstaged");
3943 }),
3944 );
3945
3946 it.effect("uses custom commit message when provided", () =>
3947 Effect.gen(function* () {
3948 const repoDir = yield* makeTempDir("t3code-git-manager-");
3949 yield* initRepo(repoDir);
3950 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\ncustom\n");
3951 let generatedCount = 0;
3952
3953 const { manager } = yield* makeManager({
3954 textGeneration: {
3955 generateCommitMessage: (input) =>
3956 Effect.sync(() => {
3957 generatedCount += 1;
3958 return {
3959 subject: "this should not be used",
3960 body: "",
3961 ...(input.includeBranch ? { branch: "feature/unused" } : {}),
3962 };
3963 }),
3964 },
3965 });
3966 const result = yield* runStackedAction(manager, {
3967 cwd: repoDir,
3968 action: "commit",
3969 commitMessage: "feat: custom summary line\n\n- details from user",
3970 });
3971
3972 expect(result.branch.status).toBe("skipped_not_requested");
3973 expect(result.commit.status).toBe("created");
3974 expect(result.commit.subject).toBe("feat: custom summary line");
3975 expect(generatedCount).toBe(0);
3976 expect(
3977 yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe(
3978 Effect.map((result) => result.stdout.trim()),
3979 ),
3980 ).toBe("feat: custom summary line");
3981 expect(
3982 yield* runGit(repoDir, ["log", "-1", "--pretty=%b"]).pipe(
3983 Effect.map((result) => result.stdout.trim()),
3984 ),
3985 ).toContain("- details from user");
3986 }),
3987 );
3988
3989 it.effect("commits only selected files when filePaths is provided", () =>
3990 Effect.gen(function* () {
3991 const repoDir = yield* makeTempDir("t3code-git-manager-");
3992 yield* initRepo(repoDir);
3993 NodeFS.writeFileSync(NodePath.join(repoDir, "a.txt"), "file a\n");
3994 NodeFS.writeFileSync(NodePath.join(repoDir, "b.txt"), "file b\n");
3995
3996 const { manager } = yield* makeManager();
3997 const result = yield* runStackedAction(manager, {
3998 cwd: repoDir,
3999 action: "commit",
4000 filePaths: ["a.txt"],
4001 });
4002
4003 expect(result.commit.status).toBe("created");
4004
4005 // b.txt should remain in the working tree
4006 const statusStdout = yield* runGit(repoDir, ["status", "--porcelain"]).pipe(
4007 Effect.map((r) => r.stdout),
4008 );
4009 expect(statusStdout).toContain("b.txt");
4010 expect(statusStdout).not.toContain("a.txt");
4011 }),
4012 );
4013
4014 it.effect("creates feature branch, commits, and pushes with featureBranch option", () =>
4015 Effect.gen(function* () {
4016 const repoDir = yield* makeTempDir("t3code-git-manager-");
4017 yield* initRepo(repoDir);
4018 const remoteDir = yield* createBareRemote();
4019 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
4020 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
4021 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nfeature-branch\n");
4022 let generatedCount = 0;
4023
4024 const { manager } = yield* makeManager({
4025 textGeneration: {
4026 generateCommitMessage: (input) =>
4027 Effect.sync(() => {
4028 generatedCount += 1;
4029 return {
4030 subject: "Implement stacked git actions",
4031 body: "",
4032 ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}),
4033 };
4034 }),
4035 },
4036 });
4037 const result = yield* runStackedAction(manager, {
4038 cwd: repoDir,
4039 action: "commit_push",
4040 featureBranch: true,
4041 });
4042
4043 expect(result.branch.status).toBe("created");
4044 expect(result.branch.name).toBe("feature/implement-stacked-git-actions");
4045 expect(result.commit.status).toBe("created");
4046 expect(result.push.status).toBe("pushed");
4047 expect(result.toast).toMatchObject({
4048 description: "Implement stacked git actions",
4049 cta: {
4050 kind: "run_action",
4051 label: "Create PR",
4052 action: {
4053 kind: "create_pr",
4054 },
4055 },
4056 });
4057 expect(result.toast.title).toMatch(
4058 /^Pushed [0-9a-f]{7} to origin\/feature\/implement-stacked-git-actions$/,
4059 );
4060 expect(
4061 yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe(
4062 Effect.map((result) => result.stdout.trim()),
4063 ),
4064 ).toBe("feature/implement-stacked-git-actions");
4065
4066 const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe(
4067 Effect.map((r) => r.stdout.trim()),
4068 );
4069 const mergeBase = yield* runGit(repoDir, ["merge-base", "main", "HEAD"]).pipe(
4070 Effect.map((r) => r.stdout.trim()),
4071 );
4072 expect(mergeBase).toBe(mainSha);
4073 expect(generatedCount).toBe(1);
4074 }),
4075 );
4076
4077 it.effect.each([
4078 { branch: "fix/name", existingBranches: ["fix"], expected: "fix-2/name" },
4079 { branch: "fix/name", existingBranches: ["fix/name/child"], expected: "fix/name-2" },
4080 ])("commits on $expected when $branch has ref namespace collisions", (scenario) =>
4081 Effect.gen(function* () {
4082 const repoDir = yield* makeTempDir("t3code-git-manager-");
4083 yield* initRepo(repoDir);
4084 const mainSha = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();
4085 for (const branch of scenario.existingBranches) {
4086 yield* runGit(repoDir, ["branch", branch]);
4087 }
4088 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nnamespace-collision\n");
4089
4090 const { manager } = yield* makeManager({
4091 textGeneration: {
4092 generateCommitMessage: () =>
4093 Effect.succeed({
4094 subject: "Fix namespace collision",
4095 body: "",
4096 branch: scenario.branch,
4097 }),
4098 },
4099 });
4100 const result = yield* runStackedAction(manager, {
4101 cwd: repoDir,
4102 action: "commit",
4103 featureBranch: true,
4104 });
4105
4106 expect(result.branch).toEqual({ status: "created", name: scenario.expected });
4107 expect(result.commit.status).toBe("created");
4108 expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe(
4109 scenario.expected,
4110 );
4111 expect((yield* runGit(repoDir, ["status", "--porcelain"])).stdout.trim()).toBe("");
4112 for (const branch of ["main", ...scenario.existingBranches]) {
4113 expect((yield* runGit(repoDir, ["rev-parse", branch])).stdout.trim()).toBe(mainSha);
4114 }
4115 }),
4116 );
4117
4118 it.effect("featureBranch uses custom commit message and derives branch name", () =>
4119 Effect.gen(function* () {
4120 const repoDir = yield* makeTempDir("t3code-git-manager-");
4121 yield* initRepo(repoDir);
4122 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\ncustom-feature\n");
4123 let generatedCount = 0;
4124
4125 const { manager } = yield* makeManager({
4126 textGeneration: {
4127 generateCommitMessage: (input) =>
4128 Effect.sync(() => {
4129 generatedCount += 1;
4130 return {
4131 subject: "unused",
4132 body: "",
4133 ...(input.includeBranch ? { branch: "feature/unused" } : {}),
4134 };
4135 }),
4136 },
4137 });
4138 const result = yield* runStackedAction(manager, {
4139 cwd: repoDir,
4140 action: "commit",
4141 featureBranch: true,
4142 commitMessage: "feat: custom summary line\n\n- details from user",
4143 });
4144
4145 expect(result.branch.status).toBe("created");
4146 expect(result.branch.name).toBe("feature/feat-custom-summary-line");
4147 expect(result.commit.status).toBe("created");
4148 expect(result.commit.subject).toBe("feat: custom summary line");
4149 expect(generatedCount).toBe(0);
4150
4151 const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe(
4152 Effect.map((r) => r.stdout.trim()),
4153 );
4154 const mergeBase = yield* runGit(repoDir, ["merge-base", "main", result.branch.name!]).pipe(
4155 Effect.map((r) => r.stdout.trim()),
4156 );
4157 expect(mergeBase).toBe(mainSha);
4158 }),
4159 );
4160
4161 it.effect("skips commit when there are no uncommitted changes", () =>
4162 Effect.gen(function* () {
4163 const repoDir = yield* makeTempDir("t3code-git-manager-");
4164 yield* initRepo(repoDir);
4165
4166 const { manager } = yield* makeManager();
4167 const result = yield* runStackedAction(manager, {
4168 cwd: repoDir,
4169 action: "commit",
4170 });
4171
4172 expect(result.branch.status).toBe("skipped_not_requested");
4173 expect(result.commit.status).toBe("skipped_no_changes");
4174 expect(result.push.status).toBe("skipped_not_requested");
4175 expect(result.pr.status).toBe("skipped_not_requested");
4176 }),
4177 );
4178
4179 it.effect("featureBranch returns error when worktree is clean", () =>
4180 Effect.gen(function* () {
4181 const repoDir = yield* makeTempDir("t3code-git-manager-");
4182 yield* initRepo(repoDir);
4183
4184 const { manager } = yield* makeManager();
4185 const error = yield* runStackedAction(manager, {
4186 cwd: repoDir,
4187 action: "commit",
4188 featureBranch: true,
4189 }).pipe(Effect.flip);
4190
4191 expect(error).toMatchObject({
4192 _tag: "GitManagerError",
4193 operation: "runFeatureBranchStep",
4194 cwd: repoDir,
4195 });
4196 expect(error.message).toContain("no changes to commit");
4197 }),
4198 );
4199
4200 it.effect("commits and pushes with upstream auto-setup when needed", () =>
4201 Effect.gen(function* () {
4202 const repoDir = yield* makeTempDir("t3code-git-manager-");
4203 yield* initRepo(repoDir);
4204 yield* runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]);
4205 const remoteDir = yield* createBareRemote();
4206 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
4207 NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n");
4208
4209 const { manager } = yield* makeManager();
4210 const result = yield* runStackedAction(manager, {
4211 cwd: repoDir,
4212 action: "commit_push",
4213 });
4214
4215 expect(result.branch.status).toBe("skipped_not_requested");
4216 expect(result.commit.status).toBe("created");
4217 expect(result.push.status).toBe("pushed");
4218 expect(result.push.setUpstream).toBe(true);
4219 expect(
4220 yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe(
4221 Effect.map((result) => result.stdout.trim()),
4222 ),
4223 ).toBe("origin/feature/stacked-flow");
4224 }),
4225 );
4226
4227 it.effect(
4228 "pushes and creates PR from a no-upstream branch when local commits are ahead of base",
4229 () =>
4230 Effect.gen(function* () {
4231 const repoDir = yield* makeTempDir("t3code-git-manager-");
4232 yield* initRepo(repoDir);
4233 yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-pr"]);
4234 const remoteDir = yield* createBareRemote();
4235 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
4236 NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n");
4237
4238 const { manager, ghCalls } = yield* makeManager({
4239 ghScenario: {
4240 prListSequence: [
4241 "[]",
4242 JSON.stringify([
4243 {
4244 number: 77,
4245 title: "Add no-upstream PR flow",
4246 url: "https://github.com/pingdotgg/codething-mvp/pull/77",
4247 baseRefName: "main",
4248 headRefName: "feature/no-upstream-pr",
4249 },
4250 ]),
4251 ],
4252 },
4253 });
4254
4255 const result = yield* runStackedAction(manager, {
4256 cwd: repoDir,
4257 action: "commit_push_pr",
4258 });
4259
4260 expect(result.branch.status).toBe("skipped_not_requested");
4261 expect(result.commit.status).toBe("created");
4262 expect(result.push.status).toBe("pushed");
4263 expect(result.push.setUpstream).toBe(true);
4264 expect(result.pr.status).toBe("created");
4265 expect(
4266 yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe(
4267 Effect.map((result) => result.stdout.trim()),
4268 ),
4269 ).toBe("origin/feature/no-upstream-pr");
4270 expect(
4271 ghCalls.some((call) =>
4272 call.includes("pr create --base main --head feature/no-upstream-pr"),
4273 ),
4274 ).toBe(true);
4275 }),
4276 );
4277
4278 it.effect("skips push when branch is already up to date", () =>
4279 Effect.gen(function* () {
4280 const repoDir = yield* makeTempDir("t3code-git-manager-");
4281 yield* initRepo(repoDir);
4282 yield* runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]);
4283 const remoteDir = yield* createBareRemote();
4284 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
4285 yield* runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]);
4286
4287 const { manager } = yield* makeManager();
4288 const result = yield* runStackedAction(manager, {
4289 cwd: repoDir,
4290 action: "commit_push",
4291 });
4292
4293 expect(result.branch.status).toBe("skipped_not_requested");
4294 expect(result.commit.status).toBe("skipped_no_changes");
4295 expect(result.push.status).toBe("skipped_up_to_date");
4296 }),
4297 );
4298
4299 it.effect("pushes existing clean commits without rerunning commit logic", () =>
4300 Effect.gen(function* () {
4301 const repoDir = yield* makeTempDir("t3code-git-manager-");
4302 yield* initRepo(repoDir);
4303 yield* runGit(repoDir, ["checkout", "-b", "feature/push-only"]);
4304 const remoteDir = yield* createBareRemote();
4305 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
4306 NodeFS.writeFileSync(NodePath.join(repoDir, "push-only.txt"), "push only\n");
4307 yield* runGit(repoDir, ["add", "push-only.txt"]);
4308 yield* runGit(repoDir, ["commit", "-m", "Push only branch"]);
4309
4310 const { manager } = yield* makeManager();
4311 const result = yield* runStackedAction(manager, {
4312 cwd: repoDir,
4313 action: "push",
4314 });
4315
4316 expect(result.commit.status).toBe("skipped_not_requested");
4317 expect(result.push.status).toBe("pushed");
4318 expect(result.pr.status).toBe("skipped_not_requested");
4319 expect(
4320 yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe(
4321 Effect.map((output) => output.stdout.trim()),
4322 ),
4323 ).toBe("origin/feature/push-only");
4324 }),
4325 );
4326
4327 it.effect("pushes existing commits without committing dirty worktree changes", () =>
4328 Effect.gen(function* () {
4329 const repoDir = yield* makeTempDir("t3code-git-manager-");
4330 yield* initRepo(repoDir);
4331 yield* runGit(repoDir, ["checkout", "-b", "feature/push-dirty"]);
4332 const remoteDir = yield* createBareRemote();
4333 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
4334 NodeFS.writeFileSync(NodePath.join(repoDir, "push-dirty.txt"), "push dirty\n");
4335 yield* runGit(repoDir, ["add", "push-dirty.txt"]);
4336 yield* runGit(repoDir, ["commit", "-m", "Push dirty branch"]);
4337 NodeFS.mkdirSync(NodePath.join(repoDir, ".vercel"));
4338 NodeFS.writeFileSync(NodePath.join(repoDir, ".vercel", "project.json"), "{}\n");
4339
4340 const { manager } = yield* makeManager();
4341 const result = yield* runStackedAction(manager, {
4342 cwd: repoDir,
4343 action: "push",
4344 });
4345
4346 expect(result.commit.status).toBe("skipped_not_requested");
4347 expect(result.push.status).toBe("pushed");
4348 expect(result.pr.status).toBe("skipped_not_requested");
4349 expect(
4350 yield* runGit(repoDir, ["status", "--porcelain"]).pipe(
4351 Effect.map((output) => output.stdout.trim()),
4352 ),
4353 ).toContain("?? .vercel/");
4354 expect(
4355 yield* runGit(remoteDir, ["log", "-1", "--pretty=%s", "feature/push-dirty"]).pipe(
4356 Effect.map((output) => output.stdout.trim()),
4357 ),
4358 ).toBe("Push dirty branch");
4359 }),
4360 );
4361
4362 it.effect("create_pr pushes committed changes while preserving a dirty worktree", () =>
4363 Effect.gen(function* () {
4364 const repoDir = yield* makeTempDir("t3code-git-manager-");
4365 yield* initRepo(repoDir);
4366 yield* runGit(repoDir, ["checkout", "-b", "feature/create-pr-only"]);
4367 const remoteDir = yield* createBareRemote();
4368 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
4369 NodeFS.writeFileSync(NodePath.join(repoDir, "create-pr-only.txt"), "create pr\n");
4370 yield* runGit(repoDir, ["add", "create-pr-only.txt"]);
4371 yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]);
4372 const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim();
4373 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "uncommitted readme\n");
4374 NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked work\n");
4375 const statusBefore = (yield* runGit(repoDir, ["status", "--porcelain"])).stdout;
4376
4377 const { manager, ghCalls } = yield* makeManager({
4378 ghScenario: {
4379 prListSequence: [
4380 "[]",
4381 JSON.stringify([
4382 {
4383 number: 303,
4384 title: "Create PR only branch",
4385 url: "https://github.com/pingdotgg/codething-mvp/pull/303",
4386 baseRefName: "main",
4387 headRefName: "feature/create-pr-only",
4388 },
4389 ]),
4390 ],
4391 },
4392 });
4393
4394 const result = yield* runStackedAction(manager, {
4395 cwd: repoDir,
4396 action: "create_pr",
4397 });
4398
4399 expect(result.commit.status).toBe("skipped_not_requested");
4400 expect(result.push.status).toBe("pushed");
4401 expect(result.push.setUpstream).toBe(true);
4402 expect(result.pr.status).toBe("created");
4403 expect(result.pr.number).toBe(303);
4404 expect((yield* runGit(repoDir, ["status", "--porcelain"])).stdout).toBe(statusBefore);
4405 expect(
4406 (yield* runGit(remoteDir, ["rev-parse", "feature/create-pr-only"])).stdout.trim(),
4407 ).toBe(headBefore);
4408 expect(
4409 ghCalls.some((call) =>
4410 call.includes("pr create --base main --head feature/create-pr-only"),
4411 ),
4412 ).toBe(true);
4413 }),
4414 );
4415
4416 it.effect("create_pr falls back to main when source control provider detection fails", () =>
4417 Effect.gen(function* () {
4418 const repoDir = yield* makeTempDir("t3code-git-manager-");
4419 yield* initRepo(repoDir);
4420 yield* runGit(repoDir, ["checkout", "-b", "feature/provider-fallback"]);
4421 NodeFS.writeFileSync(NodePath.join(repoDir, "provider-fallback.txt"), "fallback\n");
4422 yield* runGit(repoDir, ["add", "provider-fallback.txt"]);
4423 yield* runGit(repoDir, ["commit", "-m", "Provider fallback"]);
4424 const remoteDir = yield* createBareRemote();
4425 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
4426
4427 const { manager, ghCalls } = yield* makeManager({
4428 ghScenario: {
4429 prListSequence: [
4430 "[]",
4431 JSON.stringify([
4432 {
4433 number: 404,
4434 title: "Provider fallback",
4435 url: "https://github.com/pingdotgg/codething-mvp/pull/404",
4436 baseRefName: "main",
4437 headRefName: "feature/provider-fallback",
4438 },
4439 ]),
4440 ],
4441 },
4442 });
4443
4444 const result = yield* runStackedAction(manager, {
4445 cwd: repoDir,
4446 action: "create_pr",
4447 });
4448
4449 expect(result.pr.status).toBe("created");
4450 expect(result.pr.number).toBe(404);
4451 expect(
4452 ghCalls.some((call) =>
4453 call.includes("pr create --base main --head feature/provider-fallback"),
4454 ),
4455 ).toBe(true);
4456 }),
4457 );
4458
4459 it.effect("create_pr targets the remote default branch when it is not main", () =>
4460 Effect.gen(function* () {
4461 const repoDir = yield* makeTempDir("t3code-git-manager-");
4462 yield* initRepo(repoDir);
4463 const remoteDir = yield* createBareRemote();
4464 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
4465 // A repository whose default branch is master, with no main anywhere.
4466 yield* runGit(repoDir, ["push", "origin", "HEAD:master"]);
4467 yield* runGit(repoDir, ["fetch", "origin"]);
4468 yield* runGit(repoDir, ["remote", "set-head", "origin", "master"]);
4469
4470 yield* runGit(repoDir, ["checkout", "-b", "feature/master-default"]);
4471 NodeFS.writeFileSync(NodePath.join(repoDir, "master-default.txt"), "master default\n");
4472 yield* runGit(repoDir, ["add", "master-default.txt"]);
4473 yield* runGit(repoDir, ["commit", "-m", "Master default"]);
4474
4475 const { manager, ghCalls } = yield* makeManager({
4476 ghScenario: {
4477 // Mirrors a provider that cannot report a default branch, as the Azure
4478 // DevOps CLI does when it cannot detect the repository.
4479 defaultBranch: "",
4480 prListSequence: [
4481 "[]",
4482 JSON.stringify([
4483 {
4484 number: 505,
4485 title: "Master default",
4486 url: "https://github.com/pingdotgg/codething-mvp/pull/505",
4487 baseRefName: "master",
4488 headRefName: "feature/master-default",
4489 },
4490 ]),
4491 ],
4492 },
4493 });
4494
4495 const result = yield* runStackedAction(manager, {
4496 cwd: repoDir,
4497 action: "create_pr",
4498 });
4499
4500 expect(result.pr.status).toBe("created");
4501 expect(
4502 ghCalls.some((call) =>
4503 call.includes("pr create --base master --head feature/master-default"),
4504 ),
4505 ).toBe(true);
4506 }),
4507 );
4508
4509 it.effect("returns existing PR metadata for commit/push/pr action", () =>
4510 Effect.gen(function* () {
4511 const repoDir = yield* makeTempDir("t3code-git-manager-");
4512 yield* initRepo(repoDir);
4513 yield* runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]);
4514 const remoteDir = yield* createBareRemote();
4515 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
4516 yield* runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]);
4517
4518 const { manager, ghCalls } = yield* makeManager({
4519 ghScenario: {
4520 prListSequence: [
4521 JSON.stringify([
4522 {
4523 number: 42,
4524 title: "Existing PR",
4525 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
4526 baseRefName: "main",
4527 headRefName: "feature/existing-pr",
4528 },
4529 ]),
4530 ],
4531 },
4532 });
4533 const result = yield* runStackedAction(manager, {
4534 cwd: repoDir,
4535 action: "commit_push_pr",
4536 });
4537
4538 expect(result.branch.status).toBe("skipped_not_requested");
4539 expect(result.pr.status).toBe("opened_existing");
4540 expect(result.pr.number).toBe(42);
4541 expect(result.toast).toEqual({
4542 title: "Opened PR #42",
4543 description: "Existing PR",
4544 cta: {
4545 kind: "open_pr",
4546 label: "View PR",
4547 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
4548 },
4549 });
4550 expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false);
4551 }),
4552 );
4553
4554 it.effect(
4555 "returns existing cross-repo PR metadata found under the bare branch name",
4556 () =>
4557 Effect.gen(function* () {
4558 const repoDir = yield* makeTempDir("t3code-git-manager-");
4559 yield* initRepo(repoDir);
4560 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
4561 const forkDir = yield* createBareRemote();
4562 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
4563 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
4564 yield* configureVisibleRemoteUrlWithLocalRewrite(
4565 repoDir,
4566 "fork-seed",
4567 "git@github.com:octocat/codething-mvp.git",
4568 forkDir,
4569 );
4570
4571 const { manager, ghCalls } = yield* makeManager({
4572 ghScenario: {
4573 prListSequence: [
4574 JSON.stringify([
4575 {
4576 number: 142,
4577 title: "Existing fork PR",
4578 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
4579 baseRefName: "main",
4580 headRefName: "statemachine",
4581 state: "OPEN",
4582 isCrossRepository: true,
4583 headRepository: {
4584 nameWithOwner: "octocat/codething-mvp",
4585 },
4586 headRepositoryOwner: {
4587 login: "octocat",
4588 },
4589 },
4590 ]),
4591 ],
4592 },
4593 });
4594
4595 const result = yield* runStackedAction(manager, {
4596 cwd: repoDir,
4597 action: "commit_push_pr",
4598 });
4599
4600 expect(result.pr.status).toBe("opened_existing");
4601 expect(result.pr.number).toBe(142);
4602 expect(
4603 ghCalls.some((call) =>
4604 call.includes("pr list --head statemachine --state open --limit 100"),
4605 ),
4606 ).toBe(true);
4607 expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false);
4608 }),
4609 12_000,
4610 );
4611
4612 it.effect(
4613 "returns the correct existing PR when a slash remote checks out to a synthetic local alias",
4614 () =>
4615 Effect.gen(function* () {
4616 const repoDir = yield* makeTempDir("t3code-git-manager-");
4617 yield* initRepo(repoDir);
4618 const originDir = yield* createBareRemote();
4619 const upstreamDir = yield* createBareRemote();
4620 yield* configureRemote(repoDir, "origin", originDir, "origin");
4621 yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream");
4622
4623 yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]);
4624 yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]);
4625 yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]);
4626 yield* configureVisibleRemoteUrlWithLocalRewrite(
4627 repoDir,
4628 "origin",
4629 "git@github.com:pingdotgg/codething-mvp.git",
4630 originDir,
4631 );
4632 yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]);
4633 yield* configureVisibleRemoteUrlWithLocalRewrite(
4634 repoDir,
4635 "my-org/upstream",
4636 "ssh://git@github.com/pingdotgg/codething-mvp.git",
4637 upstreamDir,
4638 );
4639 yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]);
4640 yield* runGit(repoDir, ["checkout", "main"]);
4641 yield* runGit(repoDir, ["branch", "-D", "effect-atom"]);
4642 yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]);
4643 NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n");
4644 yield* runGit(repoDir, ["add", "changes.txt"]);
4645 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
4646
4647 const { manager, ghCalls } = yield* makeManager({
4648 ghScenario: {
4649 prListByHeadSelector: {
4650 "effect-atom": JSON.stringify([
4651 {
4652 number: 1618,
4653 title: "Correct PR",
4654 url: "https://github.com/pingdotgg/t3code/pull/1618",
4655 baseRefName: "main",
4656 headRefName: "effect-atom",
4657 },
4658 ]),
4659 "upstream/effect-atom": JSON.stringify([
4660 {
4661 number: 1518,
4662 title: "Wrong PR",
4663 url: "https://github.com/pingdotgg/t3code/pull/1518",
4664 baseRefName: "main",
4665 headRefName: "upstream/effect-atom",
4666 },
4667 ]),
4668 },
4669 },
4670 });
4671
4672 const result = yield* runStackedAction(manager, {
4673 cwd: repoDir,
4674 action: "commit_push_pr",
4675 });
4676
4677 expect(result.pr.status).toBe("opened_existing");
4678 expect(result.pr.number).toBe(1618);
4679 expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe(
4680 false,
4681 );
4682 }),
4683 20_000,
4684 );
4685
4686 it.effect(
4687 "picks the fork PR among same-named branches from other repositories",
4688 () =>
4689 Effect.gen(function* () {
4690 const repoDir = yield* makeTempDir("t3code-git-manager-");
4691 yield* initRepo(repoDir);
4692 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
4693 const forkDir = yield* createBareRemote();
4694 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
4695 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
4696 yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]);
4697 yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]);
4698 yield* configureVisibleRemoteUrlWithLocalRewrite(
4699 repoDir,
4700 "fork-seed",
4701 "git@github.com:octocat/codething-mvp.git",
4702 forkDir,
4703 );
4704
4705 const { manager, ghCalls } = yield* makeManager({
4706 ghScenario: {
4707 prListByHeadSelector: {
4708 "t3code/pr-142/statemachine": JSON.stringify([]),
4709 statemachine: JSON.stringify([
4710 {
4711 number: 41,
4712 title: "Unrelated same-repo PR",
4713 url: "https://github.com/pingdotgg/codething-mvp/pull/41",
4714 baseRefName: "main",
4715 headRefName: "statemachine",
4716 },
4717 {
4718 number: 142,
4719 title: "Existing fork PR",
4720 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
4721 baseRefName: "main",
4722 headRefName: "statemachine",
4723 state: "OPEN",
4724 isCrossRepository: true,
4725 headRepository: {
4726 nameWithOwner: "octocat/codething-mvp",
4727 },
4728 headRepositoryOwner: {
4729 login: "octocat",
4730 },
4731 },
4732 ]),
4733 },
4734 },
4735 });
4736
4737 const result = yield* runStackedAction(manager, {
4738 cwd: repoDir,
4739 action: "commit_push_pr",
4740 });
4741
4742 expect(result.pr.status).toBe("opened_existing");
4743 expect(result.pr.number).toBe(142);
4744 expect(
4745 ghCalls.some((call) => /--head [^ ]*:/u.test(call) && call.startsWith("pr list")),
4746 ).toBe(false);
4747 expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false);
4748 }),
4749 12_000,
4750 );
4751
4752 it.effect(
4753 "stops probing head selectors after finding an existing PR",
4754 () =>
4755 Effect.gen(function* () {
4756 const repoDir = yield* makeTempDir("t3code-git-manager-");
4757 yield* initRepo(repoDir);
4758 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
4759 const forkDir = yield* createBareRemote();
4760 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
4761 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
4762 yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]);
4763 yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]);
4764 yield* configureVisibleRemoteUrlWithLocalRewrite(
4765 repoDir,
4766 "fork-seed",
4767 "git@github.com:octocat/codething-mvp.git",
4768 forkDir,
4769 );
4770
4771 const { manager, ghCalls } = yield* makeManager({
4772 ghScenario: {
4773 prListByHeadSelector: {
4774 statemachine: JSON.stringify([
4775 {
4776 number: 142,
4777 title: "Existing fork PR",
4778 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
4779 baseRefName: "main",
4780 headRefName: "statemachine",
4781 state: "OPEN",
4782 isCrossRepository: true,
4783 headRepository: {
4784 nameWithOwner: "octocat/codething-mvp",
4785 },
4786 headRepositoryOwner: {
4787 login: "octocat",
4788 },
4789 },
4790 ]),
4791 "t3code/pr-142/statemachine": JSON.stringify([]),
4792 },
4793 },
4794 });
4795
4796 const result = yield* runStackedAction(manager, {
4797 cwd: repoDir,
4798 action: "commit_push_pr",
4799 });
4800
4801 expect(result.pr.status).toBe("opened_existing");
4802 expect(result.pr.number).toBe(142);
4803
4804 const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 100"));
4805 expect(openLookupCalls).toHaveLength(1);
4806 expect(openLookupCalls[0]).toContain(
4807 "pr list --head statemachine --state open --limit 100",
4808 );
4809 }),
4810 12_000,
4811 );
4812
4813 it.effect(
4814 "does not reuse a cross-repo PR when GitHub omits head identity metadata",
4815 () =>
4816 Effect.gen(function* () {
4817 const repoDir = yield* makeTempDir("t3code-git-manager-");
4818 yield* initRepo(repoDir);
4819 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
4820 const forkDir = yield* createBareRemote();
4821 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
4822 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
4823 yield* runGit(repoDir, [
4824 "config",
4825 "remote.fork-seed.url",
4826 "git@github.com:octocat/codething-mvp.git",
4827 ]);
4828
4829 const { manager, ghCalls } = yield* makeManager({
4830 ghScenario: {
4831 prListSequenceByHeadSelector: {
4832 statemachine: [
4833 `[{"number":41,"title":"Ambiguous fork PR","url":"https://github.com/pingdotgg/codething-mvp/pull/41","baseRefName":"main","headRefName":"statemachine","state":"OPEN"}]`,
4834 `[{"number":142,"title":"Add stacked git actions","url":"https://github.com/pingdotgg/codething-mvp/pull/142","baseRefName":"main","headRefName":"statemachine","state":"OPEN","isCrossRepository":true,"headRepository":{"nameWithOwner":"octocat/codething-mvp"},"headRepositoryOwner":{"login":"octocat"}}]`,
4835 ],
4836 },
4837 },
4838 });
4839
4840 const result = yield* runStackedAction(manager, {
4841 cwd: repoDir,
4842 action: "commit_push_pr",
4843 });
4844
4845 expect(result.pr.status).toBe("created");
4846 expect(result.pr.number).toBe(142);
4847 expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(true);
4848 }),
4849 20_000,
4850 );
4851
4852 it.effect("matches mounted Forgejo heads without confusing forks sharing a branch", () =>
4853 Effect.gen(function* () {
4854 for (const owner of ["maria", "reviewer"]) {
4855 const mapped = toForgejoChangeRequest(
4856 yield* decodeForgejoPullRequest({
4857 number: 42,
4858 title: "Greeting",
4859 html_url: "https://forgejo.example/forgejo/maria/project/pulls/42",
4860 state: "open",
4861 merged: false,
4862 base: {
4863 ref: "main",
4864 sha: "base",
4865 repo: { full_name: "maria/project", owner: { login: "maria" } },
4866 },
4867 head: {
4868 ref: "greeting",
4869 sha: "head",
4870 repo: { full_name: `${owner}/project`, owner: { login: owner } },
4871 },
4872 }),
4873 );
4874 const pr = {
4875 ...mapped,
4876 isDraft: mapped.isDraft ?? false,
4877 closedAt: mapped.closedAt ?? null,
4878 mergedAt: mapped.mergedAt ?? null,
4879 };
4880 const repository = GitManager.parseRepositoryNameWithOwnerFromRemoteUrl(
4881 `https://forgejo.example/forgejo/${owner}/project.git`,
4882 { repositoryNameFromRemoteUrl: ForgejoSourceControlProvider.repositoryNameFromRemoteUrl },
4883 );
4884 expect(repository).toBe(`${owner}/project`);
4885 const context = {
4886 headBranch: "greeting",
4887 headRepositoryNameWithOwner: repository,
4888 headRepositoryOwnerLogin: repository?.split("/")[0] ?? null,
4889 isCrossRepository: owner !== "maria",
4890 };
4891 expect(GitManager.matchesBranchHeadContext(pr, context)).toBe(true);
4892 expect(
4893 GitManager.matchesBranchHeadContext(pr, {
4894 ...context,
4895 headRepositoryNameWithOwner: "other/project",
4896 headRepositoryOwnerLogin: "other",
4897 }),
4898 ).toBe(false);
4899 }
4900 expect(
4901 GitManager.parseRepositoryNameWithOwnerFromRemoteUrl(
4902 "git@forgejo.example:maria/project.git",
4903 { repositoryNameFromRemoteUrl: ForgejoSourceControlProvider.repositoryNameFromRemoteUrl },
4904 ),
4905 ).toBe("maria/project");
4906 expect(
4907 GitManager.parseRepositoryNameWithOwnerFromRemoteUrl(
4908 "https://gitlab.example/group/maria/project.git",
4909 {},
4910 ),
4911 ).toBe("group/maria/project");
4912 }),
4913 );
4914
4915 it.effect("rejects same-repo PR metadata when matching a cross-repo head context", () =>
4916 Effect.sync(() => {
4917 const headContext = {
4918 headBranch: "statemachine",
4919 headRepositoryNameWithOwner: "pingdotgg/codething-mvp",
4920 headRepositoryOwnerLogin: "pingdotgg",
4921 isCrossRepository: true,
4922 };
4923
4924 expect(
4925 GitManager.matchesBranchHeadContext(
4926 {
4927 number: 41,
4928 title: "Same-repo PR",
4929 url: "https://github.com/pingdotgg/codething-mvp/pull/41",
4930 baseRefName: "main",
4931 headRefName: "statemachine",
4932 state: "open",
4933 updatedAt: Option.none(),
4934 isCrossRepository: false,
4935 headRepositoryNameWithOwner: "pingdotgg/codething-mvp",
4936 headRepositoryOwnerLogin: "pingdotgg",
4937 },
4938 headContext,
4939 ),
4940 ).toBe(false);
4941
4942 expect(
4943 GitManager.matchesBranchHeadContext(
4944 {
4945 number: 142,
4946 title: "Fork PR",
4947 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
4948 baseRefName: "main",
4949 headRefName: "statemachine",
4950 state: "open",
4951 updatedAt: Option.none(),
4952 isCrossRepository: true,
4953 headRepositoryNameWithOwner: "pingdotgg/codething-mvp",
4954 headRepositoryOwnerLogin: "pingdotgg",
4955 },
4956 headContext,
4957 ),
4958 ).toBe(true);
4959 }),
4960 );
4961
4962 it.effect("accepts fork PR metadata when origin is the fork checkout remote", () =>
4963 Effect.sync(() => {
4964 const headContext = {
4965 headBranch: "t3code/git-audit-stability",
4966 headRepositoryNameWithOwner: "justsomelegs/t3code",
4967 headRepositoryOwnerLogin: "justsomelegs",
4968 isCrossRepository: false,
4969 };
4970
4971 expect(
4972 GitManager.matchesBranchHeadContext(
4973 {
4974 number: 2284,
4975 title: "Improve branch mismatch warnings",
4976 url: "https://github.com/pingdotgg/t3code/pull/2284",
4977 baseRefName: "main",
4978 headRefName: "t3code/git-audit-stability",
4979 state: "open",
4980 updatedAt: Option.none(),
4981 isCrossRepository: true,
4982 headRepositoryNameWithOwner: "justsomelegs/t3code",
4983 headRepositoryOwnerLogin: "justsomelegs",
4984 },
4985 headContext,
4986 ),
4987 ).toBe(true);
4988 }),
4989 );
4990
4991 it.effect("creates PR when one does not already exist", () =>
4992 Effect.gen(function* () {
4993 const repoDir = yield* makeTempDir("t3code-git-manager-");
4994 yield* initRepo(repoDir);
4995 NodeFS.mkdirSync(NodePath.join(repoDir, ".github"));
4996 NodeFS.writeFileSync(
4997 NodePath.join(repoDir, ".github", "pull_request_template.md"),
4998 "## What changed?\n\n## Verification",
4999 );
5000 yield* runGit(repoDir, ["add", ".github/pull_request_template.md"]);
5001 yield* runGit(repoDir, ["commit", "-m", "Add pull request template"]);
5002 yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]);
5003 const remoteDir = yield* createBareRemote();
5004 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
5005 NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n");
5006 yield* runGit(repoDir, ["add", "changes.txt"]);
5007 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
5008 yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]);
5009 yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]);
5010 let generatedPolicy: TextGeneration.PrContentGenerationInput["policy"] = undefined;
5011 let generatedChangeRequestTemplate: string | undefined;
5012
5013 const { manager, ghCalls } = yield* makeManager({
5014 serverSettings: {
5015 sourceControlWritingStyle: {
5016 mode: "custom" as const,
5017 customInstructions: "Lead with user impact.",
5018 },
5019 },
5020 textGeneration: {
5021 generatePrContent: (input) => {
5022 generatedPolicy = input.policy;
5023 generatedChangeRequestTemplate = input.changeRequestTemplate;
5024 return Effect.succeed({
5025 title: "Add stacked git actions",
5026 body: "## What changed?\nAdded stacked git actions.",
5027 });
5028 },
5029 },
5030 ghScenario: {
5031 prListSequence: [
5032 "[]",
5033 JSON.stringify([
5034 {
5035 number: 88,
5036 title: "Add stacked git actions",
5037 url: "https://github.com/pingdotgg/codething-mvp/pull/88",
5038 baseRefName: "main",
5039 headRefName: "feature-create-pr",
5040 },
5041 ]),
5042 ],
5043 },
5044 });
5045 const result = yield* runStackedAction(manager, {
5046 cwd: repoDir,
5047 action: "commit_push_pr",
5048 });
5049
5050 expect(result.branch.status).toBe("skipped_not_requested");
5051 expect(result.pr.status).toBe("created");
5052 expect(result.pr.number).toBe(88);
5053 expect(generatedPolicy).toMatchObject({
5054 changeRequestInstructions: "Lead with user impact.",
5055 });
5056 expect(generatedChangeRequestTemplate).toBe("## What changed?\n\n## Verification");
5057 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);
5058 expect(
5059 ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")),
5060 ).toBe(true);
5061 expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false);
5062 }),
5063 );
5064
5065 it.effect.each(["commit", "short-commit", "tag", "revision"] as const)(
5066 "creates a PR against the default branch with a legacy %s base setting",
5067 (kind) =>
5068 Effect.gen(function* () {
5069 const repoDir = yield* makeTempDir("t3code-git-manager-");
5070 yield* initRepo(repoDir);
5071 yield* runGit(repoDir, ["branch", "-m", "main", "trunk"]);
5072 const remoteDir = yield* createBareRemote();
5073 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
5074 yield* runGit(repoDir, ["push", "-u", "origin", "trunk"]);
5075 yield* runGit(repoDir, ["checkout", "-b", "feature/legacy-base"]);
5076 NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n");
5077 yield* runGit(repoDir, ["add", "feature.txt"]);
5078 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
5079 const commit = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim();
5080 yield* runGit(repoDir, ["tag", "v1"]);
5081 const baseRef =
5082 kind === "commit"
5083 ? commit
5084 : kind === "short-commit"
5085 ? commit.slice(0, 12)
5086 : kind === "tag"
5087 ? "v1"
5088 : "HEAD~0";
5089 yield* runGit(repoDir, ["config", "branch.feature/legacy-base.gh-merge-base", baseRef]);
5090 yield* runGit(repoDir, ["push", "-u", "origin", "feature/legacy-base"]);
5091 let generatedBase = "";
5092 let generatedSummary = "";
5093 const { manager, ghCalls } = yield* makeManager({
5094 ghScenario: { defaultBranch: "trunk", prListSequence: ["[]", "[]"] },
5095 textGeneration: {
5096 generatePrContent: (input) => {
5097 generatedBase = input.baseBranch;
5098 generatedSummary = input.commitSummary;
5099 return Effect.succeed({ title: "Feature PR", body: "Feature body" });
5100 },
5101 },
5102 });
5103
5104 const result = yield* runStackedAction(manager, { cwd: repoDir, action: "create_pr" });
5105
5106 expect(result.pr.status).toBe("created");
5107 expect(generatedBase).toBe("trunk");
5108 expect(generatedSummary).toContain("Feature commit");
5109 expect(
5110 ghCalls.some((call) =>
5111 call.includes("pr create --base trunk --head feature/legacy-base"),
5112 ),
5113 ).toBe(true);
5114 }),
5115 );
5116
5117 it.effect.each([
5118 "release/v2",
5119 "refs/heads/release/v2",
5120 "origin/release/v2",
5121 "deadbeef",
5122 "remote-only",
5123 ])("preserves the configured PR target branch %s", (baseRef) =>
5124 Effect.gen(function* () {
5125 const repoDir = yield* makeTempDir("t3code-git-manager-");
5126 yield* initRepo(repoDir);
5127 const baseBranch = baseRef === "deadbeef" ? baseRef : "release/v2";
5128 const remoteDir = yield* createBareRemote();
5129 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
5130 yield* runGit(repoDir, ["checkout", "-b", baseBranch]);
5131 yield* runGit(repoDir, ["push", "-u", "origin", baseBranch]);
5132 yield* runGit(repoDir, ["checkout", "-b", "feature/configured-base"]);
5133 if (baseRef === "remote-only") {
5134 yield* runGit(repoDir, ["branch", "-D", baseBranch]);
5135 }
5136 NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n");
5137 yield* runGit(repoDir, ["add", "feature.txt"]);
5138 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
5139 yield* runGit(repoDir, ["push", "-u", "origin", "feature/configured-base"]);
5140 yield* runGit(repoDir, [
5141 "config",
5142 "branch.feature/configured-base.gh-merge-base",
5143 baseRef === "remote-only" ? baseBranch : baseRef,
5144 ]);
5145 const { manager, ghCalls } = yield* makeManager({
5146 ghScenario: { prListSequence: ["[]", "[]"] },
5147 });
5148
5149 const result = yield* runStackedAction(manager, { cwd: repoDir, action: "create_pr" });
5150
5151 expect(result.pr.status).toBe("created");
5152 expect(
5153 ghCalls.some((call) =>
5154 call.includes(`pr create --base ${baseBranch} --head feature/configured-base`),
5155 ),
5156 ).toBe(true);
5157 }),
5158 );
5159
5160 it.effect("generates PR content from branch changes when the remote base advances", () =>
5161 Effect.gen(function* () {
5162 const repoDir = yield* makeTempDir("t3code-git-manager-");
5163 yield* initRepo(repoDir);
5164 const remoteDir = yield* createBareRemote();
5165 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
5166 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
5167 yield* runGit(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/main"]);
5168
5169 const peerDir = yield* makeTempDir("t3code-git-peer-");
5170 yield* runGit(peerDir, ["clone", remoteDir, "."]);
5171 yield* runGit(peerDir, ["config", "user.email", "peer@example.com"]);
5172 yield* runGit(peerDir, ["config", "user.name", "Peer User"]);
5173 NodeFS.writeFileSync(NodePath.join(peerDir, "remote.txt"), "remote\n");
5174 yield* runGit(peerDir, ["add", "remote.txt"]);
5175 yield* runGit(peerDir, ["commit", "-m", "Remote base commit"]);
5176 yield* runGit(peerDir, ["push", "origin", "main"]);
5177
5178 yield* runGit(repoDir, ["fetch", "origin"]);
5179 yield* runGit(repoDir, [
5180 "checkout",
5181 "--no-track",
5182 "-b",
5183 "feature/remote-base",
5184 "origin/main",
5185 ]);
5186 NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n");
5187 yield* runGit(repoDir, ["add", "feature.txt"]);
5188 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
5189 yield* runGit(repoDir, ["push", "-u", "origin", "feature/remote-base"]);
5190 yield* runGit(repoDir, ["config", "branch.feature/remote-base.gh-merge-base", "main"]);
5191
5192 NodeFS.writeFileSync(NodePath.join(peerDir, "later-main.txt"), "unrelated\n");
5193 yield* runGit(peerDir, ["add", "later-main.txt"]);
5194 yield* runGit(peerDir, ["commit", "-m", "Later main commit"]);
5195 yield* runGit(peerDir, ["push", "origin", "main"]);
5196 yield* runGit(repoDir, ["fetch", "origin"]);
5197
5198 let generatedCommitSummary = "";
5199 let generatedDiffSummary = "";
5200 let generatedDiffPatch = "";
5201 const { manager } = yield* makeManager({
5202 ghScenario: {
5203 prListSequence: ["[]", "[]"],
5204 },
5205 textGeneration: {
5206 generatePrContent: (input) => {
5207 generatedCommitSummary = input.commitSummary;
5208 generatedDiffSummary = input.diffSummary;
5209 generatedDiffPatch = input.diffPatch;
5210 return Effect.succeed({ title: "Feature PR", body: "Feature body" });
5211 },
5212 },
5213 });
5214
5215 const result = yield* runStackedAction(manager, {
5216 cwd: repoDir,
5217 action: "create_pr",
5218 });
5219
5220 expect(result.pr.status).toBe("created");
5221 expect(generatedCommitSummary).toContain("Feature commit");
5222 expect(generatedCommitSummary).not.toContain("Remote base commit");
5223 expect(generatedCommitSummary).not.toContain("Later main commit");
5224 expect(generatedDiffSummary).toContain("feature.txt");
5225 expect(generatedDiffSummary).not.toContain("later-main.txt");
5226 expect(generatedDiffPatch).toContain("feature.txt");
5227 expect(generatedDiffPatch).not.toContain("later-main.txt");
5228 }),
5229 );
5230
5231 it.effect(
5232 "creates a new PR instead of reusing an unrelated fork PR with the same head branch",
5233 () =>
5234 Effect.gen(function* () {
5235 const repoDir = yield* makeTempDir("t3code-git-manager-");
5236 yield* initRepo(repoDir);
5237 yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]);
5238 const remoteDir = yield* createBareRemote();
5239 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
5240 NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n");
5241 yield* runGit(repoDir, ["add", "changes.txt"]);
5242 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
5243 yield* runGit(repoDir, ["push", "-u", "origin", "feature/no-fork-match"]);
5244
5245 const { manager, ghCalls } = yield* makeManager({
5246 ghScenario: {
5247 prListSequence: [
5248 JSON.stringify([
5249 {
5250 number: 1661,
5251 title: "Fork PR with same branch name",
5252 url: "https://github.com/pingdotgg/t3code/pull/1661",
5253 baseRefName: "main",
5254 headRefName: "feature/no-fork-match",
5255 state: "OPEN",
5256 isCrossRepository: true,
5257 headRepository: {
5258 nameWithOwner: "lnieuwenhuis/t3code",
5259 },
5260 headRepositoryOwner: {
5261 login: "lnieuwenhuis",
5262 },
5263 },
5264 ]),
5265 JSON.stringify([
5266 {
5267 number: 188,
5268 title: "Add stacked git actions",
5269 url: "https://github.com/pingdotgg/codething-mvp/pull/188",
5270 baseRefName: "main",
5271 headRefName: "feature/no-fork-match",
5272 state: "OPEN",
5273 isCrossRepository: false,
5274 },
5275 ]),
5276 ],
5277 },
5278 });
5279 const result = yield* runStackedAction(manager, {
5280 cwd: repoDir,
5281 action: "commit_push_pr",
5282 });
5283
5284 expect(result.pr.status).toBe("created");
5285 expect(result.pr.number).toBe(188);
5286 expect(result.toast).toEqual({
5287 title: "Created PR #188",
5288 description: "Add stacked git actions",
5289 cta: {
5290 kind: "open_pr",
5291 label: "View PR",
5292 url: "https://github.com/pingdotgg/codething-mvp/pull/188",
5293 },
5294 });
5295 expect(
5296 ghCalls.some((call) =>
5297 call.includes("pr create --base main --head feature/no-fork-match"),
5298 ),
5299 ).toBe(true);
5300 }),
5301 );
5302
5303 it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () =>
5304 Effect.gen(function* () {
5305 const repoDir = yield* makeTempDir("t3code-git-manager-");
5306 yield* initRepo(repoDir);
5307 const forkDir = yield* createBareRemote();
5308 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
5309 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
5310 NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n");
5311 yield* runGit(repoDir, ["add", "changes.txt"]);
5312 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
5313 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
5314 yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-91/statemachine"]);
5315 yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]);
5316 yield* configureVisibleRemoteUrlWithLocalRewrite(
5317 repoDir,
5318 "fork-seed",
5319 "git@github.com:octocat/codething-mvp.git",
5320 forkDir,
5321 );
5322
5323 const { manager, ghCalls } = yield* makeManager({
5324 ghScenario: {
5325 prListSequenceByHeadSelector: {
5326 statemachine: [
5327 JSON.stringify([]),
5328 JSON.stringify([
5329 {
5330 number: 188,
5331 title: "Add stacked git actions",
5332 url: "https://github.com/pingdotgg/codething-mvp/pull/188",
5333 baseRefName: "main",
5334 headRefName: "statemachine",
5335 state: "OPEN",
5336 isCrossRepository: true,
5337 headRepository: {
5338 nameWithOwner: "octocat/codething-mvp",
5339 },
5340 headRepositoryOwner: {
5341 login: "octocat",
5342 },
5343 },
5344 ]),
5345 ],
5346 },
5347 },
5348 });
5349
5350 const result = yield* runStackedAction(manager, {
5351 cwd: repoDir,
5352 action: "commit_push_pr",
5353 });
5354
5355 expect(result.pr.status).toBe("created");
5356 expect(result.pr.number).toBe(188);
5357 expect(
5358 ghCalls.some((call) => call.includes("pr create --base main --head octocat:statemachine")),
5359 ).toBe(true);
5360 expect(
5361 ghCalls.some((call) =>
5362 call.includes("pr create --base statemachine --head octocat:statemachine"),
5363 ),
5364 ).toBe(false);
5365 }),
5366 );
5367
5368 it.effect("rejects push/pr actions from detached HEAD", () =>
5369 Effect.gen(function* () {
5370 const repoDir = yield* makeTempDir("t3code-git-manager-");
5371 yield* initRepo(repoDir);
5372 yield* runGit(repoDir, ["checkout", "--detach", "HEAD"]);
5373
5374 const { manager } = yield* makeManager();
5375 const errorMessage = yield* runStackedAction(manager, {
5376 cwd: repoDir,
5377 action: "commit_push",
5378 }).pipe(
5379 Effect.flip,
5380 Effect.map((error) => error.message),
5381 );
5382 expect(errorMessage).toContain("detached HEAD");
5383 }),
5384 );
5385
5386 it.effect("surfaces missing gh binary errors", () =>
5387 Effect.gen(function* () {
5388 const repoDir = yield* makeTempDir("t3code-git-manager-");
5389 yield* initRepo(repoDir);
5390 yield* runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]);
5391 const remoteDir = yield* createBareRemote();
5392 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
5393 yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]);
5394
5395 const { manager } = yield* makeManager({
5396 ghScenario: {
5397 failWith:
5398 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
5399 },
5400 });
5401
5402 const errorMessage = yield* runStackedAction(manager, {
5403 cwd: repoDir,
5404 action: "commit_push_pr",
5405 }).pipe(
5406 Effect.flip,
5407 Effect.map((error) => error.message),
5408 );
5409 expect(errorMessage).toContain("No GitHub credential on the server");
5410 }),
5411 );
5412
5413 it.effect("surfaces gh auth errors with guidance", () =>
5414 Effect.gen(function* () {
5415 const repoDir = yield* makeTempDir("t3code-git-manager-");
5416 yield* initRepo(repoDir);
5417 yield* runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]);
5418 const remoteDir = yield* createBareRemote();
5419 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
5420 yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]);
5421
5422 const { manager } = yield* makeManager({
5423 ghScenario: {
5424 failWith: "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.",
5425 },
5426 });
5427
5428 const errorMessage = yield* runStackedAction(manager, {
5429 cwd: repoDir,
5430 action: "commit_push_pr",
5431 }).pipe(
5432 Effect.flip,
5433 Effect.map((error) => error.message),
5434 );
5435 expect(errorMessage).toContain("gh auth login");
5436 }),
5437 );
5438
5439 it.effect("resolves pull requests from #number references", () =>
5440 Effect.gen(function* () {
5441 const repoDir = yield* makeTempDir("t3code-git-manager-");
5442 yield* initRepo(repoDir);
5443
5444 const { manager, ghCalls } = yield* makeManager({
5445 ghScenario: {
5446 pullRequest: {
5447 number: 42,
5448 title: "Resolve PR",
5449 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
5450 baseRefName: "main",
5451 headRefName: "feature/resolve-pr",
5452 state: "open",
5453 },
5454 },
5455 });
5456
5457 const result = yield* resolvePullRequest(manager, {
5458 cwd: repoDir,
5459 reference: "#42",
5460 });
5461
5462 expect(result.pullRequest).toEqual({
5463 number: 42,
5464 title: "Resolve PR",
5465 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
5466 baseBranch: "main",
5467 headBranch: "feature/resolve-pr",
5468 state: "open",
5469 });
5470 expect(ghCalls.some((call) => call.startsWith("pr view 42 "))).toBe(true);
5471 }),
5472 );
5473
5474 it.effect("prepares pull request threads in local mode by checking out the PR branch", () =>
5475 Effect.gen(function* () {
5476 const repoDir = yield* makeTempDir("t3code-git-manager-");
5477 yield* initRepo(repoDir);
5478 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local"]);
5479 NodeFS.writeFileSync(NodePath.join(repoDir, "local.txt"), "local\n");
5480 yield* runGit(repoDir, ["add", "local.txt"]);
5481 yield* runGit(repoDir, ["commit", "-m", "Local PR branch"]);
5482
5483 const { manager, ghCalls } = yield* makeManager({
5484 ghScenario: {
5485 pullRequest: {
5486 number: 64,
5487 title: "Local PR",
5488 url: "https://github.com/pingdotgg/codething-mvp/pull/64",
5489 baseRefName: "main",
5490 headRefName: "feature/pr-local",
5491 state: "open",
5492 },
5493 },
5494 });
5495
5496 const result = yield* preparePullRequestThread(manager, {
5497 cwd: repoDir,
5498 reference: "#64",
5499 mode: "local",
5500 });
5501
5502 expect(result.branch).toBe("feature/pr-local");
5503 expect(result.worktreePath).toBeNull();
5504 const branch = (yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim();
5505 expect(branch).toBe("feature/pr-local");
5506 expect(ghCalls).toContain("pr checkout 64 --force");
5507 }),
5508 );
5509
5510 it.effect(
5511 "restores same-repository upstream tracking after local PR checkout without a remote ref",
5512 () =>
5513 Effect.gen(function* () {
5514 const repoDir = yield* makeTempDir("t3code-git-manager-");
5515 yield* initRepo(repoDir);
5516 const remoteDir = yield* createBareRemote();
5517 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
5518 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
5519 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-upstream"]);
5520 NodeFS.writeFileSync(NodePath.join(repoDir, "upstream.txt"), "upstream\n");
5521 yield* runGit(repoDir, ["add", "upstream.txt"]);
5522 yield* runGit(repoDir, ["commit", "-m", "Local upstream PR branch"]);
5523 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-upstream"]);
5524 yield* runGit(repoDir, ["checkout", "main"]);
5525 yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-upstream"]);
5526 yield* runGit(repoDir, [
5527 "update-ref",
5528 "-d",
5529 "refs/remotes/origin/feature/pr-local-upstream",
5530 ]);
5531
5532 const { manager } = yield* makeManager({
5533 ghScenario: {
5534 pullRequest: {
5535 number: 65,
5536 title: "Local upstream PR",
5537 url: "https://github.com/pingdotgg/codething-mvp/pull/65",
5538 baseRefName: "main",
5539 headRefName: "feature/pr-local-upstream",
5540 state: "open",
5541 isCrossRepository: false,
5542 headRepositoryNameWithOwner: "pingdotgg/codething-mvp",
5543 headRepositoryOwnerLogin: "pingdotgg",
5544 },
5545 repositoryCloneUrls: {
5546 "pingdotgg/codething-mvp": {
5547 url: remoteDir,
5548 sshUrl: remoteDir,
5549 },
5550 },
5551 },
5552 });
5553
5554 const result = yield* preparePullRequestThread(manager, {
5555 cwd: repoDir,
5556 reference: "65",
5557 mode: "local",
5558 });
5559
5560 expect(result.worktreePath).toBeNull();
5561 expect(result.branch).toBe("feature/pr-local-upstream");
5562 expect(
5563 (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(),
5564 ).toBe("origin/feature/pr-local-upstream");
5565 }),
5566 );
5567
5568 it.effect(
5569 "restores same-repository upstream tracking when provider omits head repository metadata",
5570 () =>
5571 Effect.gen(function* () {
5572 const repoDir = yield* makeTempDir("t3code-git-manager-");
5573 yield* initRepo(repoDir);
5574 const remoteDir = yield* createBareRemote();
5575 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
5576 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
5577 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-no-head-repo"]);
5578 NodeFS.writeFileSync(NodePath.join(repoDir, "no-head-repo.txt"), "upstream\n");
5579 yield* runGit(repoDir, ["add", "no-head-repo.txt"]);
5580 yield* runGit(repoDir, ["commit", "-m", "Local PR branch without repo metadata"]);
5581 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-no-head-repo"]);
5582 yield* runGit(repoDir, ["checkout", "main"]);
5583 yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-no-head-repo"]);
5584 yield* runGit(repoDir, [
5585 "update-ref",
5586 "-d",
5587 "refs/remotes/origin/feature/pr-local-no-head-repo",
5588 ]);
5589
5590 const { manager } = yield* makeManager({
5591 ghScenario: {
5592 pullRequest: {
5593 number: 66,
5594 title: "Local upstream PR without repo metadata",
5595 url: "https://github.com/pingdotgg/codething-mvp/pull/66",
5596 baseRefName: "main",
5597 headRefName: "feature/pr-local-no-head-repo",
5598 state: "open",
5599 },
5600 },
5601 });
5602
5603 const result = yield* preparePullRequestThread(manager, {
5604 cwd: repoDir,
5605 reference: "66",
5606 mode: "local",
5607 });
5608
5609 expect(result.worktreePath).toBeNull();
5610 expect(result.branch).toBe("feature/pr-local-no-head-repo");
5611 expect(
5612 (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(),
5613 ).toBe("origin/feature/pr-local-no-head-repo");
5614 }),
5615 );
5616
5617 it.effect("prepares pull request threads in worktree mode on the PR head branch", () =>
5618 Effect.gen(function* () {
5619 const repoDir = yield* makeTempDir("t3code-git-manager-");
5620 yield* initRepo(repoDir);
5621 const remoteDir = yield* createBareRemote();
5622 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
5623 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
5624 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree"]);
5625 NodeFS.writeFileSync(NodePath.join(repoDir, "worktree.txt"), "worktree\n");
5626 yield* runGit(repoDir, ["add", "worktree.txt"]);
5627 yield* runGit(repoDir, ["commit", "-m", "PR worktree branch"]);
5628 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree"]);
5629 yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/77/head"]);
5630 yield* runGit(repoDir, ["checkout", "main"]);
5631
5632 const { manager } = yield* makeManager({
5633 ghScenario: {
5634 pullRequest: {
5635 number: 77,
5636 title: "Worktree PR",
5637 url: "https://github.com/pingdotgg/codething-mvp/pull/77",
5638 baseRefName: "main",
5639 headRefName: "feature/pr-worktree",
5640 state: "open",
5641 },
5642 },
5643 });
5644
5645 const result = yield* preparePullRequestThread(manager, {
5646 cwd: repoDir,
5647 reference: "77",
5648 mode: "worktree",
5649 });
5650
5651 expect(result.branch).toBe("feature/pr-worktree");
5652 expect(result.worktreePath).not.toBeNull();
5653 expect(NodeFS.existsSync(result.worktreePath as string)).toBe(true);
5654 const worktreeBranch = (yield* runGit(result.worktreePath as string, [
5655 "branch",
5656 "--show-current",
5657 ])).stdout.trim();
5658 expect(worktreeBranch).toBe("feature/pr-worktree");
5659 }),
5660 );
5661
5662 it.effect("prepares a worktree from the head branch when the host has no pull ref", () =>
5663 Effect.gen(function* () {
5664 const repoDir = yield* makeTempDir("t3code-git-manager-");
5665 yield* initRepo(repoDir);
5666 const remoteDir = yield* createBareRemote();
5667 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
5668 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
5669 yield* runGit(repoDir, ["checkout", "-b", "feature/no-pull-ref"]);
5670 NodeFS.writeFileSync(NodePath.join(repoDir, "head.txt"), "head\n");
5671 yield* runGit(repoDir, ["add", "head.txt"]);
5672 yield* runGit(repoDir, ["commit", "-m", "Head without a pull ref"]);
5673 yield* runGit(repoDir, ["push", "origin", "feature/no-pull-ref"]);
5674 const headSha = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim();
5675 yield* runGit(repoDir, ["checkout", "main"]);
5676 yield* runGit(repoDir, ["branch", "-D", "feature/no-pull-ref"]);
5677 // The remote keeps its Azure spelling, which is what the pull request is compared with.
5678 const remoteUrl = "https://dev.azure.com/org/project/_git/repo";
5679 yield* runGit(repoDir, ["remote", "set-url", "origin", remoteUrl]);
5680 yield* runGit(repoDir, ["config", `url.${remoteDir}.insteadOf`, remoteUrl]);
5681
5682 // A pull request of another repository in the organization is not this remote's branch.
5683 const { manager: otherRepositoryManager } = yield* makeManager({
5684 ghScenario: {
5685 pullRequest: {
5686 number: 79,
5687 title: "Another repository's PR",
5688 url: "https://dev.azure.com/org/project/_git/other/pullrequest/79",
5689 baseRefName: "main",
5690 headRefName: "feature/no-pull-ref",
5691 state: "open",
5692 isCrossRepository: false,
5693 },
5694 },
5695 });
5696 yield* Effect.flip(
5697 preparePullRequestThread(otherRepositoryManager, {
5698 cwd: repoDir,
5699 reference: "79",
5700 mode: "worktree",
5701 }),
5702 );
5703 // Nor is the branch of a closed one, which may have moved past the head it closed with.
5704 const { manager: mergedManager } = yield* makeManager({
5705 ghScenario: {
5706 pullRequest: {
5707 number: 77,
5708 title: "Merged PR",
5709 url: "https://dev.azure.com/org/project/_git/repo/pullrequest/77",
5710 baseRefName: "main",
5711 headRefName: "feature/no-pull-ref",
5712 state: "merged",
5713 isCrossRepository: false,
5714 },
5715 },
5716 });
5717 yield* Effect.flip(
5718 preparePullRequestThread(mergedManager, {
5719 cwd: repoDir,
5720 reference: "77",
5721 mode: "worktree",
5722 }),
5723 );
5724 const localBranches = (yield* runGit(repoDir, ["branch", "--list"])).stdout;
5725 expect(localBranches).not.toContain("feature/no-pull-ref");
5726
5727 const { manager } = yield* makeManager({
5728 ghScenario: {
5729 pullRequest: {
5730 number: 78,
5731 title: "Azure DevOps PR",
5732 url: "https://dev.azure.com/org/project/_git/repo/pullrequest/78",
5733 baseRefName: "main",
5734 headRefName: "feature/no-pull-ref",
5735 state: "open",
5736 isCrossRepository: false,
5737 },
5738 },
5739 });
5740
5741 const result = yield* preparePullRequestThread(manager, {
5742 cwd: repoDir,
5743 reference: "78",
5744 mode: "worktree",
5745 });
5746
5747 expect(result.branch).toBe("feature/no-pull-ref");
5748 const worktreeHead = (yield* runGit(result.worktreePath as string, [
5749 "rev-parse",
5750 "HEAD",
5751 ])).stdout.trim();
5752 expect(worktreeHead).toBe(headSha);
5753 const upstream = (yield* runGit(result.worktreePath as string, [
5754 "rev-parse",
5755 "--abbrev-ref",
5756 "@{upstream}",
5757 ])).stdout.trim();
5758 expect(upstream).toBe("origin/feature/no-pull-ref");
5759 }),
5760 );
5761
5762 it.effect("preserves both branch materialization failures when the fallback also fails", () =>
5763 Effect.gen(function* () {
5764 const repoDir = yield* makeTempDir("t3code-git-manager-");
5765 yield* initRepo(repoDir);
5766 const originDir = yield* createBareRemote();
5767 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
5768 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
5769
5770 const missingForkDir = NodePath.join(repoDir, "missing-fork.git");
5771 const { manager } = yield* makeManager({
5772 ghScenario: {
5773 pullRequest: {
5774 number: 93,
5775 title: "Missing fork branch",
5776 url: "https://github.com/pingdotgg/codething-mvp/pull/93",
5777 baseRefName: "main",
5778 headRefName: "feature/missing-fork-branch",
5779 state: "open",
5780 isCrossRepository: true,
5781 headRepositoryNameWithOwner: "octocat/codething-mvp",
5782 headRepositoryOwnerLogin: "octocat",
5783 },
5784 repositoryCloneUrls: {
5785 "octocat/codething-mvp": {
5786 url: missingForkDir,
5787 sshUrl: missingForkDir,
5788 },
5789 },
5790 },
5791 });
5792
5793 const error = yield* preparePullRequestThread(manager, {
5794 cwd: repoDir,
5795 reference: "93",
5796 mode: "worktree",
5797 }).pipe(Effect.flip);
5798
5799 if (error._tag !== "GitPullRequestMaterializationError") {
5800 return yield* Effect.die(error);
5801 }
5802 expect(error).toMatchObject({
5803 cwd: repoDir,
5804 pullRequestNumber: 93,
5805 headRepository: "octocat/codething-mvp",
5806 headBranch: "feature/missing-fork-branch",
5807 localBranch: "t3code/pr-93/feature/missing-fork-branch",
5808 });
5809 if (!(error.cause instanceof AggregateError)) {
5810 return yield* Effect.die(error.cause);
5811 }
5812 expect(error.cause.errors).toHaveLength(2);
5813 expect(error.cause.errors).toEqual([
5814 expect.objectContaining({ _tag: "GitCommandError" }),
5815 expect.objectContaining({ _tag: "GitCommandError" }),
5816 ]);
5817 expect(error.cause.cause).toBe(error.cause.errors[0]);
5818 }),
5819 );
5820
5821 it.effect("launches setup when creating a new PR worktree", () =>
5822 Effect.gen(function* () {
5823 const repoDir = yield* makeTempDir("t3code-git-manager-");
5824 yield* initRepo(repoDir);
5825 const remoteDir = yield* createBareRemote();
5826 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
5827 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
5828 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]);
5829 NodeFS.writeFileSync(NodePath.join(repoDir, "setup.txt"), "setup\n");
5830 yield* runGit(repoDir, ["add", "setup.txt"]);
5831 yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]);
5832 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]);
5833 yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]);
5834 yield* runGit(repoDir, ["checkout", "main"]);
5835
5836 const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = [];
5837 const { manager } = yield* makeManager({
5838 ghScenario: {
5839 pullRequest: {
5840 number: 177,
5841 title: "Worktree setup PR",
5842 url: "https://github.com/pingdotgg/codething-mvp/pull/177",
5843 baseRefName: "main",
5844 headRefName: "feature/pr-worktree-setup",
5845 state: "open",
5846 },
5847 },
5848 setupScriptRunner: {
5849 runForThread: (setupInput) =>
5850 Effect.sync(() => {
5851 setupCalls.push(setupInput);
5852 return { status: "no-script" as const };
5853 }),
5854 },
5855 });
5856
5857 const result = yield* preparePullRequestThread(manager, {
5858 cwd: repoDir,
5859 reference: "177",
5860 mode: "worktree",
5861 threadId: asThreadId("thread-pr-setup"),
5862 });
5863
5864 expect(result.worktreePath).not.toBeNull();
5865 expect(setupCalls).toHaveLength(1);
5866 expect(setupCalls[0]).toEqual({
5867 threadId: "thread-pr-setup",
5868 projectCwd: repoDir,
5869 worktreePath: result.worktreePath as string,
5870 });
5871 }),
5872 );
5873
5874 it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () =>
5875 Effect.gen(function* () {
5876 const repoDir = yield* makeTempDir("t3code-git-manager-");
5877 yield* initRepo(repoDir);
5878 const originDir = yield* createBareRemote();
5879 const forkDir = yield* createBareRemote();
5880 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
5881 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
5882 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
5883 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-fork"]);
5884 NodeFS.writeFileSync(NodePath.join(repoDir, "fork.txt"), "fork\n");
5885 yield* runGit(repoDir, ["add", "fork.txt"]);
5886 yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]);
5887 yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-fork"]);
5888 yield* runGit(repoDir, ["checkout", "main"]);
5889
5890 const { manager } = yield* makeManager({
5891 ghScenario: {
5892 pullRequest: {
5893 number: 81,
5894 title: "Fork PR",
5895 url: "https://github.com/pingdotgg/codething-mvp/pull/81",
5896 baseRefName: "main",
5897 headRefName: "feature/pr-fork",
5898 state: "open",
5899 isCrossRepository: true,
5900 headRepositoryNameWithOwner: "octocat/codething-mvp",
5901 headRepositoryOwnerLogin: "octocat",
5902 },
5903 repositoryCloneUrls: {
5904 "octocat/codething-mvp": {
5905 url: forkDir,
5906 sshUrl: forkDir,
5907 },
5908 },
5909 },
5910 });
5911
5912 const result = yield* preparePullRequestThread(manager, {
5913 cwd: repoDir,
5914 reference: "81",
5915 mode: "worktree",
5916 });
5917
5918 expect(result.worktreePath).not.toBeNull();
5919 const upstreamRef = (yield* runGit(result.worktreePath as string, [
5920 "rev-parse",
5921 "--abbrev-ref",
5922 "@{upstream}",
5923 ])).stdout.trim();
5924 expect(upstreamRef).toBe("fork-seed/feature/pr-fork");
5925 expect(upstreamRef.startsWith("origin/")).toBe(false);
5926 expect(
5927 (yield* runGit(result.worktreePath as string, [
5928 "config",
5929 "--get",
5930 "remote.fork-seed.url",
5931 ])).stdout.trim(),
5932 ).toBe(forkDir);
5933 }),
5934 );
5935
5936 it.effect("preserves fork upstream tracking when preparing a local PR thread", () =>
5937 Effect.gen(function* () {
5938 const repoDir = yield* makeTempDir("t3code-git-manager-");
5939 yield* initRepo(repoDir);
5940 const originDir = yield* createBareRemote();
5941 const forkDir = yield* createBareRemote();
5942 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
5943 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
5944 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
5945 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-fork"]);
5946 NodeFS.writeFileSync(NodePath.join(repoDir, "local-fork.txt"), "local fork\n");
5947 yield* runGit(repoDir, ["add", "local-fork.txt"]);
5948 yield* runGit(repoDir, ["commit", "-m", "Local fork PR branch"]);
5949 yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-local-fork"]);
5950 yield* runGit(repoDir, ["checkout", "main"]);
5951 yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-fork"]);
5952
5953 const { manager } = yield* makeManager({
5954 ghScenario: {
5955 pullRequest: {
5956 number: 82,
5957 title: "Local Fork PR",
5958 url: "https://github.com/pingdotgg/codething-mvp/pull/82",
5959 baseRefName: "main",
5960 headRefName: "feature/pr-local-fork",
5961 state: "open",
5962 isCrossRepository: true,
5963 headRepositoryNameWithOwner: "octocat/codething-mvp",
5964 headRepositoryOwnerLogin: "octocat",
5965 },
5966 repositoryCloneUrls: {
5967 "octocat/codething-mvp": {
5968 url: forkDir,
5969 sshUrl: forkDir,
5970 },
5971 },
5972 },
5973 });
5974
5975 const result = yield* preparePullRequestThread(manager, {
5976 cwd: repoDir,
5977 reference: "82",
5978 mode: "local",
5979 });
5980
5981 expect(result.worktreePath).toBeNull();
5982 expect(result.branch).toBe("feature/pr-local-fork");
5983 expect(
5984 (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(),
5985 ).toBe("fork-seed/feature/pr-local-fork");
5986 }),
5987 );
5988
5989 it.effect("derives fork repository identity from PR URL when GitHub omits nameWithOwner", () =>
5990 Effect.gen(function* () {
5991 const repoDir = yield* makeTempDir("t3code-git-manager-");
5992 yield* initRepo(repoDir);
5993 const originDir = yield* createBareRemote();
5994 const forkDir = yield* createBareRemote();
5995 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
5996 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
5997 yield* runGit(repoDir, ["remote", "add", "binbandit-seed", forkDir]);
5998 yield* runGit(repoDir, ["checkout", "-b", "fix/git-action-default-without-origin"]);
5999 NodeFS.writeFileSync(NodePath.join(repoDir, "derived-fork.txt"), "derived fork\n");
6000 yield* runGit(repoDir, ["add", "derived-fork.txt"]);
6001 yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]);
6002 yield* runGit(repoDir, [
6003 "push",
6004 "-u",
6005 "binbandit-seed",
6006 "fix/git-action-default-without-origin",
6007 ]);
6008 yield* runGit(repoDir, ["checkout", "main"]);
6009 yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]);
6010
6011 const { manager } = yield* makeManager({
6012 ghScenario: {
6013 pullRequest: {
6014 number: 642,
6015 title: "fix: use commit as the default git action without origin",
6016 url: "https://github.com/pingdotgg/t3code/pull/642",
6017 baseRefName: "main",
6018 headRefName: "fix/git-action-default-without-origin",
6019 state: "open",
6020 isCrossRepository: true,
6021 headRepositoryOwnerLogin: "binbandit",
6022 },
6023 repositoryCloneUrls: {
6024 "binbandit/t3code": {
6025 url: forkDir,
6026 sshUrl: forkDir,
6027 },
6028 },
6029 },
6030 });
6031
6032 const result = yield* preparePullRequestThread(manager, {
6033 cwd: repoDir,
6034 reference: "642",
6035 mode: "local",
6036 });
6037
6038 expect(result.branch).toBe("fix/git-action-default-without-origin");
6039 expect(result.worktreePath).toBeNull();
6040 expect(
6041 (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(),
6042 ).toBe("binbandit-seed/fix/git-action-default-without-origin");
6043 }),
6044 );
6045
6046 it.effect("reuses an existing dedicated worktree for the PR head branch", () =>
6047 Effect.gen(function* () {
6048 const repoDir = yield* makeTempDir("t3code-git-manager-");
6049 yield* initRepo(repoDir);
6050 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-existing-worktree"]);
6051 NodeFS.writeFileSync(NodePath.join(repoDir, "existing.txt"), "existing\n");
6052 yield* runGit(repoDir, ["add", "existing.txt"]);
6053 yield* runGit(repoDir, ["commit", "-m", "Existing worktree branch"]);
6054 yield* runGit(repoDir, ["checkout", "main"]);
6055 const worktreePath = NodePath.join(
6056 repoDir,
6057 "..",
6058 `pr-existing-${NodePath.basename(repoDir)}`,
6059 );
6060 yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]);
6061
6062 const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = [];
6063 const { manager } = yield* makeManager({
6064 ghScenario: {
6065 pullRequest: {
6066 number: 78,
6067 title: "Existing worktree PR",
6068 url: "https://github.com/pingdotgg/codething-mvp/pull/78",
6069 baseRefName: "main",
6070 headRefName: "feature/pr-existing-worktree",
6071 state: "open",
6072 },
6073 },
6074 setupScriptRunner: {
6075 runForThread: (setupInput) =>
6076 Effect.sync(() => {
6077 setupCalls.push(setupInput);
6078 return { status: "no-script" as const };
6079 }),
6080 },
6081 });
6082
6083 const result = yield* preparePullRequestThread(manager, {
6084 cwd: repoDir,
6085 reference: "78",
6086 mode: "worktree",
6087 threadId: asThreadId("thread-pr-existing-worktree"),
6088 });
6089
6090 expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe(
6091 NodeFS.realpathSync.native(worktreePath),
6092 );
6093 expect(result.branch).toBe("feature/pr-existing-worktree");
6094 // Nothing to fetch from, so the checkout keeps the commit it had and setup stays out of a
6095 // worktree another thread may be sitting in.
6096 expect(setupCalls).toHaveLength(0);
6097 expect(result.isOnPullRequestHead).toBe(false);
6098 }),
6099 );
6100
6101 it.effect("refreshes a reused PR worktree onto the updated pull request head", () =>
6102 Effect.gen(function* () {
6103 const repoDir = yield* makeTempDir("t3code-git-manager-");
6104 yield* initRepo(repoDir);
6105 const remoteDir = yield* createBareRemote();
6106 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
6107 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
6108 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-stale"]);
6109 NodeFS.writeFileSync(NodePath.join(repoDir, "stale.txt"), "stale\n");
6110 yield* runGit(repoDir, ["add", "stale.txt"]);
6111 yield* runGit(repoDir, ["commit", "-m", "Reused stale PR branch"]);
6112 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-reused-stale"]);
6113 yield* runGit(repoDir, ["checkout", "main"]);
6114 const worktreePath = NodePath.join(
6115 repoDir,
6116 "..",
6117 `pr-reused-stale-${NodePath.basename(repoDir)}`,
6118 );
6119 yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-stale"]);
6120
6121 yield* runGit(repoDir, ["checkout", "-b", "author-push", "origin/feature/pr-reused-stale"]);
6122 NodeFS.writeFileSync(NodePath.join(repoDir, "authored.txt"), "authored\n");
6123 yield* runGit(repoDir, ["add", "authored.txt"]);
6124 yield* runGit(repoDir, ["commit", "-m", "New PR head commit"]);
6125 yield* runGit(repoDir, ["push", "origin", "author-push:feature/pr-reused-stale"]);
6126 const updatedHead = (yield* runGit(repoDir, ["rev-parse", "author-push"])).stdout.trim();
6127 yield* runGit(repoDir, ["checkout", "main"]);
6128
6129 const { manager } = yield* makeManager({
6130 ghScenario: {
6131 pullRequest: {
6132 number: 84,
6133 title: "Reused stale PR",
6134 url: "https://github.com/pingdotgg/codething-mvp/pull/84",
6135 baseRefName: "main",
6136 headRefName: "feature/pr-reused-stale",
6137 state: "open",
6138 },
6139 },
6140 });
6141
6142 const result = yield* preparePullRequestThread(manager, {
6143 cwd: repoDir,
6144 reference: "84",
6145 mode: "worktree",
6146 });
6147
6148 expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe(
6149 NodeFS.realpathSync.native(worktreePath),
6150 );
6151 expect(result.branch).toBe("feature/pr-reused-stale");
6152 expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(updatedHead);
6153 }),
6154 );
6155
6156 it.effect("runs the setup script when a reused PR worktree moves onto the new head", () =>
6157 Effect.gen(function* () {
6158 const repoDir = yield* makeTempDir("t3code-git-manager-");
6159 yield* initRepo(repoDir);
6160 const remoteDir = yield* createBareRemote();
6161 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
6162 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
6163 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-setup"]);
6164 NodeFS.writeFileSync(NodePath.join(repoDir, "reused-setup.txt"), "reused setup\n");
6165 yield* runGit(repoDir, ["add", "reused-setup.txt"]);
6166 yield* runGit(repoDir, ["commit", "-m", "Reused setup PR branch"]);
6167 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-reused-setup"]);
6168 yield* runGit(repoDir, ["checkout", "main"]);
6169 const worktreePath = NodePath.join(
6170 repoDir,
6171 "..",
6172 `pr-reused-setup-${NodePath.basename(repoDir)}`,
6173 );
6174 yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-setup"]);
6175
6176 yield* runGit(repoDir, ["checkout", "-b", "setup-author-push", "feature/pr-reused-setup"]);
6177 NodeFS.writeFileSync(NodePath.join(repoDir, "reused-setup.txt"), "reused setup again\n");
6178 yield* runGit(repoDir, ["add", "reused-setup.txt"]);
6179 yield* runGit(repoDir, ["commit", "-m", "New reused setup head"]);
6180 yield* runGit(repoDir, ["push", "origin", "setup-author-push:feature/pr-reused-setup"]);
6181 yield* runGit(repoDir, ["checkout", "main"]);
6182
6183 const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = [];
6184 const { manager } = yield* makeManager({
6185 ghScenario: {
6186 pullRequest: {
6187 number: 85,
6188 title: "Reused setup PR",
6189 url: "https://github.com/pingdotgg/codething-mvp/pull/85",
6190 baseRefName: "main",
6191 headRefName: "feature/pr-reused-setup",
6192 state: "open",
6193 },
6194 },
6195 setupScriptRunner: {
6196 runForThread: (setupInput) =>
6197 Effect.sync(() => {
6198 setupCalls.push(setupInput);
6199 return { status: "no-script" as const };
6200 }),
6201 },
6202 });
6203
6204 const result = yield* preparePullRequestThread(manager, {
6205 cwd: repoDir,
6206 reference: "85",
6207 mode: "worktree",
6208 threadId: asThreadId("thread-pr-reused-setup"),
6209 });
6210
6211 expect(setupCalls).toHaveLength(1);
6212 expect(setupCalls[0]).toEqual({
6213 threadId: "thread-pr-reused-setup",
6214 projectCwd: repoDir,
6215 worktreePath: result.worktreePath as string,
6216 });
6217 expect(result.isOnPullRequestHead).toBe(true);
6218 }),
6219 );
6220
6221 it.effect("leaves the setup script alone when a reused PR worktree is already on the head", () =>
6222 Effect.gen(function* () {
6223 const repoDir = yield* makeTempDir("t3code-git-manager-");
6224 yield* initRepo(repoDir);
6225 const remoteDir = yield* createBareRemote();
6226 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
6227 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
6228 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-current"]);
6229 NodeFS.writeFileSync(NodePath.join(repoDir, "reused-current.txt"), "reused current\n");
6230 yield* runGit(repoDir, ["add", "reused-current.txt"]);
6231 yield* runGit(repoDir, ["commit", "-m", "Reused current PR branch"]);
6232 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-reused-current"]);
6233 yield* runGit(repoDir, ["checkout", "main"]);
6234 const worktreePath = NodePath.join(
6235 repoDir,
6236 "..",
6237 `pr-reused-current-${NodePath.basename(repoDir)}`,
6238 );
6239 yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-current"]);
6240 const currentHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim();
6241
6242 const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = [];
6243 const { manager } = yield* makeManager({
6244 ghScenario: {
6245 pullRequest: {
6246 number: 95,
6247 title: "Reused current PR",
6248 url: "https://github.com/pingdotgg/codething-mvp/pull/95",
6249 baseRefName: "main",
6250 headRefName: "feature/pr-reused-current",
6251 state: "open",
6252 },
6253 },
6254 setupScriptRunner: {
6255 runForThread: (setupInput) =>
6256 Effect.sync(() => {
6257 setupCalls.push(setupInput);
6258 return { status: "no-script" as const };
6259 }),
6260 },
6261 });
6262
6263 const result = yield* preparePullRequestThread(manager, {
6264 cwd: repoDir,
6265 reference: "95",
6266 mode: "worktree",
6267 threadId: asThreadId("thread-pr-reused-current"),
6268 });
6269
6270 expect(result.isOnPullRequestHead).toBe(true);
6271 expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(currentHead);
6272 expect(setupCalls).toHaveLength(0);
6273 }),
6274 );
6275
6276 it.effect("resets a clean reused PR worktree onto a force-pushed pull request head", () =>
6277 Effect.gen(function* () {
6278 const repoDir = yield* makeTempDir("t3code-git-manager-");
6279 yield* initRepo(repoDir);
6280 const remoteDir = yield* createBareRemote();
6281 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
6282 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
6283 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-force-pushed"]);
6284 NodeFS.writeFileSync(NodePath.join(repoDir, "force-pushed.txt"), "first\n");
6285 yield* runGit(repoDir, ["add", "force-pushed.txt"]);
6286 yield* runGit(repoDir, ["commit", "-m", "Force-pushed PR branch"]);
6287 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-force-pushed"]);
6288 yield* runGit(repoDir, ["checkout", "main"]);
6289 const worktreePath = NodePath.join(
6290 repoDir,
6291 "..",
6292 `pr-force-pushed-${NodePath.basename(repoDir)}`,
6293 );
6294 yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-force-pushed"]);
6295 const staleHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim();
6296
6297 yield* runGit(repoDir, ["checkout", "-b", "author-rewrite", "feature/pr-force-pushed"]);
6298 NodeFS.writeFileSync(NodePath.join(repoDir, "force-pushed.txt"), "rewritten\n");
6299 yield* runGit(repoDir, ["add", "force-pushed.txt"]);
6300 yield* runGit(repoDir, ["commit", "--amend", "-m", "Rewritten PR head"]);
6301 yield* runGit(repoDir, [
6302 "push",
6303 "--force",
6304 "origin",
6305 "author-rewrite:feature/pr-force-pushed",
6306 ]);
6307 const rewrittenHead = (yield* runGit(repoDir, ["rev-parse", "author-rewrite"])).stdout.trim();
6308 // Pushing from this clone also advanced its remote-tracking ref. A head rewritten by the
6309 // author leaves that ref behind, which is the state a reused worktree is really opened in.
6310 yield* runGit(repoDir, [
6311 "update-ref",
6312 "refs/remotes/origin/feature/pr-force-pushed",
6313 staleHead,
6314 ]);
6315 yield* runGit(repoDir, ["checkout", "main"]);
6316
6317 const { manager } = yield* makeManager({
6318 ghScenario: {
6319 pullRequest: {
6320 number: 86,
6321 title: "Force-pushed PR",
6322 url: "https://github.com/pingdotgg/codething-mvp/pull/86",
6323 baseRefName: "main",
6324 headRefName: "feature/pr-force-pushed",
6325 state: "open",
6326 },
6327 },
6328 });
6329
6330 const result = yield* preparePullRequestThread(manager, {
6331 cwd: repoDir,
6332 reference: "86",
6333 mode: "worktree",
6334 });
6335
6336 expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe(
6337 NodeFS.realpathSync.native(worktreePath),
6338 );
6339 expect(result.isOnPullRequestHead).toBe(true);
6340 expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(
6341 rewrittenHead,
6342 );
6343 expect(
6344 NodeFS.readFileSync(NodePath.join(worktreePath, "force-pushed.txt"), "utf8").replace(
6345 /\r\n/g,
6346 "\n",
6347 ),
6348 ).toBe("rewritten\n");
6349 }),
6350 );
6351
6352 it.effect("keeps a reused PR worktree that carries its own commit off the rewritten head", () =>
6353 Effect.gen(function* () {
6354 const repoDir = yield* makeTempDir("t3code-git-manager-");
6355 yield* initRepo(repoDir);
6356 const remoteDir = yield* createBareRemote();
6357 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
6358 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
6359 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-commit"]);
6360 NodeFS.writeFileSync(NodePath.join(repoDir, "local-commit.txt"), "first\n");
6361 yield* runGit(repoDir, ["add", "local-commit.txt"]);
6362 yield* runGit(repoDir, ["commit", "-m", "Local commit PR branch"]);
6363 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-commit"]);
6364 yield* runGit(repoDir, ["checkout", "main"]);
6365 const worktreePath = NodePath.join(
6366 repoDir,
6367 "..",
6368 `pr-local-commit-${NodePath.basename(repoDir)}`,
6369 );
6370 yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-local-commit"]);
6371 const upstreamHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim();
6372
6373 yield* runGit(repoDir, ["checkout", "-b", "local-commit-rewrite", "feature/pr-local-commit"]);
6374 NodeFS.writeFileSync(NodePath.join(repoDir, "local-commit.txt"), "rewritten\n");
6375 yield* runGit(repoDir, ["add", "local-commit.txt"]);
6376 yield* runGit(repoDir, ["commit", "--amend", "-m", "Rewritten local commit head"]);
6377 yield* runGit(repoDir, [
6378 "push",
6379 "--force",
6380 "origin",
6381 "local-commit-rewrite:feature/pr-local-commit",
6382 ]);
6383 yield* runGit(repoDir, [
6384 "update-ref",
6385 "refs/remotes/origin/feature/pr-local-commit",
6386 upstreamHead,
6387 ]);
6388 yield* runGit(repoDir, ["checkout", "main"]);
6389
6390 // The work that must survive: a commit made in the worktree, on top of the stale head.
6391 NodeFS.writeFileSync(NodePath.join(worktreePath, "thread-work.txt"), "thread work\n");
6392 yield* runGit(worktreePath, ["add", "thread-work.txt"]);
6393 yield* runGit(worktreePath, ["commit", "-m", "Work done in the reused worktree"]);
6394 const worktreeHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim();
6395
6396 const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = [];
6397 const { manager } = yield* makeManager({
6398 ghScenario: {
6399 pullRequest: {
6400 number: 87,
6401 title: "Local commit PR",
6402 url: "https://github.com/pingdotgg/codething-mvp/pull/87",
6403 baseRefName: "main",
6404 headRefName: "feature/pr-local-commit",
6405 state: "open",
6406 },
6407 },
6408 setupScriptRunner: {
6409 runForThread: (setupInput) =>
6410 Effect.sync(() => {
6411 setupCalls.push(setupInput);
6412 return { status: "no-script" as const };
6413 }),
6414 },
6415 });
6416
6417 const result = yield* preparePullRequestThread(manager, {
6418 cwd: repoDir,
6419 reference: "87",
6420 mode: "worktree",
6421 threadId: asThreadId("thread-pr-local-commit"),
6422 });
6423
6424 expect(result.isOnPullRequestHead).toBe(false);
6425 expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(worktreeHead);
6426 expect(NodeFS.existsSync(NodePath.join(worktreePath, "thread-work.txt"))).toBe(true);
6427 expect(setupCalls).toHaveLength(0);
6428 }),
6429 );
6430
6431 it.effect("keeps a dirty reused PR worktree off the rewritten pull request head", () =>
6432 Effect.gen(function* () {
6433 const repoDir = yield* makeTempDir("t3code-git-manager-");
6434 yield* initRepo(repoDir);
6435 const remoteDir = yield* createBareRemote();
6436 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
6437 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
6438 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-dirty-worktree"]);
6439 NodeFS.writeFileSync(NodePath.join(repoDir, "dirty.txt"), "first\n");
6440 yield* runGit(repoDir, ["add", "dirty.txt"]);
6441 yield* runGit(repoDir, ["commit", "-m", "Dirty worktree PR branch"]);
6442 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-dirty-worktree"]);
6443 yield* runGit(repoDir, ["checkout", "main"]);
6444 const worktreePath = NodePath.join(
6445 repoDir,
6446 "..",
6447 `pr-dirty-worktree-${NodePath.basename(repoDir)}`,
6448 );
6449 yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-dirty-worktree"]);
6450 const staleHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim();
6451
6452 yield* runGit(repoDir, ["checkout", "-b", "dirty-rewrite", "feature/pr-dirty-worktree"]);
6453 NodeFS.writeFileSync(NodePath.join(repoDir, "dirty.txt"), "rewritten\n");
6454 yield* runGit(repoDir, ["add", "dirty.txt"]);
6455 yield* runGit(repoDir, ["commit", "--amend", "-m", "Rewritten dirty head"]);
6456 yield* runGit(repoDir, [
6457 "push",
6458 "--force",
6459 "origin",
6460 "dirty-rewrite:feature/pr-dirty-worktree",
6461 ]);
6462 yield* runGit(repoDir, [
6463 "update-ref",
6464 "refs/remotes/origin/feature/pr-dirty-worktree",
6465 staleHead,
6466 ]);
6467 yield* runGit(repoDir, ["checkout", "main"]);
6468
6469 NodeFS.writeFileSync(NodePath.join(worktreePath, "dirty.txt"), "uncommitted edit\n");
6470
6471 const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = [];
6472 const { manager } = yield* makeManager({
6473 ghScenario: {
6474 pullRequest: {
6475 number: 89,
6476 title: "Dirty worktree PR",
6477 url: "https://github.com/pingdotgg/codething-mvp/pull/89",
6478 baseRefName: "main",
6479 headRefName: "feature/pr-dirty-worktree",
6480 state: "open",
6481 },
6482 },
6483 setupScriptRunner: {
6484 runForThread: (setupInput) =>
6485 Effect.sync(() => {
6486 setupCalls.push(setupInput);
6487 return { status: "no-script" as const };
6488 }),
6489 },
6490 });
6491
6492 const result = yield* preparePullRequestThread(manager, {
6493 cwd: repoDir,
6494 reference: "89",
6495 mode: "worktree",
6496 threadId: asThreadId("thread-pr-dirty-worktree"),
6497 });
6498
6499 expect(result.isOnPullRequestHead).toBe(false);
6500 expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(staleHead);
6501 expect(NodeFS.readFileSync(NodePath.join(worktreePath, "dirty.txt"), "utf8")).toBe(
6502 "uncommitted edit\n",
6503 );
6504 expect(setupCalls).toHaveLength(0);
6505 }),
6506 );
6507
6508 it.effect("refreshes a reused PR worktree that has no upstream from the pull request ref", () =>
6509 Effect.gen(function* () {
6510 const repoDir = yield* makeTempDir("t3code-git-manager-");
6511 yield* initRepo(repoDir);
6512 const remoteDir = yield* createBareRemote();
6513 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
6514 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
6515 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-ref-only"]);
6516 NodeFS.writeFileSync(NodePath.join(repoDir, "ref-only.txt"), "ref only\n");
6517 yield* runGit(repoDir, ["add", "ref-only.txt"]);
6518 yield* runGit(repoDir, ["commit", "-m", "Pull ref only PR branch"]);
6519 // The head lives at refs/pull/90/head and nowhere else, so nothing can be tracked.
6520 yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/90/head"]);
6521 yield* runGit(repoDir, ["checkout", "main"]);
6522 yield* runGit(repoDir, ["branch", "-D", "feature/pr-ref-only"]);
6523
6524 const { manager } = yield* makeManager({
6525 ghScenario: {
6526 pullRequest: {
6527 number: 90,
6528 title: "Pull ref only PR",
6529 url: "https://github.com/pingdotgg/codething-mvp/pull/90",
6530 baseRefName: "main",
6531 headRefName: "feature/pr-ref-only",
6532 state: "open",
6533 },
6534 },
6535 });
6536
6537 const created = yield* preparePullRequestThread(manager, {
6538 cwd: repoDir,
6539 reference: "90",
6540 mode: "worktree",
6541 });
6542 const worktreePath = created.worktreePath as string;
6543 expect(
6544 (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"], true)).exitCode,
6545 ).not.toBe(0);
6546
6547 yield* runGit(repoDir, ["fetch", "origin", "refs/pull/90/head"]);
6548 yield* runGit(repoDir, ["checkout", "-b", "ref-only-author", "FETCH_HEAD"]);
6549 NodeFS.writeFileSync(NodePath.join(repoDir, "ref-only.txt"), "ref only again\n");
6550 yield* runGit(repoDir, ["add", "ref-only.txt"]);
6551 yield* runGit(repoDir, ["commit", "-m", "New pull ref head"]);
6552 yield* runGit(repoDir, ["push", "origin", "ref-only-author:refs/pull/90/head"]);
6553 const updatedHead = (yield* runGit(repoDir, ["rev-parse", "ref-only-author"])).stdout.trim();
6554 yield* runGit(repoDir, ["checkout", "main"]);
6555
6556 const result = yield* preparePullRequestThread(manager, {
6557 cwd: repoDir,
6558 reference: "90",
6559 mode: "worktree",
6560 });
6561
6562 expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe(
6563 NodeFS.realpathSync.native(worktreePath),
6564 );
6565 expect(result.isOnPullRequestHead).toBe(true);
6566 expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(updatedHead);
6567 }),
6568 );
6569
6570 it.effect("never moves an unrelated local branch that shares the fork head branch name", () =>
6571 Effect.gen(function* () {
6572 const repoDir = yield* makeTempDir("t3code-git-manager-");
6573 yield* initRepo(repoDir);
6574 const originDir = yield* createBareRemote();
6575 const forkDir = yield* createBareRemote();
6576 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
6577 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
6578 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
6579 yield* runGit(repoDir, ["checkout", "-b", "fork-main-collision"]);
6580 NodeFS.writeFileSync(NodePath.join(repoDir, "contributor.txt"), "contributor\n");
6581 yield* runGit(repoDir, ["add", "contributor.txt"]);
6582 yield* runGit(repoDir, ["commit", "-m", "Contributor commit on the fork main"]);
6583 yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-collision:main"]);
6584 // The user's own main, checked out in its own worktree and behind the fork's main: a
6585 // fast-forward would land the contributor's commits in it.
6586 yield* runGit(repoDir, ["checkout", "-b", "feature/root-work", "main"]);
6587 const mainWorktreePath = NodePath.join(
6588 repoDir,
6589 "..",
6590 `local-main-${NodePath.basename(repoDir)}`,
6591 );
6592 yield* runGit(repoDir, ["worktree", "add", mainWorktreePath, "main"]);
6593 const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();
6594
6595 const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = [];
6596 const { manager } = yield* makeManager({
6597 ghScenario: {
6598 pullRequest: {
6599 number: 94,
6600 title: "Fork main collision PR",
6601 url: "https://github.com/pingdotgg/codething-mvp/pull/94",
6602 baseRefName: "main",
6603 headRefName: "main",
6604 state: "open",
6605 isCrossRepository: true,
6606 headRepositoryNameWithOwner: "octocat/codething-mvp",
6607 headRepositoryOwnerLogin: "octocat",
6608 },
6609 repositoryCloneUrls: {
6610 "octocat/codething-mvp": {
6611 url: forkDir,
6612 sshUrl: forkDir,
6613 },
6614 },
6615 },
6616 setupScriptRunner: {
6617 runForThread: (setupInput) =>
6618 Effect.sync(() => {
6619 setupCalls.push(setupInput);
6620 return { status: "no-script" as const };
6621 }),
6622 },
6623 });
6624
6625 const result = yield* preparePullRequestThread(manager, {
6626 cwd: repoDir,
6627 reference: "94",
6628 mode: "worktree",
6629 threadId: asThreadId("thread-pr-fork-main-collision"),
6630 });
6631
6632 expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore);
6633 expect((yield* runGit(mainWorktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(
6634 localMainBefore,
6635 );
6636 expect(NodeFS.existsSync(NodePath.join(mainWorktreePath, "contributor.txt"))).toBe(false);
6637 expect(result.isOnPullRequestHead).toBe(false);
6638 expect(setupCalls).toHaveLength(0);
6639 }),
6640 );
6641
6642 it.effect(
6643 "does not block fork PR worktree prep when the fork head branch collides with root main",
6644 () =>
6645 Effect.gen(function* () {
6646 const repoDir = yield* makeTempDir("t3code-git-manager-");
6647 yield* initRepo(repoDir);
6648 const originDir = yield* createBareRemote();
6649 const forkDir = yield* createBareRemote();
6650 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
6651 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
6652 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
6653 yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]);
6654 NodeFS.writeFileSync(NodePath.join(repoDir, "fork-main.txt"), "fork main\n");
6655 yield* runGit(repoDir, ["add", "fork-main.txt"]);
6656 yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]);
6657 yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]);
6658 yield* runGit(repoDir, ["checkout", "main"]);
6659 const mainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();
6660
6661 const { manager } = yield* makeManager({
6662 ghScenario: {
6663 pullRequest: {
6664 number: 91,
6665 title: "Fork main PR",
6666 url: "https://github.com/pingdotgg/codething-mvp/pull/91",
6667 baseRefName: "main",
6668 headRefName: "main",
6669 state: "open",
6670 isCrossRepository: true,
6671 headRepositoryNameWithOwner: "octocat/codething-mvp",
6672 headRepositoryOwnerLogin: "octocat",
6673 },
6674 repositoryCloneUrls: {
6675 "octocat/codething-mvp": {
6676 url: forkDir,
6677 sshUrl: forkDir,
6678 },
6679 },
6680 },
6681 });
6682
6683 const result = yield* preparePullRequestThread(manager, {
6684 cwd: repoDir,
6685 reference: "91",
6686 mode: "worktree",
6687 });
6688
6689 expect(result.branch).toBe("t3code/pr-91/main");
6690 expect(result.worktreePath).not.toBeNull();
6691 expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main");
6692 expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainBefore);
6693 expect(
6694 (yield* runGit(result.worktreePath as string, [
6695 "branch",
6696 "--show-current",
6697 ])).stdout.trim(),
6698 ).toBe("t3code/pr-91/main");
6699 }),
6700 );
6701
6702 it.effect(
6703 "does not overwrite an existing local main branch when preparing a fork PR worktree",
6704 () =>
6705 Effect.gen(function* () {
6706 const repoDir = yield* makeTempDir("t3code-git-manager-");
6707 yield* initRepo(repoDir);
6708 const originDir = yield* createBareRemote();
6709 const forkDir = yield* createBareRemote();
6710 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
6711 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
6712 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
6713 yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]);
6714 NodeFS.writeFileSync(NodePath.join(repoDir, "fork-main-second.txt"), "fork main second\n");
6715 yield* runGit(repoDir, ["add", "fork-main-second.txt"]);
6716 yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]);
6717 yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]);
6718 yield* runGit(repoDir, ["checkout", "main"]);
6719 const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();
6720 yield* runGit(repoDir, ["checkout", "-b", "feature/root-branch"]);
6721
6722 const { manager } = yield* makeManager({
6723 ghScenario: {
6724 pullRequest: {
6725 number: 92,
6726 title: "Fork main overwrite PR",
6727 url: "https://github.com/pingdotgg/codething-mvp/pull/92",
6728 baseRefName: "main",
6729 headRefName: "main",
6730 state: "open",
6731 isCrossRepository: true,
6732 headRepositoryNameWithOwner: "octocat/codething-mvp",
6733 headRepositoryOwnerLogin: "octocat",
6734 },
6735 repositoryCloneUrls: {
6736 "octocat/codething-mvp": {
6737 url: forkDir,
6738 sshUrl: forkDir,
6739 },
6740 },
6741 },
6742 });
6743
6744 const result = yield* preparePullRequestThread(manager, {
6745 cwd: repoDir,
6746 reference: "92",
6747 mode: "worktree",
6748 });
6749
6750 expect(result.branch).toBe("t3code/pr-92/main");
6751 expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore);
6752 expect(
6753 (yield* runGit(result.worktreePath as string, [
6754 "rev-parse",
6755 "--abbrev-ref",
6756 "@{upstream}",
6757 ])).stdout.trim(),
6758 ).toBe("fork-seed/main");
6759 }),
6760 );
6761
6762 it.effect("reuses an existing PR worktree and restores fork upstream tracking", () =>
6763 Effect.gen(function* () {
6764 const repoDir = yield* makeTempDir("t3code-git-manager-");
6765 yield* initRepo(repoDir);
6766 const originDir = yield* createBareRemote();
6767 const forkDir = yield* createBareRemote();
6768 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
6769 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
6770 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
6771 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-fork"]);
6772 NodeFS.writeFileSync(NodePath.join(repoDir, "reused-fork.txt"), "reused fork\n");
6773 yield* runGit(repoDir, ["add", "reused-fork.txt"]);
6774 yield* runGit(repoDir, ["commit", "-m", "Reused fork PR branch"]);
6775 yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-reused-fork"]);
6776 yield* runGit(repoDir, ["checkout", "main"]);
6777 const worktreePath = NodePath.join(
6778 repoDir,
6779 "..",
6780 `pr-reused-fork-${NodePath.basename(repoDir)}`,
6781 );
6782 yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-fork"]);
6783 yield* runGit(worktreePath, ["branch", "--unset-upstream"], true);
6784
6785 const { manager } = yield* makeManager({
6786 ghScenario: {
6787 pullRequest: {
6788 number: 83,
6789 title: "Reused Fork PR",
6790 url: "https://github.com/pingdotgg/codething-mvp/pull/83",
6791 baseRefName: "main",
6792 headRefName: "feature/pr-reused-fork",
6793 state: "open",
6794 isCrossRepository: true,
6795 headRepositoryNameWithOwner: "octocat/codething-mvp",
6796 headRepositoryOwnerLogin: "octocat",
6797 },
6798 repositoryCloneUrls: {
6799 "octocat/codething-mvp": {
6800 url: forkDir,
6801 sshUrl: forkDir,
6802 },
6803 },
6804 },
6805 });
6806
6807 const result = yield* preparePullRequestThread(manager, {
6808 cwd: repoDir,
6809 reference: "83",
6810 mode: "worktree",
6811 });
6812
6813 expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe(
6814 NodeFS.realpathSync.native(worktreePath),
6815 );
6816 expect(
6817 (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(),
6818 ).toBe("fork-seed/feature/pr-reused-fork");
6819 }),
6820 );
6821
6822 it.effect("does not fail PR worktree prep when setup terminal startup fails", () =>
6823 Effect.gen(function* () {
6824 const repoDir = yield* makeTempDir("t3code-git-manager-");
6825 yield* initRepo(repoDir);
6826 const remoteDir = yield* createBareRemote();
6827 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
6828 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
6829 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-setup-failure"]);
6830 NodeFS.writeFileSync(NodePath.join(repoDir, "setup-failure.txt"), "setup failure\n");
6831 yield* runGit(repoDir, ["add", "setup-failure.txt"]);
6832 yield* runGit(repoDir, ["commit", "-m", "PR setup failure branch"]);
6833 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-setup-failure"]);
6834 yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/184/head"]);
6835 yield* runGit(repoDir, ["checkout", "main"]);
6836
6837 const { manager } = yield* makeManager({
6838 ghScenario: {
6839 pullRequest: {
6840 number: 184,
6841 title: "Setup failure PR",
6842 url: "https://github.com/pingdotgg/codething-mvp/pull/184",
6843 baseRefName: "main",
6844 headRefName: "feature/pr-setup-failure",
6845 state: "open",
6846 },
6847 },
6848 setupScriptRunner: {
6849 runForThread: (input) =>
6850 Effect.fail(
6851 new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({
6852 threadId: input.threadId,
6853 worktreePath: input.worktreePath,
6854 operation: "openTerminal",
6855 cause: new Error("terminal start failed"),
6856 }),
6857 ),
6858 },
6859 });
6860
6861 const result = yield* preparePullRequestThread(manager, {
6862 cwd: repoDir,
6863 reference: "184",
6864 mode: "worktree",
6865 threadId: asThreadId("thread-pr-setup-failure"),
6866 });
6867
6868 expect(result.branch).toBe("feature/pr-setup-failure");
6869 expect(result.worktreePath).not.toBeNull();
6870 expect(NodeFS.existsSync(result.worktreePath as string)).toBe(true);
6871 }),
6872 );
6873
6874 it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () =>
6875 Effect.gen(function* () {
6876 const repoDir = yield* makeTempDir("t3code-git-manager-");
6877 yield* initRepo(repoDir);
6878 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-root-only"]);
6879
6880 const { manager } = yield* makeManager({
6881 ghScenario: {
6882 pullRequest: {
6883 number: 79,
6884 title: "Root-only PR",
6885 url: "https://github.com/pingdotgg/codething-mvp/pull/79",
6886 baseRefName: "main",
6887 headRefName: "feature/pr-root-only",
6888 state: "open",
6889 },
6890 },
6891 });
6892
6893 const errorMessage = yield* preparePullRequestThread(manager, {
6894 cwd: repoDir,
6895 reference: "79",
6896 mode: "worktree",
6897 }).pipe(
6898 Effect.flip,
6899 Effect.map((error) => error.message),
6900 );
6901
6902 expect(errorMessage).toContain("already checked out in the main repo");
6903 }),
6904 );
6905
6906 it.effect("emits ordered progress events for commit hooks", () =>
6907 Effect.gen(function* () {
6908 const repoDir = yield* makeTempDir("t3code-git-manager-");
6909 yield* initRepo(repoDir);
6910 NodeFS.writeFileSync(NodePath.join(repoDir, "hooked.txt"), "hooked\n");
6911 NodeFS.writeFileSync(
6912 NodePath.join(repoDir, ".git", "hooks", "pre-commit"),
6913 '#!/bin/sh\necho "hook: start" >&2\nsleep 0.05\necho "hook: end" >&2\n',
6914 { mode: 0o755 },
6915 );
6916
6917 const { manager } = yield* makeManager();
6918 const events: GitActionProgressEvent[] = [];
6919
6920 const result = yield* runStackedAction(
6921 manager,
6922 {
6923 cwd: repoDir,
6924 action: "commit",
6925 },
6926 {
6927 actionId: "action-1",
6928 progressReporter: {
6929 publish: (event) =>
6930 Effect.sync(() => {
6931 events.push(event);
6932 }),
6933 },
6934 },
6935 );
6936
6937 expect(result.commit.status).toBe("created");
6938 expect(events.map((event) => event.kind)).toContain("action_started");
6939 expect(events).toEqual(
6940 expect.arrayContaining([
6941 expect.objectContaining({
6942 kind: "phase_started",
6943 phase: "commit",
6944 }),
6945 expect.objectContaining({
6946 kind: "hook_started",
6947 hookName: "pre-commit",
6948 }),
6949 expect.objectContaining({
6950 kind: "hook_output",
6951 text: "hook: start",
6952 }),
6953 expect.objectContaining({
6954 kind: "hook_output",
6955 text: "hook: end",
6956 }),
6957 expect.objectContaining({
6958 kind: "hook_finished",
6959 hookName: "pre-commit",
6960 }),
6961 expect.objectContaining({
6962 kind: "action_finished",
6963 }),
6964 ]),
6965 );
6966 }),
6967 );
6968
6969 it.effect("emits action_failed when a commit hook rejects", () =>
6970 Effect.gen(function* () {
6971 const repoDir = yield* makeTempDir("t3code-git-manager-");
6972 yield* initRepo(repoDir);
6973 NodeFS.writeFileSync(NodePath.join(repoDir, "hook-failure.txt"), "broken\n");
6974 NodeFS.writeFileSync(
6975 NodePath.join(repoDir, ".git", "hooks", "pre-commit"),
6976 '#!/bin/sh\necho "hook: fail" >&2\nexit 1\n',
6977 { mode: 0o755 },
6978 );
6979
6980 const { manager } = yield* makeManager();
6981 const events: GitActionProgressEvent[] = [];
6982
6983 const errorMessage = yield* runStackedAction(
6984 manager,
6985 {
6986 cwd: repoDir,
6987 action: "commit",
6988 },
6989 {
6990 actionId: "action-2",
6991 progressReporter: {
6992 publish: (event) =>
6993 Effect.sync(() => {
6994 events.push(event);
6995 }),
6996 },
6997 },
6998 ).pipe(
6999 Effect.flip,
7000 Effect.map((error) => error.message),
7001 );
7002
7003 expect(errorMessage).toContain("Git command failed in GitVcsDriver.commit.commit");
7004 expect(errorMessage).not.toContain("hook: fail");
7005 expect(events).toEqual(
7006 expect.arrayContaining([
7007 expect.objectContaining({
7008 kind: "hook_started",
7009 hookName: "pre-commit",
7010 }),
7011 expect.objectContaining({
7012 kind: "hook_output",
7013 text: "hook: fail",
7014 }),
7015 expect.objectContaining({
7016 kind: "action_failed",
7017 phase: "commit",
7018 }),
7019 ]),
7020 );
7021 }),
7022 );
7023
7024 it.effect("create_pr preserves dirty work on an already pushed branch", () =>
7025 Effect.gen(function* () {
7026 const repoDir = yield* makeTempDir("t3code-git-manager-");
7027 yield* initRepo(repoDir);
7028 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-only-follow-up"]);
7029 const remoteDir = yield* createBareRemote();
7030 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
7031 NodeFS.writeFileSync(NodePath.join(repoDir, "pr-only.txt"), "pr only\n");
7032 yield* runGit(repoDir, ["add", "pr-only.txt"]);
7033 yield* runGit(repoDir, ["commit", "-m", "PR only branch"]);
7034 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]);
7035 const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout;
7036 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "staged readme\n");
7037 yield* runGit(repoDir, ["add", "README.md"]);
7038 NodeFS.writeFileSync(NodePath.join(repoDir, "pr-only.txt"), "unstaged feature work\n");
7039 NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked work\n");
7040 const statusBefore = (yield* runGit(repoDir, ["status", "--porcelain"])).stdout;
7041 const stagedBefore = (yield* runGit(repoDir, ["diff", "--cached"])).stdout;
7042 let generatedContent: TextGeneration.PrContentGenerationInput | undefined;
7043
7044 const { manager } = yield* makeManager({
7045 textGeneration: {
7046 generatePrContent: (input) => {
7047 generatedContent = input;
7048 return Effect.succeed({ title: "PR only branch", body: "Committed feature work" });
7049 },
7050 },
7051 ghScenario: {
7052 prListSequence: [
7053 JSON.stringify([]),
7054 JSON.stringify([
7055 {
7056 number: 201,
7057 title: "PR only branch",
7058 url: "https://github.com/pingdotgg/codething-mvp/pull/201",
7059 baseRefName: "main",
7060 headRefName: "feature/pr-only-follow-up",
7061 state: "OPEN",
7062 isCrossRepository: false,
7063 },
7064 ]),
7065 ],
7066 },
7067 });
7068 const events: GitActionProgressEvent[] = [];
7069
7070 const result = yield* runStackedAction(
7071 manager,
7072 {
7073 cwd: repoDir,
7074 action: "create_pr",
7075 },
7076 {
7077 actionId: "action-pr-only",
7078 progressReporter: {
7079 publish: (event) =>
7080 Effect.sync(() => {
7081 events.push(event);
7082 }),
7083 },
7084 },
7085 );
7086
7087 expect(result.commit.status).toBe("skipped_not_requested");
7088 expect(result.push.status).toBe("skipped_not_requested");
7089 expect(result.pr.status).toBe("created");
7090 expect(generatedContent?.diffPatch).toContain("+pr only");
7091 expect(generatedContent?.diffPatch).not.toContain("staged readme");
7092 expect(generatedContent?.diffPatch).not.toContain("unstaged feature work");
7093 expect((yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout).toBe(headBefore);
7094 expect((yield* runGit(repoDir, ["status", "--porcelain"])).stdout).toBe(statusBefore);
7095 expect((yield* runGit(repoDir, ["diff", "--cached"])).stdout).toBe(stagedBefore);
7096 expect(
7097 events.filter(
7098 (event): event is Extract<GitActionProgressEvent, { kind: "phase_started" }> =>
7099 event.kind === "phase_started",
7100 ),
7101 ).toEqual([
7102 expect.objectContaining({
7103 kind: "phase_started",
7104 phase: "pr",
7105 label: "Preparing PR...",
7106 }),
7107 expect.objectContaining({
7108 kind: "phase_started",
7109 phase: "pr",
7110 label: "Generating PR content...",
7111 }),
7112 expect.objectContaining({
7113 kind: "phase_started",
7114 phase: "pr",
7115 label: "Creating pull request...",
7116 }),
7117 ]);
7118 }),
7119 );
7120});