// @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; import * as Scope from "effect/Scope"; import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vite-plus/test"; import type { GitActionProgressEvent, GitPreparePullRequestThreadInput, ThreadId, } from "@t3tools/contracts"; import { DEFAULT_SERVER_SETTINGS, GitCommandError, ProviderDriverKind, ProviderInstanceId, TextGenerationError, } from "@t3tools/contracts"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as GitManager from "./GitManager.ts"; interface FakeGhScenario { prListSequence?: string[]; prListByHeadSelector?: Record; prListSequenceByHeadSelector?: Record; createdPrUrl?: string; defaultBranch?: string; pullRequest?: { number: number; title: string; url: string; baseRefName: string; headRefName: string; state?: "open" | "closed" | "merged"; isDraft?: boolean; isCrossRepository?: boolean; headRepositoryNameWithOwner?: string | null; headRepositoryOwnerLogin?: string | null; }; repositoryCloneUrls?: Record; failWith?: GitHubCli.GitHubCliError; /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */ failAfterCalls?: number; } function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput { return { exitCode: ChildProcessSpawner.ExitCode(0), stdout, stderr: "", stdoutTruncated: false, stderrTruncated: false, }; } type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"]; type FakePullRequest = NonNullable; function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequestSummary | null { if (!raw || typeof raw !== "object") { return null; } const record = raw as Record; const number = record.number; const title = record.title; const url = record.url; const baseRefName = record.baseRefName; const headRefName = record.headRefName; const headRepository = typeof record.headRepository === "object" && record.headRepository !== null ? (record.headRepository as Record) : null; const headRepositoryOwner = typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null ? (record.headRepositoryOwner as Record) : null; if ( typeof number !== "number" || typeof title !== "string" || typeof url !== "string" || typeof baseRefName !== "string" || typeof headRefName !== "string" ) { return null; } const state = typeof record.state === "string" ? record.state === "OPEN" || record.state === "open" ? "open" : record.state === "CLOSED" || record.state === "closed" ? "closed" : "merged" : undefined; const isDraft = typeof record.isDraft === "boolean" ? record.isDraft : undefined; const isCrossRepository = typeof record.isCrossRepository === "boolean" ? record.isCrossRepository : undefined; const headRepositoryNameWithOwner = typeof record.headRepositoryNameWithOwner === "string" ? record.headRepositoryNameWithOwner : typeof headRepository?.nameWithOwner === "string" ? headRepository.nameWithOwner : undefined; const headRepositoryOwnerLogin = typeof record.headRepositoryOwnerLogin === "string" ? record.headRepositoryOwnerLogin : typeof headRepositoryOwner?.login === "string" ? headRepositoryOwner.login : undefined; return { number, title, url, baseRefName, headRefName, ...(state ? { state } : {}), ...(isDraft === true ? { isDraft: true } : {}), ...(isCrossRepository !== undefined ? { isCrossRepository } : {}), ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), }; } function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { const result = NodeChildProcess.spawnSync("git", args, { cwd, encoding: "utf8", }); if (result.status === 0) { return; } throw new Error( `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, ); } function makeTempDir( prefix: string, ): Effect.Effect { return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.makeTempDirectoryScoped({ prefix }); }); } function removePath( targetPath: string, ): Effect.Effect { return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; yield* fileSystem.remove(targetPath, { recursive: true, force: true }); }); } function makeDirectory( dirPath: string, ): Effect.Effect { return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; yield* fileSystem.makeDirectory(dirPath, { recursive: true }); }); } function runGit( cwd: string, args: readonly string[], allowNonZeroExit = false, ): Effect.Effect< { readonly exitCode: GitVcsDriver.ExecuteGitResult["exitCode"]; readonly stdout: string; readonly stderr: string; }, GitCommandError, GitVcsDriver.GitVcsDriver > { return Effect.gen(function* () { const git = yield* GitVcsDriver.GitVcsDriver; const result = yield* git.execute({ operation: "GitManager.test.runGit", cwd, args, allowNonZeroExit, }); return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, }; }); } function initRepo( cwd: string, ): Effect.Effect< void, PlatformError.PlatformError | GitCommandError, FileSystem.FileSystem | Scope.Scope | GitVcsDriver.GitVcsDriver > { 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(NodePath.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 | GitVcsDriver.GitVcsDriver > { return Effect.gen(function* () { const remoteDir = yield* makeTempDir("t3code-git-remote-"); yield* runGit(remoteDir, ["init", "--bare"]); return remoteDir; }); } function configureRemote( cwd: string, remoteName: string, remotePath: string, fetchNamespace: string, ): Effect.Effect { return Effect.gen(function* () { yield* runGit(cwd, ["config", `remote.${remoteName}.url`, remotePath]); yield* runGit(cwd, [ "config", "--replace-all", `remote.${remoteName}.fetch`, `+refs/heads/*:refs/remotes/${fetchNamespace}/*`, ]); }); } function configureVisibleRemoteUrlWithLocalRewrite( cwd: string, remoteName: string, visibleUrl: string, localRemotePath: string, ): Effect.Effect { return Effect.gen(function* () { yield* runGit(cwd, ["config", `remote.${remoteName}.url`, visibleUrl]); yield* runGit(cwd, ["config", `url.${localRemotePath}.insteadOf`, visibleUrl]); }); } function createTextGeneration( overrides: Partial = {}, ): TextGeneration.TextGeneration["Service"] { 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", }), generateThreadTitle: () => Effect.succeed({ title: "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 } : {}), }), ), ), generateThreadTitle: (input) => implementation.generateThreadTitle(input).pipe( Effect.mapError( (cause) => new TextGenerationError({ operation: "generateThreadTitle", detail: "fake text generation failed", ...(cause !== undefined ? { cause } : {}), }), ), ), }; } function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { service: GitHubCli.GitHubCli["Service"]; ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; const prListQueueByHeadSelector = new Map( Object.entries(scenario.prListSequenceByHeadSelector ?? {}).map(([headSelector, values]) => [ headSelector, [...values], ]), ); const ghCalls: string[] = []; const execute: GitHubCli.GitHubCli["Service"]["execute"] = (input) => { const args = [...input.args]; ghCalls.push(args.join(" ")); if (scenario.failWith && ghCalls.length > (scenario.failAfterCalls ?? 0)) { 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 mappedQueue = typeof headSelector === "string" ? prListQueueByHeadSelector.get(headSelector)?.shift() : undefined; const mappedStdout = typeof headSelector === "string" ? scenario.prListByHeadSelector?.[headSelector] : undefined; const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n"; return Effect.succeed(fakeGhOutput(stdout)); } if (args[0] === "pr" && args[1] === "create") { return Effect.succeed( fakeGhOutput( (scenario.createdPrUrl ?? "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n", ), ); } 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( fakeGhOutput( JSON.stringify({ ...pullRequest, ...(pullRequest.headRepositoryNameWithOwner ? { headRepository: { nameWithOwner: pullRequest.headRepositoryNameWithOwner, }, } : {}), ...(pullRequest.headRepositoryOwnerLogin ? { headRepositoryOwner: { login: pullRequest.headRepositoryOwnerLogin, }, } : {}), }) + "\n", ), ); } if (args[0] === "pr" && args[1] === "checkout") { return Effect.try({ try: () => { const headBranch = scenario.pullRequest?.headRefName; if (headBranch) { const existingBranch = NodeChildProcess.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 fakeGhOutput(""); }, catch: (error) => GitHubCli.isGitHubCliError(error) ? error : new GitHubCli.GitHubCliCommandError({ command: "gh", cwd: input.cwd, cause: error, }), }); } 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 GitHubCli.GitHubCliCommandError({ command: "gh", cwd: input.cwd, cause: new Error(`Unexpected repository lookup: ${repository}`), }), ); } return Effect.succeed( fakeGhOutput( JSON.stringify({ nameWithOwner: repository, url: cloneUrls.url, sshUrl: cloneUrls.sshUrl, }) + "\n", ), ); } return Effect.succeed(fakeGhOutput(`${scenario.defaultBranch ?? "main"}\n`)); } return Effect.fail( new GitHubCli.GitHubCliCommandError({ command: "gh", cwd: input.cwd, cause: new Error(`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,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as unknown[]), Effect.map((raw) => raw .map((entry) => normalizeFakePullRequestSummary(entry)) .filter((entry): entry is GitHubCli.GitHubPullRequestSummary => entry !== null), ), ), 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,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), ), getRepositoryCloneUrls: (input) => execute({ cwd: input.cwd, args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], }).pipe(Effect.map((result) => JSON.parse(result.stdout))), createRepository: (input) => Effect.fail( new GitHubCli.GitHubCliCommandError({ command: "gh", cwd: input.cwd, cause: new Error(`Unexpected repository create: ${input.repository}`), }), ), checkoutPullRequest: (input) => execute({ cwd: input.cwd, args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], }).pipe(Effect.asVoid), }, ghCalls, }; } function runStackedAction( manager: GitManager.GitManager["Service"], input: { cwd: string; action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr"; actionId?: string; commitMessage?: string; featureBranch?: boolean; filePaths?: readonly string[]; }, options?: Parameters[1], ) { return manager.runStackedAction( { ...input, actionId: input.actionId ?? "test-action-id", }, options, ); } function resolvePullRequest( manager: GitManager.GitManager["Service"], input: { cwd: string; reference: string }, ) { return manager.resolvePullRequest(input); } function preparePullRequestThread( manager: GitManager.GitManager["Service"], input: GitPreparePullRequestThreadInput, ) { return manager.preparePullRequestThread(input); } function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; gitConfigReads?: string[]; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-", }); const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings); const vcsDriverLayer = input?.gitConfigReads ? Layer.effect( GitVcsDriver.GitVcsDriver, GitVcsDriver.make.pipe( Effect.map((service) => GitVcsDriver.GitVcsDriver.of({ ...service, readConfigValue: (cwd, key) => Effect.sync(() => input.gitConfigReads?.push(key)).pipe( Effect.andThen(service.readConfigValue(cwd, key)), ), }), ), ), ).pipe( Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(serverConfigLayer), ) : GitVcsDriver.layer.pipe( Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(serverConfigLayer), ); const sourceControlRegistryLayer = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, GitHubSourceControlProvider.make.pipe( Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ get: () => Effect.succeed(provider), resolveHandle: () => Effect.succeed({ provider, context: null }), resolve: () => Effect.succeed(provider), discover: Effect.succeed([]), }), ), Effect.provide(Layer.succeed(GitHubCli.GitHubCli, gitHubCli)), ), ); const managerLayer = Layer.mergeAll( Layer.succeed(TextGeneration.TextGeneration, textGeneration), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), }), Layer.succeed( ProjectSetupScriptRunner.ProjectSetupScriptRunner, input?.setupScriptRunner ?? { runForThread: () => Effect.succeed({ status: "no-script" as const }), }, ), vcsDriverLayer, serverSettingsLayer, ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer)); return GitManager.make.pipe( Effect.provide(managerLayer), Effect.map((manager) => ({ manager, ghCalls })), ); } const asThreadId = (threadId: string) => threadId as ThreadId; const GitManagerTestLayer = GitVcsDriver.layer.pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), ); it.layer(GitManagerTestLayer)("GitManager", (it) => { it.effect("status includes draft PR metadata when branch already has a draft 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: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 13, title: "Existing PR", url: "https://github.com/pingdotgg/codething-mvp/pull/13", baseRefName: "main", headRefName: "feature/status-open-pr", isDraft: true, }, ]), ], }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.isRepo).toBe(true); expect(status.hasPrimaryRemote).toBe(true); expect(status.isDefaultRef).toBe(false); expect(status.refName).toBe("feature/status-open-pr"); expect(status.pr).toEqual({ number: 13, title: "Existing PR", url: "https://github.com/pingdotgg/codething-mvp/pull/13", baseRef: "main", headRef: "feature/status-open-pr", state: "open", isDraft: true, updatedAt: null, }); }), ); it.effect("status trims PR metadata returned by gh before publishing it", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/status-trimmed-pr"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-trimmed-pr"]); const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 14, title: " Existing PR title \n", url: " https://github.com/pingdotgg/codething-mvp/pull/14 ", baseRefName: " main ", headRefName: "\tfeature/status-trimmed-pr\t", }, ]), ], }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.pr).toEqual({ number: 14, title: "Existing PR title", url: "https://github.com/pingdotgg/codething-mvp/pull/14", baseRef: "main", headRef: "feature/status-trimmed-pr", state: "open", updatedAt: null, }); }), ); it.effect("status ignores invalid gh pr list entries and keeps valid ones", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/status-valid-pr-entry"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-valid-pr-entry"]); const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 0, title: "invalid", url: "https://github.com/pingdotgg/codething-mvp/pull/0", baseRefName: "main", headRefName: "feature/invalid", }, { number: 15, title: " Valid PR title ", url: " https://github.com/pingdotgg/codething-mvp/pull/15 ", baseRefName: " main ", headRefName: "\tfeature/status-valid-pr-entry\t", headRepository: { nameWithOwner: " ", }, headRepositoryOwner: { login: " ", }, }, ]), ], }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.pr).toEqual({ number: 15, title: "Valid PR title", url: "https://github.com/pingdotgg/codething-mvp/pull/15", baseRef: "main", headRef: "feature/status-valid-pr-entry", state: "open", updatedAt: null, }); }), ); it.effect("status preserves lowercase merged and closed PR states from gh json", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/status-lowercase-state"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-lowercase-state"]); const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 16, title: "Closed PR", url: "https://github.com/pingdotgg/codething-mvp/pull/16", baseRefName: "main", headRefName: "feature/status-lowercase-state", state: "closed", updatedAt: "2026-01-01T00:00:00.000Z", }, { number: 17, title: "Merged PR", url: "https://github.com/pingdotgg/codething-mvp/pull/17", baseRefName: "main", headRefName: "feature/status-lowercase-state", state: "merged", updatedAt: "2026-01-02T00:00:00.000Z", }, ]), ], }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.pr).toEqual({ number: 17, title: "Merged PR", url: "https://github.com/pingdotgg/codething-mvp/pull/17", baseRef: "main", headRef: "feature/status-lowercase-state", state: "merged", updatedAt: "2026-01-02T00:00:00.000Z", }); }), ); it.effect("status returns an explicit non-repo result for non-git directories", () => Effect.gen(function* () { const cwd = yield* makeTempDir("t3code-git-manager-non-repo-"); const { manager } = yield* makeManager(); const status = yield* manager.status({ cwd }); expect(status).toEqual({ isRepo: false, hasPrimaryRemote: false, isDefaultRef: false, refName: null, hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0, }, hasUpstream: false, aheadCount: 0, behindCount: 0, aheadOfDefaultCount: 0, pr: null, }); }), ); it.effect("status returns an explicit non-repo result for deleted directories", () => Effect.gen(function* () { const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-"); const cwd = NodePath.join(rootDir, "deleted-repo"); yield* makeDirectory(cwd); yield* removePath(cwd); const { manager } = yield* makeManager(); const status = yield* manager.status({ cwd }); expect(status).toEqual({ isRepo: false, hasPrimaryRemote: false, isDefaultRef: false, refName: null, hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0, }, hasUpstream: false, aheadCount: 0, behindCount: 0, aheadOfDefaultCount: 0, pr: null, }); }), ); it.effect("status briefly caches repeated lookups for the same cwd", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/status-cache"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-cache"]); const existingPr = { number: 113, title: "Cached PR", url: "https://github.com/pingdotgg/codething-mvp/pull/113", baseRefName: "main", headRefName: "feature/status-cache", }; const { manager, ghCalls } = yield* makeManager({ ghScenario: { // @effect-diagnostics-next-line preferSchemaOverJson:off prListSequence: [JSON.stringify([existingPr]), JSON.stringify([existingPr])], }, }); const first = yield* manager.status({ cwd: repoDir }); const second = yield* manager.status({ cwd: repoDir }); expect(first.pr?.number).toBe(113); expect(second.pr?.number).toBe(113); expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); }), ); it.effect("a warm PR cache does not reread repository identity for status", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/status-identity-cache"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-identity-cache"]); const gitConfigReads: string[] = []; const { manager } = yield* makeManager({ gitConfigReads }); yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); gitConfigReads.length = 0; yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); const identityReads = gitConfigReads.filter( (key) => key === "branch.feature/status-identity-cache.remote" || key === "remote.origin.url", ); expect(identityReads).toHaveLength(0); }), ); it.effect("turn-end refresh finds a new PR and keeps known PRs cached", () => 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/turn-refresh", "origin/main"]); yield* runGit(repoDir, ["push", "origin", "feature/turn-refresh"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ "[]", // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 114, title: "Opened during the turn", url: "https://github.com/pingdotgg/codething-mvp/pull/114", baseRefName: "main", headRefName: "feature/turn-refresh", }, ]), ], }, }); expect((yield* manager.remoteStatus({ cwd: repoDir }))?.pr).toBeNull(); expect( (yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }))?.pr, ).toBeNull(); const refreshed = yield* manager.remoteStatus( { cwd: repoDir }, { refreshUpstream: false, refreshMissingPullRequest: true }, ); expect(refreshed?.pr?.number).toBe(114); yield* manager.remoteStatus( { cwd: repoDir }, { refreshUpstream: false, refreshMissingPullRequest: true }, ); expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); }), ); it.effect("turn-end refresh preserves failed PR lookup backoff", () => 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, ["checkout", "-b", "feature/rate-limited"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", cwd: repoDir, cause: new Error("rate limited"), }), }, }); yield* manager.remoteStatus({ cwd: repoDir }); const callsAfterFailure = ghCalls.length; yield* manager.remoteStatus( { cwd: repoDir }, { refreshUpstream: false, refreshMissingPullRequest: true }, ); expect(callsAfterFailure).toBeGreaterThan(0); expect(ghCalls).toHaveLength(callsAfterFailure); }), ); it.effect("status skips the provider lookup for a branch that was never pushed", () => 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/never-pushed"]); const { manager, ghCalls } = yield* makeManager(); const status = yield* manager.status({ cwd: repoDir }); expect(status.refName).toBe("feature/never-pushed"); expect(status.pr).toBeNull(); expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(0); }), ); it.effect("branch PR lookup returns null when the repository has no remotes", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const { manager, ghCalls } = yield* makeManager(); const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); expect(pullRequest).toBeNull(); expect(ghCalls).toHaveLength(0); }), ); it.effect("branch PR lookup uses a saved tracked branch without changing checkout", () => 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/saved-branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/saved-branch"]); yield* runGit(repoDir, ["checkout", "main"]); const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 216, title: "Saved branch PR", url: "https://github.com/pingdotgg/t3code/pull/216", baseRefName: "main", headRefName: "feature/saved-branch", state: "OPEN", updatedAt: "2026-04-03T15:00:00Z", }, ]), ], }, }); const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/saved-branch", }); expect(pullRequest).toEqual({ state: "open", updatedAt: "2026-04-03T15:00:00.000Z", }); expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); }), ); it.effect("branch PR lookup uses the default branch from a non-origin remote", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "upstream", remoteDir]); yield* runGit(repoDir, ["push", "-u", "upstream", "main"]); yield* runGit(repoDir, ["checkout", "-b", "develop"]); yield* runGit(repoDir, ["push", "-u", "upstream", "develop"]); yield* runGit(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/develop"]); yield* runGit(repoDir, ["remote", "set-head", "upstream", "develop"]); const { manager } = yield* makeManager({ ghScenario: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. prListSequence: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 221, title: "Merged main PR", url: "https://github.com/pingdotgg/codething-mvp/pull/221", baseRefName: "develop", headRefName: "main", state: "MERGED", updatedAt: "2026-04-08T15:00:00Z", }, ]), ], }, }); const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); expect(pullRequest).toEqual({ state: "merged", updatedAt: "2026-04-08T15:00:00.000Z", }); }), ); it.effect("branch PR lookup uses the saved name after the local branch is deleted", () => 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/deleted-local-branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/deleted-local-branch"]); yield* runGit(repoDir, ["checkout", "main"]); yield* runGit(repoDir, ["branch", "-D", "feature/deleted-local-branch"]); yield* runGit(repoDir, ["branch", "feature/deleted-local-branch/child"]); yield* runGit(repoDir, [ "branch", "--set-upstream-to", "origin/main", "feature/deleted-local-branch/child", ]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 217, title: "Deleted local branch PR", url: "https://github.com/pingdotgg/t3code/pull/217", baseRefName: "main", headRefName: "feature/deleted-local-branch", state: "MERGED", updatedAt: "2026-04-04T15:00:00Z", }, ]), ], }, }); const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/deleted-local-branch", }); expect(pullRequest).toEqual({ state: "merged", updatedAt: "2026-04-04T15:00:00.000Z", }); expect(ghCalls.some((call) => call.includes("--head feature/deleted-local-branch"))).toBe( true, ); }), ); it.effect("branch PR lookup recovers a deleted fork branch from its remote-tracking ref", () => 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* configureRemote(repoDir, "team/fork", forkDir, "team/fork"); yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-fork-branch"]); yield* runGit(repoDir, ["push", "-u", "team/fork", "feature/deleted-fork-branch"]); yield* runGit(repoDir, ["checkout", "main"]); yield* runGit(repoDir, ["branch", "-D", "feature/deleted-fork-branch"]); yield* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "origin", "git@github.com:pingdotgg/codething-mvp.git", originDir, ); yield* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "team/fork", "git@github.com:contributor/codething-mvp.git", forkDir, ); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off "contributor:feature/deleted-fork-branch": JSON.stringify([ { number: 218, title: "Deleted fork branch PR", url: "https://github.com/pingdotgg/codething-mvp/pull/218", baseRefName: "main", headRefName: "feature/deleted-fork-branch", state: "MERGED", updatedAt: "2026-04-05T15:00:00Z", isCrossRepository: true, headRepository: { nameWithOwner: "contributor/codething-mvp" }, headRepositoryOwner: { login: "contributor" }, }, ]), }, }, }); const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/deleted-fork-branch", }); expect(pullRequest).toEqual({ state: "merged", updatedAt: "2026-04-05T15:00:00.000Z", }); expect( ghCalls.some((call) => call.includes("--head contributor:feature/deleted-fork-branch")), ).toBe(true); }), ); it.effect("branch PR lookup rejects ambiguous deleted-branch remote refs", () => 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, ["remote", "add", "fork", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "feature/ambiguous-remote"]); yield* runGit(repoDir, ["push", "origin", "feature/ambiguous-remote"]); yield* runGit(repoDir, ["push", "fork", "feature/ambiguous-remote"]); yield* runGit(repoDir, ["checkout", "main"]); yield* runGit(repoDir, ["branch", "-D", "feature/ambiguous-remote"]); const { manager, ghCalls } = yield* makeManager(); const error = yield* manager .branchPullRequest({ cwd: repoDir, branch: "feature/ambiguous-remote" }) .pipe(Effect.flip); expect(error).toMatchObject({ _tag: "GitManagerError", detail: "Multiple remotes track feature/ambiguous-remote. Its pull request is ambiguous.", }); expect(ghCalls).toHaveLength(0); }), ); it.effect("branch PR lookup does not reuse a cached PR after the remote is repointed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const originalRemoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]); yield* runGit(repoDir, ["checkout", "-b", "feature/repointed-lookup"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/repointed-lookup"]); yield* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "origin", "git@github.com:old-owner/old-repository.git", originalRemoteDir, ); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 219, title: "Old repository PR", url: "https://github.com/old-owner/old-repository/pull/219", baseRefName: "main", headRefName: "feature/repointed-lookup", state: "MERGED", updatedAt: "2026-04-06T15:00:00Z", }, ]), "[]", ], }, }); const first = yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/repointed-lookup", }); expect(first?.state).toBe("merged"); const replacementRemoteDir = yield* createBareRemote(); yield* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "origin", "git@github.com:new-owner/new-repository.git", replacementRemoteDir, ); const second = yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/repointed-lookup", }); expect(second).toBeNull(); expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); }), ); it.effect("branch PR lookup shares the status cache for the same repository identity", () => 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, ["checkout", "-b", "feature/shared-pr-cache"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/shared-pr-cache"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 220, title: "Shared cache PR", url: "https://github.com/pingdotgg/codething-mvp/pull/220", baseRefName: "main", headRefName: "feature/shared-pr-cache", state: "MERGED", updatedAt: "2026-04-07T15:00:00Z", }, ]), ], }, }); const status = yield* manager.status({ cwd: repoDir }); const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/shared-pr-cache", }); expect(status.pr?.state).toBe("merged"); expect(pullRequest?.state).toBe("merged"); expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); }), ); it.effect("branch PR lookup propagates provider failures", () => 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/lookup-failure"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-failure"]); yield* runGit(repoDir, ["checkout", "main"]); const { manager } = yield* makeManager({ ghScenario: { failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", cwd: repoDir, cause: new Error("gh is not available on PATH"), }), }, }); const error = yield* manager .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" }) .pipe(Effect.flip); expect(error._tag).toBe("SourceControlProviderError"); }), ); it.effect("status finds a merged PR after its remote branch was deleted", () => 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/merged-branch-deleted"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/merged-branch-deleted"]); // GitHub commonly deletes a pull request's head branch after merge. Git // removes the remote-tracking ref, but preserves the local branch's // remote and merge configuration as evidence that it was published. yield* runGit(repoDir, ["push", "origin", "--delete", "feature/merged-branch-deleted"]); const configuredRemote = yield* runGit(repoDir, [ "config", "--get", "branch.feature/merged-branch-deleted.remote", ]); const configuredMerge = yield* runGit(repoDir, [ "config", "--get", "branch.feature/merged-branch-deleted.merge", ]); const trackingRef = yield* runGit(repoDir, [ "for-each-ref", "--format=%(refname)", "refs/remotes/origin/feature/merged-branch-deleted", ]); expect(configuredRemote.stdout.trim()).toBe("origin"); expect(configuredMerge.stdout.trim()).toBe("refs/heads/feature/merged-branch-deleted"); expect(trackingRef.stdout.trim()).toBe(""); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 215, title: "Merged branch was deleted", url: "https://github.com/pingdotgg/t3code/pull/215", baseRefName: "main", headRefName: "feature/merged-branch-deleted", state: "MERGED", mergedAt: "2026-04-02T15:00:00Z", updatedAt: "2026-04-02T15:00:00Z", }, ]), ], }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.hasUpstream).toBe(false); expect(status.pr).toEqual({ number: 215, title: "Merged branch was deleted", url: "https://github.com/pingdotgg/t3code/pull/215", baseRef: "main", headRef: "feature/merged-branch-deleted", state: "merged", updatedAt: "2026-04-02T15:00:00.000Z", }); expect(ghCalls.filter((call) => call.startsWith("pr list ")).length).toBeGreaterThan(0); }), ); it.effect("status still looks up PRs for a branch pushed without --set-upstream", () => 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/pushed-no-upstream"]); // No `-u`, so the remote-tracking ref exists but branch..merge does // not. Most terminal and agent pushes land this way, and they can still // have a PR, so the skip must not trigger here. yield* runGit(repoDir, ["push", "origin", "feature/pushed-no-upstream"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 214, title: "Pushed without upstream", url: "https://github.com/pingdotgg/t3code/pull/214", baseRefName: "main", headRefName: "feature/pushed-no-upstream", state: "OPEN", updatedAt: "2026-04-01T15:00:00Z", }, ]), ], }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.pr?.number).toBe(214); expect(ghCalls.filter((call) => call.startsWith("pr list ")).length).toBeGreaterThan(0); }), ); it("backs off repeated PR lookup failures past the healthy refresh cadence", () => { expect(Duration.toMillis(GitManager.prLookupFailureTtl(1))).toBe(20_000); expect(Duration.toMillis(GitManager.prLookupFailureTtl(2))).toBe(40_000); // The point of the backoff: by the third retry a failing branch must not be // asking more often than a healthy one, which refreshes every 2 minutes. expect(Duration.toMillis(GitManager.prLookupFailureTtl(4))).toBeGreaterThan(120_000); expect(Duration.toMillis(GitManager.prLookupFailureTtl(20))).toBe(900_000); }); it.effect( "status ignores unrelated fork PRs when the current branch tracks the same repository", () => 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"]); const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 1661, title: "Fork PR from main", url: "https://github.com/pingdotgg/t3code/pull/1661", baseRefName: "main", headRefName: "main", state: "OPEN", updatedAt: "2026-04-01T15:00:00Z", isCrossRepository: true, headRepository: { nameWithOwner: "lnieuwenhuis/t3code", }, headRepositoryOwner: { login: "lnieuwenhuis", }, }, ]), ], }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.refName).toBe("main"); expect(status.pr).toBeNull(); }), ); 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"]); NodeFS.writeFileSync(NodePath.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* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "fork-seed", "git@github.com:jasonLaster/codething-mvp.git", forkDir, ); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off 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", isCrossRepository: true, headRepository: { nameWithOwner: "jasonLaster/codething-mvp", }, headRepositoryOwner: { login: "jasonLaster", }, }, ]), ], }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.refName).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", baseRef: "main", headRef: "statemachine", state: "open", updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, ); it.effect( "status preserves a fork PR whose head is named after the default branch", () => 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", "set-head", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["push", "fork-seed", "main"]); yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-777/main"]); yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/main"]); yield* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "fork-seed", "git@github.com:contributor/codething-mvp.git", forkDir, ); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { // @effect-diagnostics-next-line preferSchemaOverJson:off "contributor:main": JSON.stringify([ { number: 777, title: "Fork PR from main", url: "https://github.com/pingdotgg/codething-mvp/pull/777", baseRefName: "main", headRefName: "main", state: "OPEN", updatedAt: "2026-03-10T07:00:00Z", isCrossRepository: true, headRepository: { nameWithOwner: "contributor/codething-mvp", }, headRepositoryOwner: { login: "contributor", }, }, ]), }, }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.refName).toBe("t3code/pr-777/main"); expect(status.pr).toEqual({ number: 777, title: "Fork PR from main", url: "https://github.com/pingdotgg/codething-mvp/pull/777", baseRef: "main", headRef: "main", state: "open", updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, ); it.effect( "status ignores synthetic local branch aliases when the upstream remote name contains slashes", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const originDir = yield* createBareRemote(); const upstreamDir = yield* createBareRemote(); yield* configureRemote(repoDir, "origin", originDir, "origin"); yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); yield* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "origin", "git@github.com:pingdotgg/codething-mvp.git", originDir, ); yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); yield* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "my-org/upstream", "ssh://git@github.com/pingdotgg/codething-mvp.git", upstreamDir, ); yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); yield* runGit(repoDir, ["checkout", "main"]); yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { // @effect-diagnostics-next-line preferSchemaOverJson:off "effect-atom": JSON.stringify([ { number: 1618, title: "Correct PR", url: "https://github.com/pingdotgg/t3code/pull/1618", baseRefName: "main", headRefName: "effect-atom", state: "OPEN", updatedAt: "2026-03-01T10:00:00Z", }, ]), // @effect-diagnostics-next-line preferSchemaOverJson:off "upstream/effect-atom": JSON.stringify([ { number: 1518, title: "Wrong PR", url: "https://github.com/pingdotgg/t3code/pull/1518", baseRefName: "main", headRefName: "upstream/effect-atom", state: "OPEN", updatedAt: "2026-04-01T10:00:00Z", }, ]), // @effect-diagnostics-next-line preferSchemaOverJson:off "pingdotgg:effect-atom": JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off "my-org/upstream:effect-atom": JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off "pingdotgg:upstream/effect-atom": JSON.stringify([ { number: 1518, title: "Wrong PR", url: "https://github.com/pingdotgg/t3code/pull/1518", baseRefName: "main", headRefName: "upstream/effect-atom", state: "OPEN", updatedAt: "2026-04-01T10:00:00Z", }, ]), // @effect-diagnostics-next-line preferSchemaOverJson:off "my-org/upstream:upstream/effect-atom": JSON.stringify([ { number: 1518, title: "Wrong PR", url: "https://github.com/pingdotgg/t3code/pull/1518", baseRefName: "main", headRefName: "upstream/effect-atom", state: "OPEN", updatedAt: "2026-04-01T10:00:00Z", }, ]), }, }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.refName).toBe("upstream/effect-atom"); expect(status.pr).toEqual({ number: 1618, title: "Correct PR", url: "https://github.com/pingdotgg/t3code/pull/1618", baseRef: "main", headRef: "effect-atom", state: "open", updatedAt: "2026-03-01T10:00:00.000Z", }); expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( false, ); expect( ghCalls.some((call) => call.includes("pr list --head pingdotgg:upstream/effect-atom ")), ).toBe(false); expect( ghCalls.some((call) => call.includes("pr list --head my-org/upstream:upstream/effect-atom "), ), ).toBe(false); }), 20_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: [ // @effect-diagnostics-next-line preferSchemaOverJson:off 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.refName).toBe("feature/status-merged-pr"); expect(status.pr).toEqual({ number: 22, title: "Merged PR", url: "https://github.com/pingdotgg/codething-mvp/pull/22", baseRef: "main", headRef: "feature/status-merged-pr", state: "merged", updatedAt: "2026-01-30T10:00:00.000Z", }); }), ); it.effect("status hides merged PRs on the default branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 23, title: "Merged PR", url: "https://github.com/pingdotgg/codething-mvp/pull/23", baseRefName: "feature/status-default-branch-target", headRefName: "main", state: "MERGED", mergedAt: "2026-01-30T10:00:00Z", updatedAt: "2026-01-30T10:00:00Z", }, ]), ], }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.refName).toBe("main"); expect(status.pr).toBeNull(); }), ); it.effect("status does not inherit a merged PR from a feature branch's default upstream", () => 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, ["remote", "set-head", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/from-main", "origin/main"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 54, title: "Reverse merge from main", url: "https://github.com/pingdotgg/codething-mvp/pull/54", baseRefName: "je-filter-list", headRefName: "main", state: "MERGED", mergedAt: "2023-09-28T03:21:10Z", updatedAt: "2023-09-28T03:21:10Z", }, ]), ], }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.refName).toBe("feature/from-main"); expect(status.pr).toBeNull(); expect(ghCalls.some((call) => call.includes("pr list"))).toBe(false); }), ); it.effect("status finds a PR pushed under the branch's own name despite a default upstream", () => 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, ["remote", "set-head", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/pushed-plain", "origin/main"]); // A plain push (no -u) leaves the upstream on origin/main. yield* runGit(repoDir, ["push", "origin", "feature/pushed-plain"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off "feature/pushed-plain": JSON.stringify([ { number: 88, title: "Pushed without -u", url: "https://github.com/pingdotgg/codething-mvp/pull/88", baseRefName: "main", headRefName: "feature/pushed-plain", state: "OPEN", updatedAt: "2026-05-01T10:00:00Z", }, ]), }, }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.refName).toBe("feature/pushed-plain"); expect(status.pr?.number).toBe(88); expect(ghCalls.some((call) => call.includes("--head main"))).toBe(false); }), ); it.effect( "status finds a fork PR pushed under the branch's own name despite a default upstream", () => 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", "set-head", "origin", "main"]); yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork"); yield* runGit(repoDir, ["checkout", "-b", "feature/fork-plain", "origin/main"]); // Pushed to the fork without -u: upstream stays origin/main. yield* runGit(repoDir, ["push", "team/fork", "feature/fork-plain"]); yield* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "origin", "git@github.com:pingdotgg/codething-mvp.git", originDir, ); yield* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "team/fork", "git@github.com:contributor/codething-mvp.git", forkDir, ); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off "contributor:feature/fork-plain": JSON.stringify([ { number: 89, title: "Fork PR pushed without -u", url: "https://github.com/pingdotgg/codething-mvp/pull/89", baseRefName: "main", headRefName: "feature/fork-plain", state: "OPEN", updatedAt: "2026-05-01T10:00:00Z", isCrossRepository: true, headRepository: { nameWithOwner: "contributor/codething-mvp" }, headRepositoryOwner: { login: "contributor" }, }, ]), }, }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.pr?.number).toBe(89); expect(ghCalls.some((call) => call.includes("--head contributor:feature/fork-plain"))).toBe( true, ); expect(ghCalls.some((call) => call.includes("--head main"))).toBe(false); }), ); it.effect("branch PR lookup verifies identity on the fork that holds the own-name ref", () => 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", "set-head", "origin", "main"]); yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork"); yield* runGit(repoDir, ["checkout", "-b", "feature/fork-settle", "origin/main"]); yield* runGit(repoDir, ["push", "team/fork", "feature/fork-settle"]); yield* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "origin", "git@github.com:pingdotgg/codething-mvp.git", originDir, ); yield* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "team/fork", "git@github.com:contributor/codething-mvp.git", forkDir, ); const { manager } = yield* makeManager({ ghScenario: { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off "contributor:feature/fork-settle": JSON.stringify([ { number: 91, title: "Fork PR to settle", url: "https://github.com/pingdotgg/codething-mvp/pull/91", baseRefName: "main", headRefName: "feature/fork-settle", state: "MERGED", updatedAt: "2026-05-02T10:00:00Z", isCrossRepository: true, headRepository: { nameWithOwner: "contributor/codething-mvp" }, headRepositoryOwner: { login: "contributor" }, }, ]), }, }, }); const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/fork-settle", }); expect(pullRequest).toEqual({ state: "merged", updatedAt: "2026-05-02T10:00:00.000Z", }); }), ); it.effect("status keeps an own-name PR when a later lookup fails on a default upstream", () => 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, ["remote", "set-head", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/sticky-plain", "origin/main"]); yield* runGit(repoDir, ["push", "origin", "feature/sticky-plain"]); const { manager } = yield* makeManager({ ghScenario: { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off "feature/sticky-plain": JSON.stringify([ { number: 90, title: "Sticky own-name PR", url: "https://github.com/pingdotgg/codething-mvp/pull/90", baseRefName: "main", headRefName: "feature/sticky-plain", state: "OPEN", updatedAt: "2026-05-01T10:00:00Z", }, ]), }, failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", cwd: repoDir, cause: new Error("rate limited"), }), failAfterCalls: 1, }, }); const first = yield* manager.status({ cwd: repoDir }); expect(first.pr?.number).toBe(90); yield* manager.invalidateStatus(repoDir); const second = yield* manager.status({ cwd: repoDir }); expect(second.pr?.number).toBe(90); }), ); 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: [ // @effect-diagnostics-next-line preferSchemaOverJson:off 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.refName).toBe("feature/status-open-over-merged"); expect(status.pr).toEqual({ number: 46, title: "Open PR", url: "https://github.com/pingdotgg/codething-mvp/pull/46", baseRef: "main", headRef: "feature/status-open-over-merged", state: "open", updatedAt: "2026-01-30T10:00:00.000Z", }); }), ); 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 GitHubCli.GitHubCliUnavailableError({ command: "gh", cwd: repoDir, cause: new Error("gh is not available on PATH"), }), }, }); const status = yield* manager.status({ cwd: repoDir }); expect(status.refName).toBe("feature/status-no-gh"); expect(status.pr).toBeNull(); }), ); it.effect("status logs actionable provider detail without exposing the upstream cause", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/status-rate-limited"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-rate-limited"]); const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value"; const { manager } = yield* makeManager({ ghScenario: { failWith: new GitHubCli.GitHubCliRateLimitError({ command: "gh", cwd: repoDir, cause: new Error(upstreamCause), }), }, }); const logs: Array<{ message: string; annotations: Record }> = []; const logger = Logger.make(({ fiber, message }) => { logs.push({ message: String(message), annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, }); }); const status = yield* manager .status({ cwd: repoDir }) .pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); expect(status.pr).toBeNull(); const warning = logs.find((entry) => entry.message.includes("PR lookup failed")); expect(warning?.annotations).toMatchObject({ operation: "lookupStatusPr", branch: "feature/status-rate-limited", errorTag: "SourceControlProviderError", provider: "github", providerOperation: "listChangeRequests", providerCommand: "gh", errorDetail: "GitHub API rate limit exceeded. Run `gh api rate_limit` to inspect the quota and reset time.", }); const loggedText = [ warning?.message ?? "", ...Object.values(warning?.annotations ?? {}).map(String), ].join("\n"); expect(loggedText).not.toContain(upstreamCause); expect(loggedText).not.toContain("secret-value"); }), ); it.effect("status keeps the last known PR when a later lookup fails", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-sticky"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-sticky"]); const existingPr = { number: 214, title: "Sticky PR", url: "https://github.com/pingdotgg/codething-mvp/pull/214", baseRefName: "main", headRefName: "feature/pr-sticky", }; const { manager } = yield* makeManager({ ghScenario: { // @effect-diagnostics-next-line preferSchemaOverJson:off prListSequence: [JSON.stringify([existingPr])], failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", cwd: repoDir, cause: new Error("rate limited"), }), failAfterCalls: 1, }, }); const first = yield* manager.status({ cwd: repoDir }); expect(first.pr?.number).toBe(214); // An explicit invalidation (user refresh, git action) bypasses the PR // cache and forces a live lookup — which now fails. The badge must keep // the last known PR instead of blanking out. yield* manager.invalidateStatus(repoDir); const second = yield* manager.status({ cwd: repoDir }); expect(second.pr?.number).toBe(214); }), ); it.effect( "status does not reuse a stale PR after the branch is retargeted to a different upstream", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-retarget"]); const originRemote = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", originRemote]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-retarget"]); const existingPr = { number: 214, title: "Sticky PR", url: "https://github.com/pingdotgg/codething-mvp/pull/214", baseRefName: "main", headRefName: "feature/pr-retarget", }; const { manager } = yield* makeManager({ ghScenario: { // @effect-diagnostics-next-line preferSchemaOverJson:off prListSequence: [JSON.stringify([existingPr])], failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", cwd: repoDir, cause: new Error("rate limited"), }), failAfterCalls: 1, }, }); const first = yield* manager.status({ cwd: repoDir }); expect(first.pr?.number).toBe(214); // Retarget the branch to a different remote/upstream (e.g. the PR was // reopened against a fork). The previously cached PR belonged to the // old upstream and must not be shown against the new one. const forkRemote = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "fork", forkRemote]); yield* runGit(repoDir, ["push", "fork", "feature/pr-retarget"]); yield* runGit(repoDir, [ "branch", "--set-upstream-to=fork/feature/pr-retarget", "feature/pr-retarget", ]); yield* manager.invalidateStatus(repoDir); const second = yield* manager.status({ cwd: repoDir }); expect(second.pr).toBeNull(); }), ); it.effect("status keeps the last known PR when the branch gains its first upstream", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-sticky-first-push"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); const existingPr = { number: 215, title: "Sticky first-push PR", url: "https://github.com/pingdotgg/codething-mvp/pull/215", baseRefName: "main", headRefName: "feature/pr-sticky-first-push", }; const { manager } = yield* makeManager({ ghScenario: { // @effect-diagnostics-next-line preferSchemaOverJson:off prListSequence: [JSON.stringify([existingPr])], failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", cwd: repoDir, cause: new Error("rate limited"), }), failAfterCalls: 1, }, }); const first = yield* manager.status({ cwd: repoDir }); expect(first.pr?.number).toBe(215); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-sticky-first-push"]); yield* manager.invalidateStatus(repoDir); const second = yield* manager.status({ cwd: repoDir }); expect(second.pr?.number).toBe(215); }), ); it.effect("status drops the last known PR when the tracked remote is repointed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-repointed"]); const originalRemoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-repointed"]); const existingPr = { number: 216, title: "Old remote PR", url: "https://github.com/pingdotgg/codething-mvp/pull/216", baseRefName: "main", headRefName: "feature/pr-repointed", }; const { manager } = yield* makeManager({ ghScenario: { // @effect-diagnostics-next-line preferSchemaOverJson:off prListSequence: [JSON.stringify([existingPr])], failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", cwd: repoDir, cause: new Error("rate limited"), }), failAfterCalls: 1, }, }); const first = yield* manager.status({ cwd: repoDir }); expect(first.pr?.number).toBe(216); const replacementRemoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "replacement", replacementRemoteDir]); yield* runGit(repoDir, ["push", "replacement", "feature/pr-repointed"]); yield* runGit(repoDir, ["remote", "set-url", "origin", replacementRemoteDir]); yield* manager.invalidateStatus(repoDir); const second = yield* manager.status({ cwd: repoDir }); expect(second.pr).toBeNull(); }), ); it.effect("status keeps the last known PR when the current remote URL can't be resolved", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-config-hiccup"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-config-hiccup"]); const existingPr = { number: 217, title: "Config hiccup PR", url: "https://github.com/pingdotgg/codething-mvp/pull/217", baseRefName: "main", headRefName: "feature/pr-config-hiccup", }; const { manager } = yield* makeManager({ ghScenario: { // @effect-diagnostics-next-line preferSchemaOverJson:off prListSequence: [JSON.stringify([existingPr])], failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", cwd: repoDir, cause: new Error("rate limited"), }), failAfterCalls: 1, }, }); const first = yield* manager.status({ cwd: repoDir }); expect(first.pr?.number).toBe(217); // `remote.origin.url` reads go through readConfigValueNullable, which // maps ANY failed read (a real "no remote configured" state or a // transient git-config hiccup) to null the same way. Unsetting the // key here reproduces that ambiguity without touching branch // tracking (refs/remotes/origin/* and branch..remote are // untouched) — the remote identity has not actually changed, so the // sticky PR must survive even though the current lookup can no // longer resolve a remote URL to compare against. yield* runGit(repoDir, ["config", "--unset", "remote.origin.url"]); yield* manager.invalidateStatus(repoDir); const second = yield* manager.status({ cwd: repoDir }); expect(second.pr?.number).toBe(217); }), ); it.effect("creates a commit when working tree is dirty", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n"); let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; const { manager } = yield* makeManager({ serverSettings: { sourceControlWritingStyle: { mode: "custom" as const, customInstructions: "Use a direct tone.", }, }, textGeneration: { generateCommitMessage: (input) => { generatedPolicy = input.policy; return Effect.succeed({ subject: "Implement stacked git actions", body: "" }); }, }, }); 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(generatedPolicy).toMatchObject({ commitInstructions: "Use a direct tone." }); expect(result.toast).toMatchObject({ description: "Implement stacked git actions", cta: { kind: "run_action", label: "Push", action: { kind: "push", }, }, }); expect(result.toast.title).toMatch(/^Committed [0-9a-f]{7}$/); expect( yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( Effect.map((result) => result.stdout.trim()), ), ).toBe("Implement stacked git actions"); }), ); it.effect("preserves custom style when instructions are empty", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n"); let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; const { manager } = yield* makeManager({ serverSettings: { sourceControlWritingStyle: { mode: "custom" as const, customInstructions: "", }, }, textGeneration: { generateCommitMessage: (input) => { generatedPolicy = input.policy; return Effect.succeed({ subject: "Preserve custom style", body: "" }); }, }, }); yield* runStackedAction(manager, { cwd: repoDir, action: "commit", }); expect(generatedPolicy).toEqual({ kind: "custom", inferRepositoryConventions: false, }); }), ); it.effect("falls back when the dedicated source control writer is unavailable", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n"); const missingInstanceId = ProviderInstanceId.make("missing_writer"); let generatedModelSelection: | TextGeneration.CommitMessageGenerationInput["modelSelection"] | undefined; const { manager } = yield* makeManager({ serverSettings: { providerInstances: { [missingInstanceId]: { driver: ProviderDriverKind.make("missing-driver"), config: {}, }, }, sourceControlWriterModelSelection: { instanceId: missingInstanceId, model: "missing-model", }, }, textGeneration: { generateCommitMessage: (input) => { generatedModelSelection = input.modelSelection; return Effect.succeed({ subject: "Use the available writer", body: "" }); }, }, }); yield* runStackedAction(manager, { cwd: repoDir, action: "commit", }); expect(generatedModelSelection).toEqual(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection); }), ); it.effect("includes local agent instructions when recent history is empty", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* runGit(repoDir, ["init", "--initial-branch=main"]); yield* runGit(repoDir, ["config", "user.email", "test@example.com"]); yield* runGit(repoDir, ["config", "user.name", "Test User"]); const agentInstructions = "Use lowercase source control text."; const claudeInstructions = "Keep pull request bodies brief."; NodeFS.writeFileSync(NodePath.join(repoDir, "AGENTS.md"), agentInstructions); NodeFS.writeFileSync(NodePath.join(repoDir, "CLAUDE.md"), claudeInstructions); NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\n"); yield* runGit(repoDir, ["add", "README.md"]); let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; const { manager } = yield* makeManager({ serverSettings: { textGenerationModelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-6", }, sourceControlWritingStyle: { mode: "repo_conventions" as const, }, }, textGeneration: { generateCommitMessage: (input) => { generatedPolicy = input.policy; return Effect.succeed({ subject: "Create initial commit", body: "" }); }, }, }); yield* runStackedAction(manager, { cwd: repoDir, action: "commit", }); expect(generatedPolicy).toEqual({ kind: "repo_conventions", 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}`, 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}`, inferRepositoryConventions: true, }); }), ); it.effect("uses custom commit message when provided", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); NodeFS.writeFileSync(NodePath.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); NodeFS.writeFileSync(NodePath.join(repoDir, "a.txt"), "file a\n"); NodeFS.writeFileSync(NodePath.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"]); NodeFS.writeFileSync(NodePath.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(result.toast).toMatchObject({ description: "Implement stacked git actions", cta: { kind: "run_action", label: "Create PR", action: { kind: "create_pr", }, }, }); expect(result.toast.title).toMatch( /^Pushed [0-9a-f]{7} to origin\/feature\/implement-stacked-git-actions$/, ); 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); NodeFS.writeFileSync(NodePath.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 error = yield* runStackedAction(manager, { cwd: repoDir, action: "commit", featureBranch: true, }).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "GitManagerError", operation: "runFeatureBranchStep", cwd: repoDir, }); expect(error.message).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]); NodeFS.writeFileSync(NodePath.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]); NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ "[]", // @effect-diagnostics-next-line preferSchemaOverJson:off 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("pushes existing clean commits without rerunning commit logic", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/push-only"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); NodeFS.writeFileSync(NodePath.join(repoDir, "push-only.txt"), "push only\n"); yield* runGit(repoDir, ["add", "push-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "Push only branch"]); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "push", }); expect(result.commit.status).toBe("skipped_not_requested"); expect(result.push.status).toBe("pushed"); expect(result.pr.status).toBe("skipped_not_requested"); expect( yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( Effect.map((output) => output.stdout.trim()), ), ).toBe("origin/feature/push-only"); }), ); it.effect("pushes existing commits without committing dirty worktree changes", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/push-dirty"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); NodeFS.writeFileSync(NodePath.join(repoDir, "push-dirty.txt"), "push dirty\n"); yield* runGit(repoDir, ["add", "push-dirty.txt"]); yield* runGit(repoDir, ["commit", "-m", "Push dirty branch"]); NodeFS.mkdirSync(NodePath.join(repoDir, ".vercel")); NodeFS.writeFileSync(NodePath.join(repoDir, ".vercel", "project.json"), "{}\n"); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "push", }); expect(result.commit.status).toBe("skipped_not_requested"); expect(result.push.status).toBe("pushed"); expect(result.pr.status).toBe("skipped_not_requested"); expect( yield* runGit(repoDir, ["status", "--porcelain"]).pipe( Effect.map((output) => output.stdout.trim()), ), ).toContain("?? .vercel/"); expect( yield* runGit(remoteDir, ["log", "-1", "--pretty=%s", "feature/push-dirty"]).pipe( Effect.map((output) => output.stdout.trim()), ), ).toBe("Push dirty branch"); }), ); it.effect("create_pr pushes a clean branch before creating the PR when needed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/create-pr-only"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); NodeFS.writeFileSync(NodePath.join(repoDir, "create-pr-only.txt"), "create pr\n"); yield* runGit(repoDir, ["add", "create-pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ "[]", // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 303, title: "Create PR only branch", url: "https://github.com/pingdotgg/codething-mvp/pull/303", baseRefName: "main", headRefName: "feature/create-pr-only", }, ]), ], }, }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "create_pr", }); expect(result.commit.status).toBe("skipped_not_requested"); expect(result.push.status).toBe("pushed"); expect(result.push.setUpstream).toBe(true); expect(result.pr.status).toBe("created"); expect(result.pr.number).toBe(303); expect( ghCalls.some((call) => call.includes("pr create --base main --head feature/create-pr-only"), ), ).toBe(true); }), ); it.effect("create_pr falls back to main when source control provider detection fails", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/provider-fallback"]); NodeFS.writeFileSync(NodePath.join(repoDir, "provider-fallback.txt"), "fallback\n"); yield* runGit(repoDir, ["add", "provider-fallback.txt"]); yield* runGit(repoDir, ["commit", "-m", "Provider fallback"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ "[]", // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 404, title: "Provider fallback", url: "https://github.com/pingdotgg/codething-mvp/pull/404", baseRefName: "main", headRefName: "feature/provider-fallback", }, ]), ], }, }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "create_pr", }); expect(result.pr.status).toBe("created"); expect(result.pr.number).toBe(404); expect( ghCalls.some((call) => call.includes("pr create --base main --head feature/provider-fallback"), ), ).toBe(true); }), ); it.effect("create_pr targets the remote default branch when it is not main", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); // A repository whose default branch is master, with no main anywhere. yield* runGit(repoDir, ["push", "origin", "HEAD:master"]); yield* runGit(repoDir, ["fetch", "origin"]); yield* runGit(repoDir, ["remote", "set-head", "origin", "master"]); yield* runGit(repoDir, ["checkout", "-b", "feature/master-default"]); NodeFS.writeFileSync(NodePath.join(repoDir, "master-default.txt"), "master default\n"); yield* runGit(repoDir, ["add", "master-default.txt"]); yield* runGit(repoDir, ["commit", "-m", "Master default"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { // Mirrors a provider that cannot report a default branch, as the Azure // DevOps CLI does when it cannot detect the repository. defaultBranch: "", prListSequence: [ "[]", // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 505, title: "Master default", url: "https://github.com/pingdotgg/codething-mvp/pull/505", baseRefName: "master", headRefName: "feature/master-default", }, ]), ], }, }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "create_pr", }); expect(result.pr.status).toBe("created"); expect( ghCalls.some((call) => call.includes("pr create --base master --head feature/master-default"), ), ).toBe(true); }), ); 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: [ // @effect-diagnostics-next-line preferSchemaOverJson:off 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(result.toast).toEqual({ title: "Opened PR #42", description: "Existing PR", cta: { kind: "open_pr", label: "View PR", url: "https://github.com/pingdotgg/codething-mvp/pull/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* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "fork-seed", "git@github.com:octocat/codething-mvp.git", forkDir, ); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 142, title: "Existing fork PR", 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", }, }, ]), ], }, }); 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( "returns the correct existing PR when a slash remote checks out to a synthetic local alias", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const originDir = yield* createBareRemote(); const upstreamDir = yield* createBareRemote(); yield* configureRemote(repoDir, "origin", originDir, "origin"); yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); yield* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "origin", "git@github.com:pingdotgg/codething-mvp.git", originDir, ); yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); yield* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "my-org/upstream", "ssh://git@github.com/pingdotgg/codething-mvp.git", upstreamDir, ); yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); yield* runGit(repoDir, ["checkout", "main"]); yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); yield* runGit(repoDir, ["add", "changes.txt"]); yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { // @effect-diagnostics-next-line preferSchemaOverJson:off "effect-atom": JSON.stringify([ { number: 1618, title: "Correct PR", url: "https://github.com/pingdotgg/t3code/pull/1618", baseRefName: "main", headRefName: "effect-atom", }, ]), // @effect-diagnostics-next-line preferSchemaOverJson:off "upstream/effect-atom": JSON.stringify([ { number: 1518, title: "Wrong PR", url: "https://github.com/pingdotgg/t3code/pull/1518", baseRefName: "main", headRefName: "upstream/effect-atom", }, ]), // @effect-diagnostics-next-line preferSchemaOverJson:off "pingdotgg:effect-atom": JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off "my-org/upstream:effect-atom": JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off "pingdotgg:upstream/effect-atom": JSON.stringify([ { number: 1518, title: "Wrong PR", url: "https://github.com/pingdotgg/t3code/pull/1518", baseRefName: "main", headRefName: "upstream/effect-atom", }, ]), // @effect-diagnostics-next-line preferSchemaOverJson:off "my-org/upstream:upstream/effect-atom": JSON.stringify([ { number: 1518, title: "Wrong PR", url: "https://github.com/pingdotgg/t3code/pull/1518", baseRefName: "main", headRefName: "upstream/effect-atom", }, ]), }, }, }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push_pr", }); expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(1618); expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( false, ); }), 20_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* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "fork-seed", "git@github.com:octocat/codething-mvp.git", forkDir, ); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { // @effect-diagnostics-next-line preferSchemaOverJson:off "t3code/pr-142/statemachine": JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off statemachine: JSON.stringify([ { number: 41, title: "Unrelated same-repo PR", url: "https://github.com/pingdotgg/codething-mvp/pull/41", baseRefName: "main", headRefName: "statemachine", }, ]), // @effect-diagnostics-next-line preferSchemaOverJson:off "octocat:statemachine": JSON.stringify([ { number: 142, title: "Existing fork PR", 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", }, }, ]), // @effect-diagnostics-next-line preferSchemaOverJson:off "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* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "fork-seed", "git@github.com:octocat/codething-mvp.git", forkDir, ); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { // @effect-diagnostics-next-line preferSchemaOverJson:off "octocat:statemachine": JSON.stringify([ { number: 142, title: "Existing fork PR", 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", }, }, ]), // @effect-diagnostics-next-line preferSchemaOverJson:off "fork-seed:statemachine": JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off "t3code/pr-142/statemachine": JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off 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 openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1")); expect(openLookupCalls).toHaveLength(1); expect(openLookupCalls[0]).toContain( "pr list --head octocat:statemachine --state open --limit 1", ); }), 12_000, ); it.effect( "does not reuse a cross-repo PR when GitHub omits head identity metadata", () => 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: { prListSequenceByHeadSelector: { "octocat:statemachine": [ `[{"number":41,"title":"Ambiguous fork PR","url":"https://github.com/pingdotgg/codething-mvp/pull/41","baseRefName":"main","headRefName":"statemachine","state":"OPEN"}]`, `[{"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"}}]`, ], "fork-seed:statemachine": ["[]"], statemachine: ["[]"], }, }, }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push_pr", }); expect(result.pr.status).toBe("created"); expect(result.pr.number).toBe(142); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(true); }), 20_000, ); it.effect("rejects same-repo PR metadata when matching a cross-repo head context", () => Effect.sync(() => { const headContext = { headBranch: "statemachine", headRepositoryNameWithOwner: "pingdotgg/codething-mvp", headRepositoryOwnerLogin: "pingdotgg", isCrossRepository: true, }; expect( GitManager.matchesBranchHeadContext( { number: 41, title: "Same-repo PR", url: "https://github.com/pingdotgg/codething-mvp/pull/41", baseRefName: "main", headRefName: "statemachine", state: "open", updatedAt: Option.none(), isCrossRepository: false, headRepositoryNameWithOwner: "pingdotgg/codething-mvp", headRepositoryOwnerLogin: "pingdotgg", }, headContext, ), ).toBe(false); expect( GitManager.matchesBranchHeadContext( { number: 142, title: "Fork PR", url: "https://github.com/pingdotgg/codething-mvp/pull/142", baseRefName: "main", headRefName: "statemachine", state: "open", updatedAt: Option.none(), isCrossRepository: true, headRepositoryNameWithOwner: "pingdotgg/codething-mvp", headRepositoryOwnerLogin: "pingdotgg", }, headContext, ), ).toBe(true); }), ); it.effect("accepts fork PR metadata when origin is the fork checkout remote", () => Effect.sync(() => { const headContext = { headBranch: "t3code/git-audit-stability", headRepositoryNameWithOwner: "justsomelegs/t3code", headRepositoryOwnerLogin: "justsomelegs", isCrossRepository: false, }; expect( GitManager.matchesBranchHeadContext( { number: 2284, title: "Improve branch mismatch warnings", url: "https://github.com/pingdotgg/t3code/pull/2284", baseRefName: "main", headRefName: "t3code/git-audit-stability", state: "open", updatedAt: Option.none(), isCrossRepository: true, headRepositoryNameWithOwner: "justsomelegs/t3code", headRepositoryOwnerLogin: "justsomelegs", }, headContext, ), ).toBe(true); }), ); it.effect("creates PR when one does not already exist", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); NodeFS.mkdirSync(NodePath.join(repoDir, ".github")); NodeFS.writeFileSync( NodePath.join(repoDir, ".github", "pull_request_template.md"), "## What changed?\n\n## Verification", ); yield* runGit(repoDir, ["add", ".github/pull_request_template.md"]); yield* runGit(repoDir, ["commit", "-m", "Add pull request template"]); yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); NodeFS.writeFileSync(NodePath.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"]); let generatedPolicy: TextGeneration.PrContentGenerationInput["policy"] = undefined; let generatedChangeRequestTemplate: string | undefined; const { manager, ghCalls } = yield* makeManager({ serverSettings: { sourceControlWritingStyle: { mode: "custom" as const, customInstructions: "Lead with user impact.", }, }, textGeneration: { generatePrContent: (input) => { generatedPolicy = input.policy; generatedChangeRequestTemplate = input.changeRequestTemplate; return Effect.succeed({ title: "Add stacked git actions", body: "## What changed?\nAdded stacked git actions.", }); }, }, ghScenario: { prListSequence: [ "[]", // @effect-diagnostics-next-line preferSchemaOverJson:off 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(generatedPolicy).toMatchObject({ changeRequestInstructions: "Lead with user impact.", }); expect(generatedChangeRequestTemplate).toBe("## What changed?\n\n## Verification"); expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); 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("generates PR content against the remote base when the local base is stale", () => 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(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/main"]); const peerDir = yield* makeTempDir("t3code-git-peer-"); yield* runGit(peerDir, ["clone", remoteDir, "."]); yield* runGit(peerDir, ["config", "user.email", "peer@example.com"]); yield* runGit(peerDir, ["config", "user.name", "Peer User"]); NodeFS.writeFileSync(NodePath.join(peerDir, "remote.txt"), "remote\n"); yield* runGit(peerDir, ["add", "remote.txt"]); yield* runGit(peerDir, ["commit", "-m", "Remote base commit"]); yield* runGit(peerDir, ["push", "origin", "main"]); yield* runGit(repoDir, ["fetch", "origin"]); yield* runGit(repoDir, [ "checkout", "--no-track", "-b", "feature/remote-base", "origin/main", ]); NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); yield* runGit(repoDir, ["add", "feature.txt"]); yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/remote-base"]); yield* runGit(repoDir, ["config", "branch.feature/remote-base.gh-merge-base", "main"]); let generatedCommitSummary = ""; const { manager } = yield* makeManager({ ghScenario: { prListSequence: ["[]", "[]"], }, textGeneration: { generatePrContent: (input) => { generatedCommitSummary = input.commitSummary; return Effect.succeed({ title: "Feature PR", body: "Feature body" }); }, }, }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "create_pr", }); expect(result.pr.status).toBe("created"); expect(generatedCommitSummary).toContain("Feature commit"); expect(generatedCommitSummary).not.toContain("Remote base commit"); }), ); it.effect( "creates a new PR instead of reusing an unrelated fork PR with the same head branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); NodeFS.writeFileSync(NodePath.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/no-fork-match"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 1661, title: "Fork PR with same branch name", url: "https://github.com/pingdotgg/t3code/pull/1661", baseRefName: "main", headRefName: "feature/no-fork-match", state: "OPEN", isCrossRepository: true, headRepository: { nameWithOwner: "lnieuwenhuis/t3code", }, headRepositoryOwner: { login: "lnieuwenhuis", }, }, ]), // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 188, title: "Add stacked git actions", url: "https://github.com/pingdotgg/codething-mvp/pull/188", baseRefName: "main", headRefName: "feature/no-fork-match", state: "OPEN", isCrossRepository: false, }, ]), ], }, }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push_pr", }); expect(result.pr.status).toBe("created"); expect(result.pr.number).toBe(188); expect(result.toast).toEqual({ title: "Created PR #188", description: "Add stacked git actions", cta: { kind: "open_pr", label: "View PR", url: "https://github.com/pingdotgg/codething-mvp/pull/188", }, }); expect( ghCalls.some((call) => call.includes("pr create --base main --head feature/no-fork-match"), ), ).toBe(true); }), ); 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"]); NodeFS.writeFileSync(NodePath.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* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "fork-seed", "git@github.com:octocat/codething-mvp.git", forkDir, ); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequenceByHeadSelector: { "octocat:statemachine": [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 188, title: "Add stacked git actions", url: "https://github.com/pingdotgg/codething-mvp/pull/188", baseRefName: "main", headRefName: "statemachine", state: "OPEN", isCrossRepository: true, headRepository: { nameWithOwner: "octocat/codething-mvp", }, headRepositoryOwner: { login: "octocat", }, }, ]), ], // @effect-diagnostics-next-line preferSchemaOverJson:off "fork-seed:statemachine": [JSON.stringify([])], // @effect-diagnostics-next-line preferSchemaOverJson:off statemachine: [JSON.stringify([])], }, }, }); 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 GitHubCli.GitHubCliUnavailableError({ command: "gh", cwd: repoDir, cause: new Error("gh is 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 GitHubCli.GitHubCliAuthenticationError({ command: "gh", cwd: repoDir, cause: new Error("gh is not authenticated"), }), }, }); 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"]); NodeFS.writeFileSync(NodePath.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( "restores same-repository upstream tracking after local PR checkout without a remote ref", () => 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-local-upstream"]); NodeFS.writeFileSync(NodePath.join(repoDir, "upstream.txt"), "upstream\n"); yield* runGit(repoDir, ["add", "upstream.txt"]); yield* runGit(repoDir, ["commit", "-m", "Local upstream PR branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-upstream"]); yield* runGit(repoDir, ["checkout", "main"]); yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-upstream"]); yield* runGit(repoDir, [ "update-ref", "-d", "refs/remotes/origin/feature/pr-local-upstream", ]); const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 65, title: "Local upstream PR", url: "https://github.com/pingdotgg/codething-mvp/pull/65", baseRefName: "main", headRefName: "feature/pr-local-upstream", state: "open", isCrossRepository: false, headRepositoryNameWithOwner: "pingdotgg/codething-mvp", headRepositoryOwnerLogin: "pingdotgg", }, repositoryCloneUrls: { "pingdotgg/codething-mvp": { url: remoteDir, sshUrl: remoteDir, }, }, }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "65", mode: "local", }); expect(result.worktreePath).toBeNull(); expect(result.branch).toBe("feature/pr-local-upstream"); expect( (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), ).toBe("origin/feature/pr-local-upstream"); }), ); it.effect( "restores same-repository upstream tracking when provider omits head repository metadata", () => 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-local-no-head-repo"]); NodeFS.writeFileSync(NodePath.join(repoDir, "no-head-repo.txt"), "upstream\n"); yield* runGit(repoDir, ["add", "no-head-repo.txt"]); yield* runGit(repoDir, ["commit", "-m", "Local PR branch without repo metadata"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-no-head-repo"]); yield* runGit(repoDir, ["checkout", "main"]); yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-no-head-repo"]); yield* runGit(repoDir, [ "update-ref", "-d", "refs/remotes/origin/feature/pr-local-no-head-repo", ]); const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 66, title: "Local upstream PR without repo metadata", url: "https://github.com/pingdotgg/codething-mvp/pull/66", baseRefName: "main", headRefName: "feature/pr-local-no-head-repo", state: "open", }, }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "66", mode: "local", }); expect(result.worktreePath).toBeNull(); expect(result.branch).toBe("feature/pr-local-no-head-repo"); expect( (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), ).toBe("origin/feature/pr-local-no-head-repo"); }), ); 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"]); NodeFS.writeFileSync(NodePath.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(NodeFS.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 both branch materialization failures when the fallback also fails", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const originDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", originDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); const missingForkDir = NodePath.join(repoDir, "missing-fork.git"); const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 93, title: "Missing fork branch", url: "https://github.com/pingdotgg/codething-mvp/pull/93", baseRefName: "main", headRefName: "feature/missing-fork-branch", state: "open", isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", }, repositoryCloneUrls: { "octocat/codething-mvp": { url: missingForkDir, sshUrl: missingForkDir, }, }, }, }); const error = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "93", mode: "worktree", }).pipe(Effect.flip); if (error._tag !== "GitPullRequestMaterializationError") { return yield* Effect.die(error); } expect(error).toMatchObject({ cwd: repoDir, pullRequestNumber: 93, headRepository: "octocat/codething-mvp", headBranch: "feature/missing-fork-branch", localBranch: "t3code/pr-93/feature/missing-fork-branch", }); if (!(error.cause instanceof AggregateError)) { return yield* Effect.die(error.cause); } expect(error.cause.errors).toHaveLength(2); expect(error.cause.errors).toEqual([ expect.objectContaining({ _tag: "GitCommandError" }), expect.objectContaining({ _tag: "GitCommandError" }), ]); expect(error.cause.cause).toBe(error.cause.errors[0]); }), ); it.effect("launches setup when creating a new PR worktree", () => 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-setup"]); NodeFS.writeFileSync(NodePath.join(repoDir, "setup.txt"), "setup\n"); yield* runGit(repoDir, ["add", "setup.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]); yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]); yield* runGit(repoDir, ["checkout", "main"]); const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 177, title: "Worktree setup PR", url: "https://github.com/pingdotgg/codething-mvp/pull/177", baseRefName: "main", headRefName: "feature/pr-worktree-setup", state: "open", }, }, setupScriptRunner: { runForThread: (setupInput) => Effect.sync(() => { setupCalls.push(setupInput); return { status: "no-script" as const }; }), }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "177", mode: "worktree", threadId: asThreadId("thread-pr-setup"), }); expect(result.worktreePath).not.toBeNull(); expect(setupCalls).toHaveLength(1); expect(setupCalls[0]).toEqual({ threadId: "thread-pr-setup", projectCwd: repoDir, worktreePath: result.worktreePath as string, }); }), ); 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"]); NodeFS.writeFileSync(NodePath.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"]); NodeFS.writeFileSync(NodePath.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"]); NodeFS.writeFileSync(NodePath.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"]); NodeFS.writeFileSync(NodePath.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 = NodePath.join( repoDir, "..", `pr-existing-${NodePath.basename(repoDir)}`, ); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]); const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; 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", }, }, setupScriptRunner: { runForThread: (setupInput) => Effect.sync(() => { setupCalls.push(setupInput); return { status: "no-script" as const }; }), }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "78", mode: "worktree", threadId: asThreadId("thread-pr-existing-worktree"), }); expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( NodeFS.realpathSync.native(worktreePath), ); expect(result.branch).toBe("feature/pr-existing-worktree"); // Nothing to fetch from, so the checkout keeps the commit it had and setup stays out of a // worktree another thread may be sitting in. expect(setupCalls).toHaveLength(0); expect(result.isOnPullRequestHead).toBe(false); }), ); it.effect("refreshes a reused PR worktree onto the updated pull request head", () => 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-reused-stale"]); NodeFS.writeFileSync(NodePath.join(repoDir, "stale.txt"), "stale\n"); yield* runGit(repoDir, ["add", "stale.txt"]); yield* runGit(repoDir, ["commit", "-m", "Reused stale PR branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-reused-stale"]); yield* runGit(repoDir, ["checkout", "main"]); const worktreePath = NodePath.join( repoDir, "..", `pr-reused-stale-${NodePath.basename(repoDir)}`, ); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-stale"]); yield* runGit(repoDir, ["checkout", "-b", "author-push", "origin/feature/pr-reused-stale"]); NodeFS.writeFileSync(NodePath.join(repoDir, "authored.txt"), "authored\n"); yield* runGit(repoDir, ["add", "authored.txt"]); yield* runGit(repoDir, ["commit", "-m", "New PR head commit"]); yield* runGit(repoDir, ["push", "origin", "author-push:feature/pr-reused-stale"]); const updatedHead = (yield* runGit(repoDir, ["rev-parse", "author-push"])).stdout.trim(); yield* runGit(repoDir, ["checkout", "main"]); const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 84, title: "Reused stale PR", url: "https://github.com/pingdotgg/codething-mvp/pull/84", baseRefName: "main", headRefName: "feature/pr-reused-stale", state: "open", }, }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "84", mode: "worktree", }); expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( NodeFS.realpathSync.native(worktreePath), ); expect(result.branch).toBe("feature/pr-reused-stale"); expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(updatedHead); }), ); it.effect("runs the setup script when a reused PR worktree moves onto the new head", () => 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-reused-setup"]); NodeFS.writeFileSync(NodePath.join(repoDir, "reused-setup.txt"), "reused setup\n"); yield* runGit(repoDir, ["add", "reused-setup.txt"]); yield* runGit(repoDir, ["commit", "-m", "Reused setup PR branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-reused-setup"]); yield* runGit(repoDir, ["checkout", "main"]); const worktreePath = NodePath.join( repoDir, "..", `pr-reused-setup-${NodePath.basename(repoDir)}`, ); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-setup"]); yield* runGit(repoDir, ["checkout", "-b", "setup-author-push", "feature/pr-reused-setup"]); NodeFS.writeFileSync(NodePath.join(repoDir, "reused-setup.txt"), "reused setup again\n"); yield* runGit(repoDir, ["add", "reused-setup.txt"]); yield* runGit(repoDir, ["commit", "-m", "New reused setup head"]); yield* runGit(repoDir, ["push", "origin", "setup-author-push:feature/pr-reused-setup"]); yield* runGit(repoDir, ["checkout", "main"]); const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 85, title: "Reused setup PR", url: "https://github.com/pingdotgg/codething-mvp/pull/85", baseRefName: "main", headRefName: "feature/pr-reused-setup", state: "open", }, }, setupScriptRunner: { runForThread: (setupInput) => Effect.sync(() => { setupCalls.push(setupInput); return { status: "no-script" as const }; }), }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "85", mode: "worktree", threadId: asThreadId("thread-pr-reused-setup"), }); expect(setupCalls).toHaveLength(1); expect(setupCalls[0]).toEqual({ threadId: "thread-pr-reused-setup", projectCwd: repoDir, worktreePath: result.worktreePath as string, }); expect(result.isOnPullRequestHead).toBe(true); }), ); it.effect("leaves the setup script alone when a reused PR worktree is already on the head", () => 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-reused-current"]); NodeFS.writeFileSync(NodePath.join(repoDir, "reused-current.txt"), "reused current\n"); yield* runGit(repoDir, ["add", "reused-current.txt"]); yield* runGit(repoDir, ["commit", "-m", "Reused current PR branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-reused-current"]); yield* runGit(repoDir, ["checkout", "main"]); const worktreePath = NodePath.join( repoDir, "..", `pr-reused-current-${NodePath.basename(repoDir)}`, ); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-current"]); const currentHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 95, title: "Reused current PR", url: "https://github.com/pingdotgg/codething-mvp/pull/95", baseRefName: "main", headRefName: "feature/pr-reused-current", state: "open", }, }, setupScriptRunner: { runForThread: (setupInput) => Effect.sync(() => { setupCalls.push(setupInput); return { status: "no-script" as const }; }), }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "95", mode: "worktree", threadId: asThreadId("thread-pr-reused-current"), }); expect(result.isOnPullRequestHead).toBe(true); expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(currentHead); expect(setupCalls).toHaveLength(0); }), ); it.effect("resets a clean reused PR worktree onto a force-pushed pull request head", () => 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-force-pushed"]); NodeFS.writeFileSync(NodePath.join(repoDir, "force-pushed.txt"), "first\n"); yield* runGit(repoDir, ["add", "force-pushed.txt"]); yield* runGit(repoDir, ["commit", "-m", "Force-pushed PR branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-force-pushed"]); yield* runGit(repoDir, ["checkout", "main"]); const worktreePath = NodePath.join( repoDir, "..", `pr-force-pushed-${NodePath.basename(repoDir)}`, ); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-force-pushed"]); const staleHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); yield* runGit(repoDir, ["checkout", "-b", "author-rewrite", "feature/pr-force-pushed"]); NodeFS.writeFileSync(NodePath.join(repoDir, "force-pushed.txt"), "rewritten\n"); yield* runGit(repoDir, ["add", "force-pushed.txt"]); yield* runGit(repoDir, ["commit", "--amend", "-m", "Rewritten PR head"]); yield* runGit(repoDir, [ "push", "--force", "origin", "author-rewrite:feature/pr-force-pushed", ]); const rewrittenHead = (yield* runGit(repoDir, ["rev-parse", "author-rewrite"])).stdout.trim(); // Pushing from this clone also advanced its remote-tracking ref. A head rewritten by the // author leaves that ref behind, which is the state a reused worktree is really opened in. yield* runGit(repoDir, [ "update-ref", "refs/remotes/origin/feature/pr-force-pushed", staleHead, ]); yield* runGit(repoDir, ["checkout", "main"]); const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 86, title: "Force-pushed PR", url: "https://github.com/pingdotgg/codething-mvp/pull/86", baseRefName: "main", headRefName: "feature/pr-force-pushed", state: "open", }, }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "86", mode: "worktree", }); expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( NodeFS.realpathSync.native(worktreePath), ); expect(result.isOnPullRequestHead).toBe(true); expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe( rewrittenHead, ); expect(NodeFS.readFileSync(NodePath.join(worktreePath, "force-pushed.txt"), "utf8")).toBe( "rewritten\n", ); }), ); it.effect("keeps a reused PR worktree that carries its own commit off the rewritten head", () => 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-local-commit"]); NodeFS.writeFileSync(NodePath.join(repoDir, "local-commit.txt"), "first\n"); yield* runGit(repoDir, ["add", "local-commit.txt"]); yield* runGit(repoDir, ["commit", "-m", "Local commit PR branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-commit"]); yield* runGit(repoDir, ["checkout", "main"]); const worktreePath = NodePath.join( repoDir, "..", `pr-local-commit-${NodePath.basename(repoDir)}`, ); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-local-commit"]); const upstreamHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); yield* runGit(repoDir, ["checkout", "-b", "local-commit-rewrite", "feature/pr-local-commit"]); NodeFS.writeFileSync(NodePath.join(repoDir, "local-commit.txt"), "rewritten\n"); yield* runGit(repoDir, ["add", "local-commit.txt"]); yield* runGit(repoDir, ["commit", "--amend", "-m", "Rewritten local commit head"]); yield* runGit(repoDir, [ "push", "--force", "origin", "local-commit-rewrite:feature/pr-local-commit", ]); yield* runGit(repoDir, [ "update-ref", "refs/remotes/origin/feature/pr-local-commit", upstreamHead, ]); yield* runGit(repoDir, ["checkout", "main"]); // The work that must survive: a commit made in the worktree, on top of the stale head. NodeFS.writeFileSync(NodePath.join(worktreePath, "thread-work.txt"), "thread work\n"); yield* runGit(worktreePath, ["add", "thread-work.txt"]); yield* runGit(worktreePath, ["commit", "-m", "Work done in the reused worktree"]); const worktreeHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 87, title: "Local commit PR", url: "https://github.com/pingdotgg/codething-mvp/pull/87", baseRefName: "main", headRefName: "feature/pr-local-commit", state: "open", }, }, setupScriptRunner: { runForThread: (setupInput) => Effect.sync(() => { setupCalls.push(setupInput); return { status: "no-script" as const }; }), }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "87", mode: "worktree", threadId: asThreadId("thread-pr-local-commit"), }); expect(result.isOnPullRequestHead).toBe(false); expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(worktreeHead); expect(NodeFS.existsSync(NodePath.join(worktreePath, "thread-work.txt"))).toBe(true); expect(setupCalls).toHaveLength(0); }), ); it.effect("keeps a dirty reused PR worktree off the rewritten pull request head", () => 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-dirty-worktree"]); NodeFS.writeFileSync(NodePath.join(repoDir, "dirty.txt"), "first\n"); yield* runGit(repoDir, ["add", "dirty.txt"]); yield* runGit(repoDir, ["commit", "-m", "Dirty worktree PR branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-dirty-worktree"]); yield* runGit(repoDir, ["checkout", "main"]); const worktreePath = NodePath.join( repoDir, "..", `pr-dirty-worktree-${NodePath.basename(repoDir)}`, ); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-dirty-worktree"]); const staleHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); yield* runGit(repoDir, ["checkout", "-b", "dirty-rewrite", "feature/pr-dirty-worktree"]); NodeFS.writeFileSync(NodePath.join(repoDir, "dirty.txt"), "rewritten\n"); yield* runGit(repoDir, ["add", "dirty.txt"]); yield* runGit(repoDir, ["commit", "--amend", "-m", "Rewritten dirty head"]); yield* runGit(repoDir, [ "push", "--force", "origin", "dirty-rewrite:feature/pr-dirty-worktree", ]); yield* runGit(repoDir, [ "update-ref", "refs/remotes/origin/feature/pr-dirty-worktree", staleHead, ]); yield* runGit(repoDir, ["checkout", "main"]); NodeFS.writeFileSync(NodePath.join(worktreePath, "dirty.txt"), "uncommitted edit\n"); const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 89, title: "Dirty worktree PR", url: "https://github.com/pingdotgg/codething-mvp/pull/89", baseRefName: "main", headRefName: "feature/pr-dirty-worktree", state: "open", }, }, setupScriptRunner: { runForThread: (setupInput) => Effect.sync(() => { setupCalls.push(setupInput); return { status: "no-script" as const }; }), }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "89", mode: "worktree", threadId: asThreadId("thread-pr-dirty-worktree"), }); expect(result.isOnPullRequestHead).toBe(false); expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(staleHead); expect(NodeFS.readFileSync(NodePath.join(worktreePath, "dirty.txt"), "utf8")).toBe( "uncommitted edit\n", ); expect(setupCalls).toHaveLength(0); }), ); it.effect("refreshes a reused PR worktree that has no upstream from the pull request ref", () => 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-ref-only"]); NodeFS.writeFileSync(NodePath.join(repoDir, "ref-only.txt"), "ref only\n"); yield* runGit(repoDir, ["add", "ref-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "Pull ref only PR branch"]); // The head lives at refs/pull/90/head and nowhere else, so nothing can be tracked. yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/90/head"]); yield* runGit(repoDir, ["checkout", "main"]); yield* runGit(repoDir, ["branch", "-D", "feature/pr-ref-only"]); const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 90, title: "Pull ref only PR", url: "https://github.com/pingdotgg/codething-mvp/pull/90", baseRefName: "main", headRefName: "feature/pr-ref-only", state: "open", }, }, }); const created = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "90", mode: "worktree", }); const worktreePath = created.worktreePath as string; expect( (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"], true)).exitCode, ).not.toBe(0); yield* runGit(repoDir, ["fetch", "origin", "refs/pull/90/head"]); yield* runGit(repoDir, ["checkout", "-b", "ref-only-author", "FETCH_HEAD"]); NodeFS.writeFileSync(NodePath.join(repoDir, "ref-only.txt"), "ref only again\n"); yield* runGit(repoDir, ["add", "ref-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "New pull ref head"]); yield* runGit(repoDir, ["push", "origin", "ref-only-author:refs/pull/90/head"]); const updatedHead = (yield* runGit(repoDir, ["rev-parse", "ref-only-author"])).stdout.trim(); yield* runGit(repoDir, ["checkout", "main"]); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "90", mode: "worktree", }); expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( NodeFS.realpathSync.native(worktreePath), ); expect(result.isOnPullRequestHead).toBe(true); expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(updatedHead); }), ); it.effect("never moves an unrelated local branch that shares the fork head branch name", () => 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-collision"]); NodeFS.writeFileSync(NodePath.join(repoDir, "contributor.txt"), "contributor\n"); yield* runGit(repoDir, ["add", "contributor.txt"]); yield* runGit(repoDir, ["commit", "-m", "Contributor commit on the fork main"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-collision:main"]); // The user's own main, checked out in its own worktree and behind the fork's main: a // fast-forward would land the contributor's commits in it. yield* runGit(repoDir, ["checkout", "-b", "feature/root-work", "main"]); const mainWorktreePath = NodePath.join( repoDir, "..", `local-main-${NodePath.basename(repoDir)}`, ); yield* runGit(repoDir, ["worktree", "add", mainWorktreePath, "main"]); const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 94, title: "Fork main collision PR", url: "https://github.com/pingdotgg/codething-mvp/pull/94", baseRefName: "main", headRefName: "main", state: "open", isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", }, repositoryCloneUrls: { "octocat/codething-mvp": { url: forkDir, sshUrl: forkDir, }, }, }, setupScriptRunner: { runForThread: (setupInput) => Effect.sync(() => { setupCalls.push(setupInput); return { status: "no-script" as const }; }), }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "94", mode: "worktree", threadId: asThreadId("thread-pr-fork-main-collision"), }); expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore); expect((yield* runGit(mainWorktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe( localMainBefore, ); expect(NodeFS.existsSync(NodePath.join(mainWorktreePath, "contributor.txt"))).toBe(false); expect(result.isOnPullRequestHead).toBe(false); expect(setupCalls).toHaveLength(0); }), ); 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"]); NodeFS.writeFileSync(NodePath.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"]); NodeFS.writeFileSync(NodePath.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"]); NodeFS.writeFileSync(NodePath.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 = NodePath.join( repoDir, "..", `pr-reused-fork-${NodePath.basename(repoDir)}`, ); 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 && NodeFS.realpathSync.native(result.worktreePath)).toBe( NodeFS.realpathSync.native(worktreePath), ); expect( (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), ).toBe("fork-seed/feature/pr-reused-fork"); }), ); it.effect("does not fail PR worktree prep when setup terminal startup fails", () => 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-setup-failure"]); NodeFS.writeFileSync(NodePath.join(repoDir, "setup-failure.txt"), "setup failure\n"); yield* runGit(repoDir, ["add", "setup-failure.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR setup failure branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-setup-failure"]); yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/184/head"]); yield* runGit(repoDir, ["checkout", "main"]); const { manager } = yield* makeManager({ ghScenario: { pullRequest: { number: 184, title: "Setup failure PR", url: "https://github.com/pingdotgg/codething-mvp/pull/184", baseRefName: "main", headRefName: "feature/pr-setup-failure", state: "open", }, }, setupScriptRunner: { runForThread: (input) => Effect.fail( new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({ threadId: input.threadId, worktreePath: input.worktreePath, operation: "openTerminal", cause: new Error("terminal start failed"), }), ), }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "184", mode: "worktree", threadId: asThreadId("thread-pr-setup-failure"), }); expect(result.branch).toBe("feature/pr-setup-failure"); expect(result.worktreePath).not.toBeNull(); expect(NodeFS.existsSync(result.worktreePath as string)).toBe(true); }), ); 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"); }), ); it.effect("emits ordered progress events for commit hooks", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); NodeFS.writeFileSync(NodePath.join(repoDir, "hooked.txt"), "hooked\n"); NodeFS.writeFileSync( NodePath.join(repoDir, ".git", "hooks", "pre-commit"), '#!/bin/sh\necho "hook: start" >&2\nsleep 0.05\necho "hook: end" >&2\n', { mode: 0o755 }, ); const { manager } = yield* makeManager(); const events: GitActionProgressEvent[] = []; const result = yield* runStackedAction( manager, { cwd: repoDir, action: "commit", }, { actionId: "action-1", progressReporter: { publish: (event) => Effect.sync(() => { events.push(event); }), }, }, ); expect(result.commit.status).toBe("created"); expect(events.map((event) => event.kind)).toContain("action_started"); expect(events).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "phase_started", phase: "commit", }), expect.objectContaining({ kind: "hook_started", hookName: "pre-commit", }), expect.objectContaining({ kind: "hook_output", text: "hook: start", }), expect.objectContaining({ kind: "hook_output", text: "hook: end", }), expect.objectContaining({ kind: "hook_finished", hookName: "pre-commit", }), expect.objectContaining({ kind: "action_finished", }), ]), ); }), ); it.effect("emits action_failed when a commit hook rejects", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); NodeFS.writeFileSync(NodePath.join(repoDir, "hook-failure.txt"), "broken\n"); NodeFS.writeFileSync( NodePath.join(repoDir, ".git", "hooks", "pre-commit"), '#!/bin/sh\necho "hook: fail" >&2\nexit 1\n', { mode: 0o755 }, ); const { manager } = yield* makeManager(); const events: GitActionProgressEvent[] = []; const errorMessage = yield* runStackedAction( manager, { cwd: repoDir, action: "commit", }, { actionId: "action-2", progressReporter: { publish: (event) => Effect.sync(() => { events.push(event); }), }, }, ).pipe( Effect.flip, Effect.map((error) => error.message), ); expect(errorMessage).toContain("Git command failed in GitVcsDriver.commit.commit"); expect(errorMessage).not.toContain("hook: fail"); expect(events).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "hook_started", hookName: "pre-commit", }), expect.objectContaining({ kind: "hook_output", text: "hook: fail", }), expect.objectContaining({ kind: "action_failed", phase: "commit", }), ]), ); }), ); it.effect("create_pr emits only the PR phase when the branch is already pushed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-only-follow-up"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); NodeFS.writeFileSync(NodePath.join(repoDir, "pr-only.txt"), "pr only\n"); yield* runGit(repoDir, ["add", "pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 201, title: "PR only branch", url: "https://github.com/pingdotgg/codething-mvp/pull/201", baseRefName: "main", headRefName: "feature/pr-only-follow-up", state: "OPEN", isCrossRepository: false, }, ]), ], }, }); const events: GitActionProgressEvent[] = []; const result = yield* runStackedAction( manager, { cwd: repoDir, action: "create_pr", }, { actionId: "action-pr-only", progressReporter: { publish: (event) => Effect.sync(() => { events.push(event); }), }, }, ); expect(result.commit.status).toBe("skipped_not_requested"); expect(result.push.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("created"); expect( events.filter( (event): event is Extract => event.kind === "phase_started", ), ).toEqual([ expect.objectContaining({ kind: "phase_started", phase: "pr", label: "Preparing PR...", }), expect.objectContaining({ kind: "phase_started", phase: "pr", label: "Generating PR content...", }), expect.objectContaining({ kind: "phase_started", phase: "pr", label: "Creating pull request...", }), ]); }), ); });