import fs from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; import { expect } from "vitest"; import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts"; import { type GitManagerShape } from "../Services/GitManager.ts"; import { type GitHubCliShape, type GitHubPullRequestSummary, GitHubCli, } from "../Services/GitHubCli.ts"; import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; import { GitServiceLive } from "./GitService.ts"; import { GitService } from "../Services/GitService.ts"; import { GitCoreLive } from "./GitCore.ts"; import { makeGitManager } from "./GitManager.ts"; interface FakeGhScenario { prListSequence?: string[]; prListByHeadSelector?: Record; createdPrUrl?: string; defaultBranch?: string; pullRequest?: { number: number; title: string; url: string; baseRefName: string; headRefName: string; state?: "open" | "closed" | "merged"; isCrossRepository?: boolean; headRepositoryNameWithOwner?: string | null; headRepositoryOwnerLogin?: string | null; }; repositoryCloneUrls?: Record; failWith?: GitHubCliError; } interface FakeGitTextGeneration { generateCommitMessage: (input: { cwd: string; branch: string | null; stagedSummary: string; stagedPatch: string; includeBranch?: boolean; }) => Effect.Effect< { subject: string; body: string; branch?: string | undefined }, TextGenerationError >; generatePrContent: (input: { cwd: string; baseBranch: string; headBranch: string; commitSummary: string; diffSummary: string; diffPatch: string; }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; generateBranchName: (input: { cwd: string; message: string; }) => Effect.Effect<{ branch: string }, TextGenerationError>; } type FakePullRequest = NonNullable; function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { const result = spawnSync("git", args, { cwd, encoding: "utf8", }); if (result.status === 0) { return; } throw new GitHubCliError({ operation: "execute", detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, }); } function isGitHubCliError(error: unknown): error is GitHubCliError { return ( typeof error === "object" && error !== null && "_tag" in error && (error as { _tag?: unknown })._tag === "GitHubCliError" ); } function makeTempDir( prefix: string, ): Effect.Effect { return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.makeTempDirectoryScoped({ prefix }); }); } function runGit( cwd: string, args: readonly string[], allowNonZeroExit = false, ): Effect.Effect< { readonly code: number; readonly stdout: string; readonly stderr: string }, GitCommandError, GitService > { return Effect.gen(function* () { const gitService = yield* GitService; return yield* gitService.execute({ operation: "GitManager.test.runGit", cwd, args, allowNonZeroExit, }); }); } function initRepo( cwd: string, ): Effect.Effect< void, PlatformError.PlatformError | GitCommandError, FileSystem.FileSystem | Scope.Scope | GitService > { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* runGit(cwd, ["init", "--initial-branch=main"]); yield* runGit(cwd, ["config", "user.email", "test@example.com"]); yield* runGit(cwd, ["config", "user.name", "Test User"]); yield* fs.writeFileString(path.join(cwd, "README.md"), "hello\n"); yield* runGit(cwd, ["add", "README.md"]); yield* runGit(cwd, ["commit", "-m", "Initial commit"]); }); } function createBareRemote(): Effect.Effect< string, PlatformError.PlatformError | GitCommandError, FileSystem.FileSystem | Scope.Scope | GitService > { return Effect.gen(function* () { const remoteDir = yield* makeTempDir("t3code-git-remote-"); yield* runGit(remoteDir, ["init", "--bare"]); return remoteDir; }); } function createTextGeneration(overrides: Partial = {}): TextGenerationShape { const implementation: FakeGitTextGeneration = { generateCommitMessage: (input) => Effect.succeed({ subject: "Implement stacked git actions", body: "", ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), }), generatePrContent: () => Effect.succeed({ title: "Add stacked git actions", body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", }), generateBranchName: () => Effect.succeed({ branch: "update-workflow", }), ...overrides, }; return { generateCommitMessage: (input) => implementation.generateCommitMessage(input).pipe( Effect.mapError( (cause) => new TextGenerationError({ operation: "generateCommitMessage", detail: "fake text generation failed", ...(cause !== undefined ? { cause } : {}), }), ), ), generatePrContent: (input) => implementation.generatePrContent(input).pipe( Effect.mapError( (cause) => new TextGenerationError({ operation: "generatePrContent", detail: "fake text generation failed", ...(cause !== undefined ? { cause } : {}), }), ), ), generateBranchName: (input) => implementation.generateBranchName(input).pipe( Effect.mapError( (cause) => new TextGenerationError({ operation: "generateBranchName", detail: "fake text generation failed", ...(cause !== undefined ? { cause } : {}), }), ), ), }; } function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { service: GitHubCliShape; ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; const ghCalls: string[] = []; const execute: GitHubCliShape["execute"] = (input) => { const args = [...input.args]; ghCalls.push(args.join(" ")); if (scenario.failWith) { return Effect.fail(scenario.failWith); } if (args[0] === "pr" && args[1] === "list") { const headSelectorIndex = args.findIndex((value) => value === "--head"); const headSelector = headSelectorIndex >= 0 && headSelectorIndex < args.length - 1 ? args[headSelectorIndex + 1] : undefined; const mappedStdout = typeof headSelector === "string" ? scenario.prListByHeadSelector?.[headSelector] : undefined; const stdout = (mappedStdout ?? prListQueue.shift() ?? "[]") + "\n"; return Effect.succeed({ stdout, stderr: "", code: 0, signal: null, timedOut: false, }); } if (args[0] === "pr" && args[1] === "create") { return Effect.succeed({ stdout: (scenario.createdPrUrl ?? "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n", stderr: "", code: 0, signal: null, timedOut: false, }); } if (args[0] === "pr" && args[1] === "view") { const pullRequest: FakePullRequest = scenario.pullRequest ?? { number: 101, title: "Pull request", url: "https://github.com/pingdotgg/codething-mvp/pull/101", baseRefName: "main", headRefName: "feature/pull-request", state: "open", }; return Effect.succeed({ stdout: JSON.stringify({ ...pullRequest, ...(pullRequest.headRepositoryNameWithOwner ? { headRepository: { nameWithOwner: pullRequest.headRepositoryNameWithOwner, }, } : {}), ...(pullRequest.headRepositoryOwnerLogin ? { headRepositoryOwner: { login: pullRequest.headRepositoryOwnerLogin, }, } : {}), }) + "\n", stderr: "", code: 0, signal: null, timedOut: false, }); } if (args[0] === "pr" && args[1] === "checkout") { return Effect.try({ try: () => { const headBranch = scenario.pullRequest?.headRefName; if (headBranch) { const existingBranch = spawnSync( "git", ["show-ref", "--verify", "--quiet", `refs/heads/${headBranch}`], { cwd: input.cwd, encoding: "utf8", }, ); if (existingBranch.status === 0) { runGitSyncForFakeGh(input.cwd, ["checkout", headBranch]); } else { runGitSyncForFakeGh(input.cwd, ["checkout", "-b", headBranch]); } } return { stdout: "", stderr: "", code: 0, signal: null, timedOut: false, }; }, catch: (error) => isGitHubCliError(error) ? error : new GitHubCliError({ operation: "execute", detail: error instanceof Error ? `Failed to simulate gh checkout: ${error.message}` : "Failed to simulate gh checkout.", }), }); } if (args[0] === "repo" && args[1] === "view") { const repository = args[2]; if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) { const cloneUrls = scenario.repositoryCloneUrls?.[repository]; if (!cloneUrls) { return Effect.fail( new GitHubCliError({ operation: "execute", detail: `Unexpected repository lookup: ${repository}`, }), ); } return Effect.succeed({ stdout: JSON.stringify({ nameWithOwner: repository, url: cloneUrls.url, sshUrl: cloneUrls.sshUrl, }) + "\n", stderr: "", code: 0, signal: null, timedOut: false, }); } return Effect.succeed({ stdout: `${scenario.defaultBranch ?? "main"}\n`, stderr: "", code: 0, signal: null, timedOut: false, }); } return Effect.fail( new GitHubCliError({ operation: "execute", detail: `Unexpected gh command: ${args.join(" ")}`, }), ); }; return { service: { execute, listOpenPullRequests: (input) => execute({ cwd: input.cwd, args: [ "pr", "list", "--head", input.headSelector, "--state", "open", "--limit", String(input.limit ?? 1), "--json", "number,title,url,baseRefName,headRefName", ], }).pipe( Effect.map( (result) => JSON.parse(result.stdout) as ReadonlyArray, ), ), createPullRequest: (input) => execute({ cwd: input.cwd, args: [ "pr", "create", "--base", input.baseBranch, "--head", input.headSelector, "--title", input.title, "--body-file", input.bodyFile, ], }).pipe(Effect.asVoid), getDefaultBranch: (input) => execute({ cwd: input.cwd, args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], }).pipe( Effect.map((result) => { const value = result.stdout.trim(); return value.length > 0 ? value : null; }), ), getPullRequest: (input) => execute({ cwd: input.cwd, args: [ "pr", "view", input.reference, "--json", "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe(Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary)), getRepositoryCloneUrls: (input) => execute({ cwd: input.cwd, args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], }).pipe(Effect.map((result) => JSON.parse(result.stdout))), checkoutPullRequest: (input) => execute({ cwd: input.cwd, args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], }).pipe(Effect.asVoid), }, ghCalls, }; } function runStackedAction( manager: GitManagerShape, input: { cwd: string; action: "commit" | "commit_push" | "commit_push_pr"; commitMessage?: string; featureBranch?: boolean; filePaths?: readonly string[]; }, ) { return manager.runStackedAction(input); } function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) { return manager.resolvePullRequest(input); } function preparePullRequestThread( manager: GitManagerShape, input: { cwd: string; reference: string; mode: "local" | "worktree" }, ) { return manager.preparePullRequestThread(input); } function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); const gitCoreLayer = GitCoreLive.pipe( Layer.provideMerge(GitServiceLive), Layer.provideMerge(NodeServices.layer), ); const managerLayer = Layer.mergeAll( Layer.succeed(GitHubCli, gitHubCli), Layer.succeed(TextGeneration, textGeneration), gitCoreLayer, NodeServices.layer, ); return makeGitManager.pipe( Effect.provide(managerLayer), Effect.map((manager) => ({ manager, ghCalls })), ); } const GitManagerTestLayer = Layer.provideMerge(GitServiceLive, NodeServices.layer); it.layer(GitManagerTestLayer)("GitManager", (it) => { it.effect("status includes PR metadata when branch already has an open PR", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ JSON.stringify([ { number: 13, title: "Existing PR", url: "https://github.com/pingdotgg/codething-mvp/pull/13", baseRefName: "main", headRefName: "feature/status-open-pr", }, ]), ], }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.branch).toBe("feature/status-open-pr"); expect(status.pr).toEqual({ number: 13, title: "Existing PR", url: "https://github.com/pingdotgg/codething-mvp/pull/13", baseBranch: "main", headBranch: "feature/status-open-pr", state: "open", }); }), ); it.effect( "status detects cross-repo PRs from the upstream remote URL owner", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const forkDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); fs.writeFileSync(path.join(repoDir, "fork-pr.txt"), "fork pr\n"); yield* runGit(repoDir, ["add", "fork-pr.txt"]); yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-488/statemachine"]); yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); yield* runGit(repoDir, [ "config", "remote.fork-seed.url", "git@github.com:jasonLaster/codething-mvp.git", ]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ JSON.stringify([]), JSON.stringify([]), JSON.stringify([ { number: 488, title: "Rebase this PR on latest main", url: "https://github.com/pingdotgg/codething-mvp/pull/488", baseRefName: "main", headRefName: "statemachine", state: "OPEN", updatedAt: "2026-03-10T07:00:00Z", }, ]), ], }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.branch).toBe("t3code/pr-488/statemachine"); expect(status.pr).toEqual({ number: 488, title: "Rebase this PR on latest main", url: "https://github.com/pingdotgg/codething-mvp/pull/488", baseBranch: "main", headBranch: "statemachine", state: "open", }); expect(ghCalls).toContain( "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", ); }), 12_000, ); it.effect("status returns merged PR state when latest PR was merged", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]); const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ JSON.stringify([ { number: 22, title: "Merged PR", url: "https://github.com/pingdotgg/codething-mvp/pull/22", baseRefName: "main", headRefName: "feature/status-merged-pr", state: "MERGED", mergedAt: "2026-01-30T10:00:00Z", updatedAt: "2026-01-30T10:00:00Z", }, ]), ], }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.branch).toBe("feature/status-merged-pr"); expect(status.pr).toEqual({ number: 22, title: "Merged PR", url: "https://github.com/pingdotgg/codething-mvp/pull/22", baseBranch: "main", headBranch: "feature/status-merged-pr", state: "merged", }); }), ); it.effect("status prefers open PR when merged PR has newer updatedAt", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-over-merged"]); const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ JSON.stringify([ { number: 45, title: "Merged PR", url: "https://github.com/pingdotgg/codething-mvp/pull/45", baseRefName: "main", headRefName: "feature/status-open-over-merged", state: "MERGED", mergedAt: "2026-01-31T10:00:00Z", updatedAt: "2026-02-01T10:00:00Z", }, { number: 46, title: "Open PR", url: "https://github.com/pingdotgg/codething-mvp/pull/46", baseRefName: "main", headRefName: "feature/status-open-over-merged", state: "OPEN", updatedAt: "2026-01-30T10:00:00Z", }, ]), ], }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.branch).toBe("feature/status-open-over-merged"); expect(status.pr).toEqual({ number: 46, title: "Open PR", url: "https://github.com/pingdotgg/codething-mvp/pull/46", baseBranch: "main", headBranch: "feature/status-open-over-merged", state: "open", }); }), ); it.effect("status is resilient to gh lookup failures and returns pr null", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]); const { manager } = yield* makeManager({ ghScenario: { failWith: new GitHubCliError({ operation: "execute", detail: "GitHub CLI (`gh`) is required but not available on PATH.", }), }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.branch).toBe("feature/status-no-gh"); expect(status.pr).toBeNull(); }), ); it.effect("creates a commit when working tree is dirty", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n"); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit", }); expect(result.branch.status).toBe("skipped_not_requested"); expect(result.commit.status).toBe("created"); expect(result.push.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("skipped_not_requested"); expect( yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( Effect.map((result) => result.stdout.trim()), ), ).toBe("Implement stacked git actions"); }), ); it.effect("uses custom commit message when provided", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n"); let generatedCount = 0; const { manager } = yield* makeManager({ textGeneration: { generateCommitMessage: (input) => Effect.sync(() => { generatedCount += 1; return { subject: "this should not be used", body: "", ...(input.includeBranch ? { branch: "feature/unused" } : {}), }; }), }, }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit", commitMessage: "feat: custom summary line\n\n- details from user", }); expect(result.branch.status).toBe("skipped_not_requested"); expect(result.commit.status).toBe("created"); expect(result.commit.subject).toBe("feat: custom summary line"); expect(generatedCount).toBe(0); expect( yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( Effect.map((result) => result.stdout.trim()), ), ).toBe("feat: custom summary line"); expect( yield* runGit(repoDir, ["log", "-1", "--pretty=%b"]).pipe( Effect.map((result) => result.stdout.trim()), ), ).toContain("- details from user"); }), ); it.effect("commits only selected files when filePaths is provided", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); fs.writeFileSync(path.join(repoDir, "a.txt"), "file a\n"); fs.writeFileSync(path.join(repoDir, "b.txt"), "file b\n"); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit", filePaths: ["a.txt"], }); expect(result.commit.status).toBe("created"); // b.txt should remain in the working tree const statusStdout = yield* runGit(repoDir, ["status", "--porcelain"]).pipe( Effect.map((r) => r.stdout), ); expect(statusStdout).toContain("b.txt"); expect(statusStdout).not.toContain("a.txt"); }), ); it.effect("creates feature branch, commits, and pushes with featureBranch option", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n"); let generatedCount = 0; const { manager } = yield* makeManager({ textGeneration: { generateCommitMessage: (input) => Effect.sync(() => { generatedCount += 1; return { subject: "Implement stacked git actions", body: "", ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), }; }), }, }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push", featureBranch: true, }); expect(result.branch.status).toBe("created"); expect(result.branch.name).toBe("feature/implement-stacked-git-actions"); expect(result.commit.status).toBe("created"); expect(result.push.status).toBe("pushed"); expect( yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe( Effect.map((result) => result.stdout.trim()), ), ).toBe("feature/implement-stacked-git-actions"); const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe( Effect.map((r) => r.stdout.trim()), ); const mergeBase = yield* runGit(repoDir, ["merge-base", "main", "HEAD"]).pipe( Effect.map((r) => r.stdout.trim()), ); expect(mergeBase).toBe(mainSha); expect(generatedCount).toBe(1); }), ); it.effect("featureBranch uses custom commit message and derives branch name", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-feature\n"); let generatedCount = 0; const { manager } = yield* makeManager({ textGeneration: { generateCommitMessage: (input) => Effect.sync(() => { generatedCount += 1; return { subject: "unused", body: "", ...(input.includeBranch ? { branch: "feature/unused" } : {}), }; }), }, }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit", featureBranch: true, commitMessage: "feat: custom summary line\n\n- details from user", }); expect(result.branch.status).toBe("created"); expect(result.branch.name).toBe("feature/feat-custom-summary-line"); expect(result.commit.status).toBe("created"); expect(result.commit.subject).toBe("feat: custom summary line"); expect(generatedCount).toBe(0); const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe( Effect.map((r) => r.stdout.trim()), ); const mergeBase = yield* runGit(repoDir, ["merge-base", "main", result.branch.name!]).pipe( Effect.map((r) => r.stdout.trim()), ); expect(mergeBase).toBe(mainSha); }), ); it.effect("skips commit when there are no uncommitted changes", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit", }); expect(result.branch.status).toBe("skipped_not_requested"); expect(result.commit.status).toBe("skipped_no_changes"); expect(result.push.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("skipped_not_requested"); }), ); it.effect("featureBranch returns error when worktree is clean", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const { manager } = yield* makeManager(); const errorMessage = yield* runStackedAction(manager, { cwd: repoDir, action: "commit", featureBranch: true, }).pipe( Effect.flip, Effect.map((error) => error.message), ); expect(errorMessage).toContain("no changes to commit"); }), ); it.effect("commits and pushes with upstream auto-setup when needed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push", }); expect(result.branch.status).toBe("skipped_not_requested"); expect(result.commit.status).toBe("created"); expect(result.push.status).toBe("pushed"); expect(result.push.setUpstream).toBe(true); expect( yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( Effect.map((result) => result.stdout.trim()), ), ).toBe("origin/feature/stacked-flow"); }), ); it.effect( "pushes and creates PR from a no-upstream branch when local commits are ahead of base", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-pr"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ "[]", JSON.stringify([ { number: 77, title: "Add no-upstream PR flow", url: "https://github.com/pingdotgg/codething-mvp/pull/77", baseRefName: "main", headRefName: "feature/no-upstream-pr", }, ]), ], }, }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push_pr", }); expect(result.branch.status).toBe("skipped_not_requested"); expect(result.commit.status).toBe("created"); expect(result.push.status).toBe("pushed"); expect(result.push.setUpstream).toBe(true); expect(result.pr.status).toBe("created"); expect( yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( Effect.map((result) => result.stdout.trim()), ), ).toBe("origin/feature/no-upstream-pr"); expect( ghCalls.some((call) => call.includes("pr create --base main --head feature/no-upstream-pr"), ), ).toBe(true); }), ); it.effect("skips push when branch is already up to date", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push", }); expect(result.branch.status).toBe("skipped_not_requested"); expect(result.commit.status).toBe("skipped_no_changes"); expect(result.push.status).toBe("skipped_up_to_date"); }), ); it.effect("returns existing PR metadata for commit/push/pr action", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ JSON.stringify([ { number: 42, title: "Existing PR", url: "https://github.com/pingdotgg/codething-mvp/pull/42", baseRefName: "main", headRefName: "feature/existing-pr", }, ]), ], }, }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push_pr", }); expect(result.branch.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(42); expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); }), ); it.effect( "returns existing cross-repo PR metadata using the fork owner selector", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); const forkDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); yield* runGit(repoDir, [ "config", "remote.fork-seed.url", "git@github.com:octocat/codething-mvp.git", ]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ JSON.stringify([]), JSON.stringify([ { number: 142, title: "Existing fork PR", url: "https://github.com/pingdotgg/codething-mvp/pull/142", baseRefName: "main", headRefName: "statemachine", }, ]), ], }, }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push_pr", }); expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(142); expect( ghCalls.some((call) => call.includes("pr list --head octocat:statemachine --state open --limit 1"), ), ).toBe(true); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); }), 12_000, ); it.effect( "prefers owner-qualified selectors before bare branch names for cross-repo PRs", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); const forkDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); yield* runGit(repoDir, [ "config", "remote.fork-seed.url", "git@github.com:octocat/codething-mvp.git", ]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { "t3code/pr-142/statemachine": JSON.stringify([]), statemachine: JSON.stringify([ { number: 41, title: "Unrelated same-repo PR", url: "https://github.com/pingdotgg/codething-mvp/pull/41", baseRefName: "main", headRefName: "statemachine", }, ]), "octocat:statemachine": JSON.stringify([ { number: 142, title: "Existing fork PR", url: "https://github.com/pingdotgg/codething-mvp/pull/142", baseRefName: "main", headRefName: "statemachine", }, ]), "fork-seed:statemachine": JSON.stringify([]), }, }, }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push_pr", }); expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(142); const ownerSelectorCallIndex = ghCalls.findIndex((call) => call.includes("pr list --head octocat:statemachine --state open --limit 1"), ); expect(ownerSelectorCallIndex).toBeGreaterThanOrEqual(0); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); }), 12_000, ); it.effect( "stops probing head selectors after finding an existing PR", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); const forkDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); yield* runGit(repoDir, [ "config", "remote.fork-seed.url", "git@github.com:octocat/codething-mvp.git", ]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { "octocat:statemachine": JSON.stringify([ { number: 142, title: "Existing fork PR", url: "https://github.com/pingdotgg/codething-mvp/pull/142", baseRefName: "main", headRefName: "statemachine", }, ]), "fork-seed:statemachine": JSON.stringify([]), "t3code/pr-142/statemachine": JSON.stringify([]), statemachine: JSON.stringify([]), }, }, }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push_pr", }); expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(142); const prListCalls = ghCalls.filter((call) => call.startsWith("pr list ")); expect(prListCalls).toHaveLength(1); expect(prListCalls[0]).toContain( "pr list --head octocat:statemachine --state open --limit 1", ); }), 12_000, ); it.effect("creates PR when one does not already exist", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); yield* runGit(repoDir, ["add", "changes.txt"]); yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ "[]", JSON.stringify([ { number: 88, title: "Add stacked git actions", url: "https://github.com/pingdotgg/codething-mvp/pull/88", baseRefName: "main", headRefName: "feature-create-pr", }, ]), ], }, }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push_pr", }); expect(result.branch.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("created"); expect(result.pr.number).toBe(88); expect( ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), ).toBe(true); expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); }), ); it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const forkDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); yield* runGit(repoDir, ["add", "changes.txt"]); yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-91/statemachine"]); yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); yield* runGit(repoDir, [ "config", "remote.fork-seed.url", "git@github.com:octocat/codething-mvp.git", ]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ JSON.stringify([]), JSON.stringify([]), JSON.stringify([]), JSON.stringify([]), JSON.stringify([]), JSON.stringify([]), JSON.stringify([ { number: 188, title: "Add stacked git actions", url: "https://github.com/pingdotgg/codething-mvp/pull/188", baseRefName: "main", headRefName: "statemachine", }, ]), ], }, }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push_pr", }); expect(result.pr.status).toBe("created"); expect(result.pr.number).toBe(188); expect( ghCalls.some((call) => call.includes("pr create --base main --head octocat:statemachine")), ).toBe(true); expect( ghCalls.some((call) => call.includes("pr create --base statemachine --head octocat:statemachine"), ), ).toBe(false); }), ); it.effect("rejects push/pr actions from detached HEAD", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "--detach", "HEAD"]); const { manager } = yield* makeManager(); const errorMessage = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push", }).pipe( Effect.flip, Effect.map((error) => error.message), ); expect(errorMessage).toContain("detached HEAD"); }), ); it.effect("surfaces missing gh binary errors", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]); const { manager } = yield* makeManager({ ghScenario: { failWith: new GitHubCliError({ operation: "execute", detail: "GitHub CLI (`gh`) is required but not available on PATH.", }), }, }); const errorMessage = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push_pr", }).pipe( Effect.flip, Effect.map((error) => error.message), ); expect(errorMessage).toContain("GitHub CLI (`gh`) is required"); }), ); it.effect("surfaces gh auth errors with guidance", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]); const { manager } = yield* makeManager({ ghScenario: { failWith: new GitHubCliError({ operation: "execute", detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", }), }, }); const errorMessage = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push_pr", }).pipe( Effect.flip, Effect.map((error) => error.message), ); expect(errorMessage).toContain("gh auth login"); }), ); it.effect("resolves pull requests from #number references", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const { manager, ghCalls } = yield* makeManager({ ghScenario: { pullRequest: { number: 42, title: "Resolve PR", url: "https://github.com/pingdotgg/codething-mvp/pull/42", baseRefName: "main", headRefName: "feature/resolve-pr", state: "open", }, }, }); const result = yield* resolvePullRequest(manager, { cwd: repoDir, reference: "#42", }); expect(result.pullRequest).toEqual({ number: 42, title: "Resolve PR", url: "https://github.com/pingdotgg/codething-mvp/pull/42", baseBranch: "main", headBranch: "feature/resolve-pr", state: "open", }); expect(ghCalls.some((call) => call.startsWith("pr view 42 "))).toBe(true); }), ); it.effect("prepares pull request threads in local mode by checking out the PR branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local"]); fs.writeFileSync(path.join(repoDir, "local.txt"), "local\n"); yield* runGit(repoDir, ["add", "local.txt"]); yield* runGit(repoDir, ["commit", "-m", "Local PR branch"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { pullRequest: { number: 64, title: "Local PR", url: "https://github.com/pingdotgg/codething-mvp/pull/64", baseRefName: "main", headRefName: "feature/pr-local", state: "open", }, }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "#64", mode: "local", }); expect(result.branch).toBe("feature/pr-local"); expect(result.worktreePath).toBeNull(); const branch = (yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim(); expect(branch).toBe("feature/pr-local"); expect(ghCalls).toContain("pr checkout 64 --force"); }), ); it.effect("prepares pull request threads in worktree mode on the PR head branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree"]); fs.writeFileSync(path.join(repoDir, "worktree.txt"), "worktree\n"); yield* runGit(repoDir, ["add", "worktree.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR worktree branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree"]); yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/77/head"]); yield* runGit(repoDir, ["checkout", "main"]); const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 77, title: "Worktree PR", url: "https://github.com/pingdotgg/codething-mvp/pull/77", baseRefName: "main", headRefName: "feature/pr-worktree", state: "open", }, }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "77", mode: "worktree", }); expect(result.branch).toBe("feature/pr-worktree"); expect(result.worktreePath).not.toBeNull(); expect(fs.existsSync(result.worktreePath as string)).toBe(true); const worktreeBranch = (yield* runGit(result.worktreePath as string, [ "branch", "--show-current", ])).stdout.trim(); expect(worktreeBranch).toBe("feature/pr-worktree"); }), ); it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const originDir = yield* createBareRemote(); const forkDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", originDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-fork"]); fs.writeFileSync(path.join(repoDir, "fork.txt"), "fork\n"); yield* runGit(repoDir, ["add", "fork.txt"]); yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-fork"]); yield* runGit(repoDir, ["checkout", "main"]); const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 81, title: "Fork PR", url: "https://github.com/pingdotgg/codething-mvp/pull/81", baseRefName: "main", headRefName: "feature/pr-fork", state: "open", isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", }, repositoryCloneUrls: { "octocat/codething-mvp": { url: forkDir, sshUrl: forkDir, }, }, }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "81", mode: "worktree", }); expect(result.worktreePath).not.toBeNull(); const upstreamRef = (yield* runGit(result.worktreePath as string, [ "rev-parse", "--abbrev-ref", "@{upstream}", ])).stdout.trim(); expect(upstreamRef).toBe("fork-seed/feature/pr-fork"); expect(upstreamRef.startsWith("origin/")).toBe(false); expect( (yield* runGit(result.worktreePath as string, [ "config", "--get", "remote.fork-seed.url", ])).stdout.trim(), ).toBe(forkDir); }), ); it.effect("preserves fork upstream tracking when preparing a local PR thread", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const originDir = yield* createBareRemote(); const forkDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", originDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-fork"]); fs.writeFileSync(path.join(repoDir, "local-fork.txt"), "local fork\n"); yield* runGit(repoDir, ["add", "local-fork.txt"]); yield* runGit(repoDir, ["commit", "-m", "Local fork PR branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-local-fork"]); yield* runGit(repoDir, ["checkout", "main"]); yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-fork"]); const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 82, title: "Local Fork PR", url: "https://github.com/pingdotgg/codething-mvp/pull/82", baseRefName: "main", headRefName: "feature/pr-local-fork", state: "open", isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", }, repositoryCloneUrls: { "octocat/codething-mvp": { url: forkDir, sshUrl: forkDir, }, }, }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "82", mode: "local", }); expect(result.worktreePath).toBeNull(); expect(result.branch).toBe("feature/pr-local-fork"); expect( (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), ).toBe("fork-seed/feature/pr-local-fork"); }), ); it.effect("derives fork repository identity from PR URL when GitHub omits nameWithOwner", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const originDir = yield* createBareRemote(); const forkDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", originDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "binbandit-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "fix/git-action-default-without-origin"]); fs.writeFileSync(path.join(repoDir, "derived-fork.txt"), "derived fork\n"); yield* runGit(repoDir, ["add", "derived-fork.txt"]); yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]); yield* runGit(repoDir, [ "push", "-u", "binbandit-seed", "fix/git-action-default-without-origin", ]); yield* runGit(repoDir, ["checkout", "main"]); yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]); const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 642, title: "fix: use commit as the default git action without origin", url: "https://github.com/pingdotgg/t3code/pull/642", baseRefName: "main", headRefName: "fix/git-action-default-without-origin", state: "open", isCrossRepository: true, headRepositoryOwnerLogin: "binbandit", }, repositoryCloneUrls: { "binbandit/t3code": { url: forkDir, sshUrl: forkDir, }, }, }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "642", mode: "local", }); expect(result.branch).toBe("fix/git-action-default-without-origin"); expect(result.worktreePath).toBeNull(); expect( (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), ).toBe("binbandit-seed/fix/git-action-default-without-origin"); }), ); it.effect("reuses an existing dedicated worktree for the PR head branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-existing-worktree"]); fs.writeFileSync(path.join(repoDir, "existing.txt"), "existing\n"); yield* runGit(repoDir, ["add", "existing.txt"]); yield* runGit(repoDir, ["commit", "-m", "Existing worktree branch"]); yield* runGit(repoDir, ["checkout", "main"]); const worktreePath = path.join(repoDir, "..", `pr-existing-${Date.now()}`); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]); const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 78, title: "Existing worktree PR", url: "https://github.com/pingdotgg/codething-mvp/pull/78", baseRefName: "main", headRefName: "feature/pr-existing-worktree", state: "open", }, }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "78", mode: "worktree", }); expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( fs.realpathSync.native(worktreePath), ); expect(result.branch).toBe("feature/pr-existing-worktree"); }), ); it.effect( "does not block fork PR worktree prep when the fork head branch collides with root main", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const originDir = yield* createBareRemote(); const forkDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", originDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); fs.writeFileSync(path.join(repoDir, "fork-main.txt"), "fork main\n"); yield* runGit(repoDir, ["add", "fork-main.txt"]); yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); yield* runGit(repoDir, ["checkout", "main"]); const mainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 91, title: "Fork main PR", url: "https://github.com/pingdotgg/codething-mvp/pull/91", baseRefName: "main", headRefName: "main", state: "open", isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", }, repositoryCloneUrls: { "octocat/codething-mvp": { url: forkDir, sshUrl: forkDir, }, }, }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "91", mode: "worktree", }); expect(result.branch).toBe("t3code/pr-91/main"); expect(result.worktreePath).not.toBeNull(); expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainBefore); expect( (yield* runGit(result.worktreePath as string, [ "branch", "--show-current", ])).stdout.trim(), ).toBe("t3code/pr-91/main"); }), ); it.effect( "does not overwrite an existing local main branch when preparing a fork PR worktree", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const originDir = yield* createBareRemote(); const forkDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", originDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); fs.writeFileSync(path.join(repoDir, "fork-main-second.txt"), "fork main second\n"); yield* runGit(repoDir, ["add", "fork-main-second.txt"]); yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); yield* runGit(repoDir, ["checkout", "main"]); const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); yield* runGit(repoDir, ["checkout", "-b", "feature/root-branch"]); const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 92, title: "Fork main overwrite PR", url: "https://github.com/pingdotgg/codething-mvp/pull/92", baseRefName: "main", headRefName: "main", state: "open", isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", }, repositoryCloneUrls: { "octocat/codething-mvp": { url: forkDir, sshUrl: forkDir, }, }, }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "92", mode: "worktree", }); expect(result.branch).toBe("t3code/pr-92/main"); expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore); expect( (yield* runGit(result.worktreePath as string, [ "rev-parse", "--abbrev-ref", "@{upstream}", ])).stdout.trim(), ).toBe("fork-seed/main"); }), ); it.effect("reuses an existing PR worktree and restores fork upstream tracking", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const originDir = yield* createBareRemote(); const forkDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", originDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-fork"]); fs.writeFileSync(path.join(repoDir, "reused-fork.txt"), "reused fork\n"); yield* runGit(repoDir, ["add", "reused-fork.txt"]); yield* runGit(repoDir, ["commit", "-m", "Reused fork PR branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-reused-fork"]); yield* runGit(repoDir, ["checkout", "main"]); const worktreePath = path.join(repoDir, "..", `pr-reused-fork-${Date.now()}`); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-fork"]); yield* runGit(worktreePath, ["branch", "--unset-upstream"], true); const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 83, title: "Reused Fork PR", url: "https://github.com/pingdotgg/codething-mvp/pull/83", baseRefName: "main", headRefName: "feature/pr-reused-fork", state: "open", isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", }, repositoryCloneUrls: { "octocat/codething-mvp": { url: forkDir, sshUrl: forkDir, }, }, }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "83", mode: "worktree", }); expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( fs.realpathSync.native(worktreePath), ); expect( (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), ).toBe("fork-seed/feature/pr-reused-fork"); }), ); it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-root-only"]); const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 79, title: "Root-only PR", url: "https://github.com/pingdotgg/codething-mvp/pull/79", baseRefName: "main", headRefName: "feature/pr-root-only", state: "open", }, }, }); const errorMessage = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "79", mode: "worktree", }).pipe( Effect.flip, Effect.map((error) => error.message), ); expect(errorMessage).toContain("already checked out in the main repo"); }), ); });