From 52681634cdf71e05a8df069664953b42352a388c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 25 Feb 2026 17:41:10 -0800 Subject: [PATCH] refactor git manager --- .../checkpointing/Layers/CheckpointStore.ts | 4 +- apps/server/src/codexTextGenerator.ts | 254 ---- apps/server/src/coreServices.ts | 32 - apps/server/src/git.test.ts | 930 ------------- apps/server/src/git.ts | 730 ----------- apps/server/src/git/Errors.ts | 66 + .../git/Layers/CodexTextGeneration.test.ts | 205 +++ .../src/git/Layers/CodexTextGeneration.ts | 393 ++++++ apps/server/src/git/Layers/GitCore.test.ts | 1157 +++++++++++++++++ apps/server/src/git/Layers/GitCore.ts | 746 +++++++++++ apps/server/src/git/Layers/GitHubCli.ts | 178 +++ apps/server/src/git/Layers/GitManager.test.ts | 660 ++++++++++ apps/server/src/git/Layers/GitManager.ts | 318 +++++ .../GitService.test.ts} | 6 +- .../git/{Layer.ts => Layers/GitService.ts} | 9 +- apps/server/src/git/Services/GitCore.ts | 146 +++ apps/server/src/git/Services/GitHubCli.ts | 66 + apps/server/src/git/Services/GitManager.ts | 41 + .../{Service.ts => Services/GitService.ts} | 2 +- .../server/src/git/Services/TextGeneration.ts | 71 + apps/server/src/gitManager.test.ts | 482 ------- apps/server/src/gitManager.ts | 462 ------- apps/server/src/ptyAdapter.ts | 256 ---- apps/server/src/serverLayers.ts | 54 +- apps/server/src/terminal/Layers/BunPTY.ts | 115 ++ .../Layers/Manager.test.ts} | 4 +- .../Layers/Manager.ts} | 136 +- .../Layers/NodePTY.test.ts} | 2 +- apps/server/src/terminal/Layers/NodePTY.ts | 111 ++ apps/server/src/terminal/Services/Manager.ts | 71 + apps/server/src/terminal/Services/PTY.ts | 32 + apps/server/src/wsServer.test.ts | 106 +- apps/server/src/wsServer.ts | 55 +- 33 files changed, 4557 insertions(+), 3343 deletions(-) delete mode 100644 apps/server/src/codexTextGenerator.ts delete mode 100644 apps/server/src/coreServices.ts delete mode 100644 apps/server/src/git.test.ts delete mode 100644 apps/server/src/git.ts create mode 100644 apps/server/src/git/Layers/CodexTextGeneration.test.ts create mode 100644 apps/server/src/git/Layers/CodexTextGeneration.ts create mode 100644 apps/server/src/git/Layers/GitCore.test.ts create mode 100644 apps/server/src/git/Layers/GitCore.ts create mode 100644 apps/server/src/git/Layers/GitHubCli.ts create mode 100644 apps/server/src/git/Layers/GitManager.test.ts create mode 100644 apps/server/src/git/Layers/GitManager.ts rename apps/server/src/git/{Layer.test.ts => Layers/GitService.test.ts} (92%) rename apps/server/src/git/{Layer.ts => Layers/GitService.ts} (96%) create mode 100644 apps/server/src/git/Services/GitCore.ts create mode 100644 apps/server/src/git/Services/GitHubCli.ts create mode 100644 apps/server/src/git/Services/GitManager.ts rename apps/server/src/git/{Service.ts => Services/GitService.ts} (95%) create mode 100644 apps/server/src/git/Services/TextGeneration.ts delete mode 100644 apps/server/src/gitManager.test.ts delete mode 100644 apps/server/src/gitManager.ts delete mode 100644 apps/server/src/ptyAdapter.ts create mode 100644 apps/server/src/terminal/Layers/BunPTY.ts rename apps/server/src/{terminalManager.test.ts => terminal/Layers/Manager.test.ts} (99%) rename apps/server/src/{terminalManager.ts => terminal/Layers/Manager.ts} (91%) rename apps/server/src/{ptyAdapter.test.ts => terminal/Layers/NodePTY.test.ts} (96%) create mode 100644 apps/server/src/terminal/Layers/NodePTY.ts create mode 100644 apps/server/src/terminal/Services/Manager.ts create mode 100644 apps/server/src/terminal/Services/PTY.ts diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index dd13373151faa3623c9a60a229b71301d0f68c0f..09bff7e5f8392b13ba507e15144c350d9e6efeb9 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -15,8 +15,8 @@ import { Effect, Layer, FileSystem, Path } from "effect"; import { CheckpointInvariantError } from "../Errors.ts"; import { GitCommandError } from "../../git/Errors.ts"; -import { GitServiceLive } from "../../git/Layer.ts"; -import { GitService } from "../../git/Service.ts"; +import { GitServiceLive } from "../../git/Layers/GitService.ts"; +import { GitService } from "../../git/Services/GitService.ts"; import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; import { CheckpointRef } from "@t3tools/contracts"; diff --git a/apps/server/src/codexTextGenerator.ts b/apps/server/src/codexTextGenerator.ts deleted file mode 100644 index ebc92ac9a855d9aeaa69bd5ebdb3fa845f3c11d1..0000000000000000000000000000000000000000 --- a/apps/server/src/codexTextGenerator.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { randomUUID } from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import type { - CommitMessageGenerationInput, - CommitMessageGenerationResult, - PrContentGenerationInput, - PrContentGenerationResult, - TextGenerationService, -} from "./coreServices"; -import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner"; - -interface ProcessRunner { - ( - command: string, - args: readonly string[], - options?: ProcessRunOptions, - ): Promise; -} - -const CODEX_MODEL = "gpt-5.3-codex"; -const CODEX_REASONING_EFFORT = "low"; - -const COMMIT_OUTPUT_SCHEMA_JSON = { - type: "object", - properties: { - subject: { type: "string" }, - body: { type: "string" }, - }, - required: ["subject", "body"], - additionalProperties: false, -} as const; - -const PR_OUTPUT_SCHEMA_JSON = { - type: "object", - properties: { - title: { type: "string" }, - body: { type: "string" }, - }, - required: ["title", "body"], - additionalProperties: false, -} as const; - -function parseCommitOutput(raw: unknown): { subject: string; body: string } { - if (!raw || typeof raw !== "object") { - throw new Error("Codex returned a non-object commit message payload."); - } - const record = raw as Record; - const subject = typeof record.subject === "string" ? record.subject.trim() : ""; - const body = typeof record.body === "string" ? record.body : ""; - if (subject.length === 0) { - throw new Error("Codex returned an empty commit subject."); - } - return { subject, body }; -} - -function parsePrOutput(raw: unknown): { title: string; body: string } { - if (!raw || typeof raw !== "object") { - throw new Error("Codex returned a non-object PR payload."); - } - const record = raw as Record; - const title = typeof record.title === "string" ? record.title.trim() : ""; - const body = typeof record.body === "string" ? record.body.trim() : ""; - if (title.length === 0 || body.length === 0) { - throw new Error("Codex returned an invalid PR title/body payload."); - } - return { title, body }; -} - -function limitSection(value: string, maxChars: number): string { - if (value.length <= maxChars) return value; - const truncated = value.slice(0, maxChars); - return `${truncated}\n\n[truncated]`; -} - -async function writeTempFile(prefix: string, content: string): Promise { - const filePath = path.join(os.tmpdir(), `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`); - await fs.writeFile(filePath, content, "utf8"); - return filePath; -} - -async function safeUnlink(filePath: string): Promise { - try { - await fs.unlink(filePath); - } catch { - // Best-effort cleanup. - } -} - -function sanitizeCommitSubject(raw: string): string { - const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); - if (withoutTrailingPeriod.length === 0) { - return "Update project files"; - } - - if (withoutTrailingPeriod.length <= 72) { - return withoutTrailingPeriod; - } - return withoutTrailingPeriod.slice(0, 72).trimEnd(); -} - -function sanitizePrTitle(raw: string): string { - const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - if (singleLine.length > 0) { - return singleLine; - } - return "Update project changes"; -} - -async function runCodexJson({ - cwd, - prompt, - outputSchemaJson, - parse, - run, -}: { - cwd: string; - prompt: string; - outputSchemaJson: object; - parse: (raw: unknown) => T; - run: ProcessRunner; -}): Promise { - const schemaPath = await writeTempFile("codex-schema", JSON.stringify(outputSchemaJson)); - let outputPath: string | null = null; - - try { - outputPath = await writeTempFile("codex-output", ""); - await run( - "codex", - [ - "exec", - "--ephemeral", - "-s", - "read-only", - "--model", - CODEX_MODEL, - "--config", - `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`, - "--output-schema", - schemaPath, - "--output-last-message", - outputPath, - "-", - ], - { - cwd, - timeoutMs: 180_000, - stdin: prompt, - }, - ); - - const rawOutput = (await fs.readFile(outputPath, "utf8")).trim(); - if (rawOutput.length === 0) { - throw new Error("Codex returned an empty response."); - } - - let parsedJson: unknown; - try { - parsedJson = JSON.parse(rawOutput); - } catch { - throw new Error("Codex returned invalid JSON output."); - } - - return parse(parsedJson); - } finally { - await Promise.all([safeUnlink(schemaPath), ...(outputPath ? [safeUnlink(outputPath)] : [])]); - } -} - -interface CodexTextGeneratorDeps { - runProcess?: ProcessRunner; -} - -export class CodexTextGenerator implements TextGenerationService { - private readonly run: ProcessRunner; - - constructor(deps: CodexTextGeneratorDeps = {}) { - this.run = deps.runProcess ?? runProcess; - } - - async generateCommitMessage( - input: CommitMessageGenerationInput, - ): Promise { - const prompt = [ - "You write concise git commit messages.", - "Return a JSON object with keys: subject, body.", - "Rules:", - "- subject must be imperative, <= 72 chars, and no trailing period", - "- body can be empty string or short bullet points", - "- capture the primary user-visible or developer-visible change", - "", - `Branch: ${input.branch ?? "(detached)"}`, - "", - "Staged files:", - limitSection(input.stagedSummary, 6_000), - "", - "Staged patch:", - limitSection(input.stagedPatch, 40_000), - ].join("\n"); - - const generated = await runCodexJson({ - cwd: input.cwd, - prompt, - outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON, - parse: (raw) => parseCommitOutput(raw), - run: this.run, - }); - - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - }; - } - - async generatePrContent(input: PrContentGenerationInput): Promise { - const prompt = [ - "You write GitHub pull request content.", - "Return a JSON object with keys: title, body.", - "Rules:", - "- title should be concise and specific", - "- body must be markdown and include headings '## Summary' and '## Testing'", - "- under Summary, provide short bullet points", - "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", - "", - `Base branch: ${input.baseBranch}`, - `Head branch: ${input.headBranch}`, - "", - "Commits:", - limitSection(input.commitSummary, 12_000), - "", - "Diff stat:", - limitSection(input.diffSummary, 12_000), - "", - "Diff patch:", - limitSection(input.diffPatch, 40_000), - ].join("\n"); - - const generated = await runCodexJson({ - cwd: input.cwd, - prompt, - outputSchemaJson: PR_OUTPUT_SCHEMA_JSON, - parse: (raw) => parsePrOutput(raw), - run: this.run, - }); - - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - } -} diff --git a/apps/server/src/coreServices.ts b/apps/server/src/coreServices.ts deleted file mode 100644 index 887ff36a4bcc0718357ba631d899b84ccbb49a2f..0000000000000000000000000000000000000000 --- a/apps/server/src/coreServices.ts +++ /dev/null @@ -1,32 +0,0 @@ -export interface CommitMessageGenerationInput { - cwd: string; - branch: string | null; - stagedSummary: string; - stagedPatch: string; -} - -export interface CommitMessageGenerationResult { - subject: string; - body: string; -} - -export interface PrContentGenerationInput { - cwd: string; - baseBranch: string; - headBranch: string; - commitSummary: string; - diffSummary: string; - diffPatch: string; -} - -export interface PrContentGenerationResult { - title: string; - body: string; -} - -export interface TextGenerationService { - generateCommitMessage( - input: CommitMessageGenerationInput, - ): Promise; - generatePrContent(input: PrContentGenerationInput): Promise; -} diff --git a/apps/server/src/git.test.ts b/apps/server/src/git.test.ts deleted file mode 100644 index 1e01c6d2623ede654bd0cc3c8de2ed234b1c21fe..0000000000000000000000000000000000000000 --- a/apps/server/src/git.test.ts +++ /dev/null @@ -1,930 +0,0 @@ -import { existsSync } from "node:fs"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; - -import { - GitCoreService, - checkoutGitBranch, - createGitBranch, - createGitWorktree, - initGitRepo, - listGitBranches, - pullGitBranch, - removeGitWorktree, - runTerminalCommand, -} from "./git"; - -// ── Helpers ── - -/** Run a raw git command for test setup (not under test). */ -async function git(cwd: string, command: string): Promise { - const result = await runTerminalCommand({ - command: `git ${command}`, - cwd, - timeoutMs: 10_000, - }); - if (result.code !== 0) { - throw new Error(`git ${command} failed: ${result.stderr}`); - } - return result.stdout.trim(); -} - -/** Create a disposable temp directory that cleans up automatically. */ -async function makeTmpDir() { - const dir = await mkdtemp(path.join(tmpdir(), "git-test-")); - return { - path: dir, - [Symbol.asyncDispose]: async () => { - await rm(dir, { recursive: true, force: true }); - }, - }; -} - -/** Create a repo with an initial commit so branches work. */ -async function initRepoWithCommit(cwd: string): Promise { - await initGitRepo({ cwd }); - await git(cwd, "config user.email 'test@test.com'"); - await git(cwd, "config user.name 'Test'"); - await writeFile(path.join(cwd, "README.md"), "# test\n"); - await git(cwd, "add ."); - await git(cwd, "commit -m 'initial commit'"); -} - -async function commitWithDate( - cwd: string, - fileName: string, - fileContents: string, - dateIsoString: string, - message: string, -): Promise { - await writeFile(path.join(cwd, fileName), fileContents); - await git(cwd, `add ${fileName}`); - const commitResult = await runTerminalCommand({ - command: `GIT_AUTHOR_DATE="${dateIsoString}" GIT_COMMITTER_DATE="${dateIsoString}" git commit -m "${message}"`, - cwd, - timeoutMs: 10_000, - }); - if (commitResult.code !== 0) { - throw new Error(`git dated commit failed: ${commitResult.stderr}`); - } -} - -// ── Tests ── - -describe("git integration", () => { - describe("runTerminalCommand", () => { - it("caps captured output when maxOutputBytes is exceeded", async () => { - const result = await runTerminalCommand({ - command: `node -e "process.stdout.write('x'.repeat(2000))"`, - cwd: process.cwd(), - timeoutMs: 10_000, - maxOutputBytes: 128, - }); - - expect(result.code).toBe(0); - expect(result.stdout.length).toBeLessThanOrEqual(128); - expect(result.stderr).toContain("output truncated"); - }); - }); - - // ── initGitRepo ── - - describe("initGitRepo", () => { - it("creates a valid git repo", async () => { - await using tmp = await makeTmpDir(); - await initGitRepo({ cwd: tmp.path }); - expect(existsSync(path.join(tmp.path, ".git"))).toBe(true); - }); - - it("listGitBranches reports isRepo: true after init + commit", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - const result = await listGitBranches({ cwd: tmp.path }); - expect(result.isRepo).toBe(true); - expect(result.branches.length).toBeGreaterThanOrEqual(1); - }); - }); - - // ── listGitBranches ── - - describe("listGitBranches", () => { - it("returns isRepo: false for non-git directory", async () => { - await using tmp = await makeTmpDir(); - const result = await listGitBranches({ cwd: tmp.path }); - expect(result.isRepo).toBe(false); - expect(result.branches).toEqual([]); - }); - - it("returns the current branch with current: true", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - const result = await listGitBranches({ cwd: tmp.path }); - const current = result.branches.find((b) => b.current); - expect(current).toBeDefined(); - expect(current!.current).toBe(true); - }); - - it("keeps current branch first and sorts the remaining branches by recency", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - const initialBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( - (branch) => branch.current, - )!.name; - - await createGitBranch({ cwd: tmp.path, branch: "older-branch" }); - await checkoutGitBranch({ cwd: tmp.path, branch: "older-branch" }); - await commitWithDate( - tmp.path, - "older.txt", - "older branch change\n", - "Thu, 1 Jan 2037 00:00:00 +0000", - "older branch change", - ); - - await checkoutGitBranch({ cwd: tmp.path, branch: initialBranch }); - await createGitBranch({ cwd: tmp.path, branch: "newer-branch" }); - await checkoutGitBranch({ cwd: tmp.path, branch: "newer-branch" }); - await commitWithDate( - tmp.path, - "newer.txt", - "newer branch change\n", - "Fri, 1 Jan 2038 00:00:00 +0000", - "newer branch change", - ); - - // Switch away to show current branch is pinned, then remaining branches are recency-sorted. - await checkoutGitBranch({ cwd: tmp.path, branch: "older-branch" }); - - const result = await listGitBranches({ cwd: tmp.path }); - expect(result.branches[0]!.name).toBe("older-branch"); - expect(result.branches[1]!.name).toBe("newer-branch"); - }); - - it("keeps default branch right after current branch", async () => { - await using tmp = await makeTmpDir(); - await using remote = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - const defaultBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( - (branch) => branch.current, - )!.name; - - await git(remote.path, "init --bare"); - await git(tmp.path, `remote add origin ${JSON.stringify(remote.path)}`); - await git(tmp.path, `push -u origin ${defaultBranch}`); - await git(tmp.path, `remote set-head origin ${defaultBranch}`); - - await createGitBranch({ cwd: tmp.path, branch: "current-branch" }); - await checkoutGitBranch({ cwd: tmp.path, branch: "current-branch" }); - await commitWithDate( - tmp.path, - "current.txt", - "current change\n", - "Thu, 1 Jan 2037 00:00:00 +0000", - "current change", - ); - - await checkoutGitBranch({ cwd: tmp.path, branch: defaultBranch }); - await createGitBranch({ cwd: tmp.path, branch: "newer-branch" }); - await checkoutGitBranch({ cwd: tmp.path, branch: "newer-branch" }); - await commitWithDate( - tmp.path, - "newer.txt", - "newer change\n", - "Fri, 1 Jan 2038 00:00:00 +0000", - "newer change", - ); - - await checkoutGitBranch({ cwd: tmp.path, branch: "current-branch" }); - - const result = await listGitBranches({ cwd: tmp.path }); - expect(result.branches[0]!.name).toBe("current-branch"); - expect(result.branches[1]!.name).toBe(defaultBranch); - expect(result.branches[2]!.name).toBe("newer-branch"); - }); - - it("lists multiple branches after creating them", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - await createGitBranch({ cwd: tmp.path, branch: "feature-a" }); - await createGitBranch({ cwd: tmp.path, branch: "feature-b" }); - - const result = await listGitBranches({ cwd: tmp.path }); - const names = result.branches.map((b) => b.name); - expect(names).toContain("feature-a"); - expect(names).toContain("feature-b"); - }); - - it("isDefault is false when no remote exists", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - const result = await listGitBranches({ cwd: tmp.path }); - expect(result.branches.every((b) => b.isDefault === false)).toBe(true); - }); - }); - - // ── checkoutGitBranch ── - - describe("checkoutGitBranch", () => { - it("checks out an existing branch", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - await createGitBranch({ cwd: tmp.path, branch: "feature" }); - - await checkoutGitBranch({ cwd: tmp.path, branch: "feature" }); - - const result = await listGitBranches({ cwd: tmp.path }); - const current = result.branches.find((b) => b.current); - expect(current!.name).toBe("feature"); - }); - - it("refreshes upstream behind count after checkout when remote branch advanced", async () => { - await using remote = await makeTmpDir(); - await using source = await makeTmpDir(); - await using clone = await makeTmpDir(); - await git(remote.path, "init --bare"); - - await initRepoWithCommit(source.path); - const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( - (branch) => branch.current, - )!.name; - await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); - await git(source.path, `push -u origin ${defaultBranch}`); - - const featureBranch = "feature-behind"; - await createGitBranch({ cwd: source.path, branch: featureBranch }); - await checkoutGitBranch({ cwd: source.path, branch: featureBranch }); - await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); - await git(source.path, "add feature.txt"); - await git(source.path, "commit -m 'feature base'"); - await git(source.path, `push -u origin ${featureBranch}`); - await checkoutGitBranch({ cwd: source.path, branch: defaultBranch }); - - await git(clone.path, `clone ${JSON.stringify(remote.path)} .`); - await git(clone.path, "config user.email 'test@test.com'"); - await git(clone.path, "config user.name 'Test'"); - await git(clone.path, `checkout -b ${featureBranch} --track origin/${featureBranch}`); - await writeFile(path.join(clone.path, "feature.txt"), "feature from remote\n"); - await git(clone.path, "add feature.txt"); - await git(clone.path, "commit -m 'remote feature update'"); - await git(clone.path, `push origin ${featureBranch}`); - - await checkoutGitBranch({ cwd: source.path, branch: featureBranch }); - const core = new GitCoreService(); - await vi.waitFor(async () => { - const details = await core.statusDetails(source.path); - expect(details.branch).toBe(featureBranch); - expect(details.aheadCount).toBe(0); - expect(details.behindCount).toBe(1); - }); - }); - - it("keeps checkout successful when upstream refresh fails", async () => { - await using remote = await makeTmpDir(); - await using source = await makeTmpDir(); - await git(remote.path, "init --bare"); - - await initRepoWithCommit(source.path); - const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( - (branch) => branch.current, - )!.name; - await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); - await git(source.path, `push -u origin ${defaultBranch}`); - - const featureBranch = "feature-refresh-failure"; - await git(source.path, `branch ${featureBranch}`); - await git(source.path, `checkout ${featureBranch}`); - await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); - await git(source.path, "add feature.txt"); - await git(source.path, "commit -m 'feature base'"); - await git(source.path, `push -u origin ${featureBranch}`); - await git(source.path, `checkout ${defaultBranch}`); - - class RefreshFailingGitCoreService extends GitCoreService { - refreshFetchAttempts = 0; - - override async git( - cwd: string, - args: readonly string[], - allowNonZeroExit = false, - ): Promise { - if (args[0] === "fetch") { - this.refreshFetchAttempts += 1; - throw new Error("simulated fetch timeout"); - } - await super.git(cwd, args, allowNonZeroExit); - } - } - - const core = new RefreshFailingGitCoreService(); - await expect(core.checkoutBranch({ cwd: source.path, branch: featureBranch })).resolves.toBe( - undefined, - ); - await vi.waitFor(() => { - expect(core.refreshFetchAttempts).toBe(1); - }); - expect(await git(source.path, "branch --show-current")).toBe(featureBranch); - }); - - it("refresh fetch is scoped to the checked out branch upstream refspec", async () => { - await using remote = await makeTmpDir(); - await using source = await makeTmpDir(); - await git(remote.path, "init --bare"); - - await initRepoWithCommit(source.path); - const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( - (branch) => branch.current, - )!.name; - await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); - await git(source.path, `push -u origin ${defaultBranch}`); - - const featureBranch = "feature/scoped-fetch"; - await git(source.path, `checkout -b ${featureBranch}`); - await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); - await git(source.path, "add feature.txt"); - await git(source.path, "commit -m 'feature base'"); - await git(source.path, `push -u origin ${featureBranch}`); - await git(source.path, `checkout ${defaultBranch}`); - - class FetchRecordingGitCoreService extends GitCoreService { - fetchArgs: readonly string[] | null = null; - - override async git( - cwd: string, - args: readonly string[], - allowNonZeroExit = false, - ): Promise { - if (args[0] === "fetch") { - this.fetchArgs = args; - return; - } - await super.git(cwd, args, allowNonZeroExit); - } - } - - const core = new FetchRecordingGitCoreService(); - await core.checkoutBranch({ cwd: source.path, branch: featureBranch }); - await vi.waitFor(() => { - expect(core.fetchArgs).not.toBeNull(); - }); - - expect(await git(source.path, "branch --show-current")).toBe(featureBranch); - expect(core.fetchArgs).toEqual([ - "fetch", - "--quiet", - "--no-tags", - "origin", - `+refs/heads/${featureBranch}:refs/remotes/origin/${featureBranch}`, - ]); - }); - - it("returns checkout result before background upstream refresh completes", async () => { - await using remote = await makeTmpDir(); - await using source = await makeTmpDir(); - await git(remote.path, "init --bare"); - - await initRepoWithCommit(source.path); - const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( - (branch) => branch.current, - )!.name; - await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); - await git(source.path, `push -u origin ${defaultBranch}`); - - const featureBranch = "feature/background-refresh"; - await git(source.path, `checkout -b ${featureBranch}`); - await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); - await git(source.path, "add feature.txt"); - await git(source.path, "commit -m 'feature base'"); - await git(source.path, `push -u origin ${featureBranch}`); - await git(source.path, `checkout ${defaultBranch}`); - - class SlowRefreshGitCoreService extends GitCoreService { - fetchStarted = false; - private readonly waitForReleasePromise: Promise; - private releaseWait: () => void = () => undefined; - - constructor() { - super(); - this.waitForReleasePromise = new Promise((resolve) => { - this.releaseWait = resolve; - }); - } - - releaseFetch(): void { - this.releaseWait(); - } - - override async git( - cwd: string, - args: readonly string[], - allowNonZeroExit = false, - ): Promise { - if (args[0] === "fetch") { - this.fetchStarted = true; - await this.waitForReleasePromise; - return; - } - await super.git(cwd, args, allowNonZeroExit); - } - } - - const core = new SlowRefreshGitCoreService(); - await expect(core.checkoutBranch({ cwd: source.path, branch: featureBranch })).resolves.toBe( - undefined, - ); - await vi.waitFor(() => { - expect(core.fetchStarted).toBe(true); - }); - expect(await git(source.path, "branch --show-current")).toBe(featureBranch); - core.releaseFetch(); - }); - - it("throws when branch does not exist", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - await expect(checkoutGitBranch({ cwd: tmp.path, branch: "nonexistent" })).rejects.toThrow(); - }); - - it("throws when checkout would overwrite uncommitted changes", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - await createGitBranch({ cwd: tmp.path, branch: "other" }); - - // Create a conflicting change: modify README on current branch - await writeFile(path.join(tmp.path, "README.md"), "modified\n"); - await git(tmp.path, "add README.md"); - - // First, checkout other branch cleanly - await git(tmp.path, "stash"); - await checkoutGitBranch({ cwd: tmp.path, branch: "other" }); - await writeFile(path.join(tmp.path, "README.md"), "other content\n"); - await git(tmp.path, "add ."); - await git(tmp.path, "commit -m 'other change'"); - - // Go back to default branch - const defaultBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( - (b) => !b.current, - )!.name; - await checkoutGitBranch({ cwd: tmp.path, branch: defaultBranch }); - - // Make uncommitted changes to the same file - await writeFile(path.join(tmp.path, "README.md"), "conflicting local\n"); - - // Checkout should fail due to uncommitted changes - await expect(checkoutGitBranch({ cwd: tmp.path, branch: "other" })).rejects.toThrow(); - }); - }); - - // ── createGitBranch ── - - describe("createGitBranch", () => { - it("creates a new branch visible in listGitBranches", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - await createGitBranch({ cwd: tmp.path, branch: "new-feature" }); - - const result = await listGitBranches({ cwd: tmp.path }); - expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); - }); - - it("throws when branch already exists", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - await createGitBranch({ cwd: tmp.path, branch: "dupe" }); - await expect(createGitBranch({ cwd: tmp.path, branch: "dupe" })).rejects.toThrow(); - }); - }); - - // ── createGitWorktree + removeGitWorktree ── - - describe("createGitWorktree", () => { - it("creates a worktree with a new branch from the base branch", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - - const wtPath = path.join(tmp.path, "worktree-out"); - const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( - (b) => b.current, - )!.name; - - const result = await createGitWorktree({ - cwd: tmp.path, - branch: currentBranch, - newBranch: "wt-branch", - path: wtPath, - }); - - expect(result.worktree.path).toBe(wtPath); - expect(result.worktree.branch).toBe("wt-branch"); - expect(existsSync(wtPath)).toBe(true); - expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); - - // Clean up worktree before tmp dir disposal - await removeGitWorktree({ cwd: tmp.path, path: wtPath }); - }); - - it("worktree has the new branch checked out", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - - const wtPath = path.join(tmp.path, "wt-check-dir"); - const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( - (b) => b.current, - )!.name; - - await createGitWorktree({ - cwd: tmp.path, - branch: currentBranch, - newBranch: "wt-check", - path: wtPath, - }); - - // Verify the worktree is on the new branch - const branchOutput = await git(wtPath, "branch --show-current"); - expect(branchOutput).toBe("wt-check"); - - await removeGitWorktree({ cwd: tmp.path, path: wtPath }); - }); - - it("throws when new branch name already exists", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - await createGitBranch({ cwd: tmp.path, branch: "existing" }); - - const wtPath = path.join(tmp.path, "wt-conflict"); - const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( - (b) => b.current, - )!.name; - - await expect( - createGitWorktree({ - cwd: tmp.path, - branch: currentBranch, - newBranch: "existing", - path: wtPath, - }), - ).rejects.toThrow(); - }); - - it("listGitBranches from worktree cwd reports worktree branch as current", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - - const wtPath = path.join(tmp.path, "wt-list-dir"); - const mainBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( - (b) => b.current, - )!.name; - - await createGitWorktree({ - cwd: tmp.path, - branch: mainBranch, - newBranch: "wt-list", - path: wtPath, - }); - - // listGitBranches from the worktree should show wt-list as current - const wtBranches = await listGitBranches({ cwd: wtPath }); - expect(wtBranches.isRepo).toBe(true); - const wtCurrent = wtBranches.branches.find((b) => b.current); - expect(wtCurrent!.name).toBe("wt-list"); - - // Main repo should still show the original branch as current - const mainBranches = await listGitBranches({ cwd: tmp.path }); - const mainCurrent = mainBranches.branches.find((b) => b.current); - expect(mainCurrent!.name).toBe(mainBranch); - - await removeGitWorktree({ cwd: tmp.path, path: wtPath }); - }); - - it("removeGitWorktree cleans up the worktree", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - - const wtPath = path.join(tmp.path, "wt-remove-dir"); - const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( - (b) => b.current, - )!.name; - - await createGitWorktree({ - cwd: tmp.path, - branch: currentBranch, - newBranch: "wt-remove", - path: wtPath, - }); - expect(existsSync(wtPath)).toBe(true); - - await removeGitWorktree({ cwd: tmp.path, path: wtPath }); - expect(existsSync(wtPath)).toBe(false); - }); - - it("removeGitWorktree force removes a dirty worktree", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - - const wtPath = path.join(tmp.path, "wt-dirty-dir"); - const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( - (b) => b.current, - )!.name; - - await createGitWorktree({ - cwd: tmp.path, - branch: currentBranch, - newBranch: "wt-dirty", - path: wtPath, - }); - expect(existsSync(wtPath)).toBe(true); - - await writeFile(path.join(wtPath, "README.md"), "dirty change\n"); - - await expect(removeGitWorktree({ cwd: tmp.path, path: wtPath })).rejects.toThrow(); - expect(existsSync(wtPath)).toBe(true); - - await removeGitWorktree({ cwd: tmp.path, path: wtPath, force: true }); - expect(existsSync(wtPath)).toBe(false); - }); - }); - - // ── Full flow: local branch checkout ── - - describe("full flow: local branch checkout", () => { - it("init → commit → create branch → checkout → verify current", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - await createGitBranch({ cwd: tmp.path, branch: "feature-login" }); - await checkoutGitBranch({ cwd: tmp.path, branch: "feature-login" }); - - const result = await listGitBranches({ cwd: tmp.path }); - const current = result.branches.find((b) => b.current); - expect(current!.name).toBe("feature-login"); - }); - }); - - // ── Full flow: worktree creation from base branch ── - - describe("full flow: worktree creation", () => { - it("creates worktree with new branch from current branch", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - - const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( - (b) => b.current, - )!.name; - - const wtPath = path.join(tmp.path, "my-worktree"); - const result = await createGitWorktree({ - cwd: tmp.path, - branch: currentBranch, - newBranch: "feature-wt", - path: wtPath, - }); - - // Worktree exists - expect(existsSync(result.worktree.path)).toBe(true); - - // Main repo still on original branch - const mainBranches = await listGitBranches({ cwd: tmp.path }); - const mainCurrent = mainBranches.branches.find((b) => b.current); - expect(mainCurrent!.name).toBe(currentBranch); - - // Worktree is on the new branch - const wtBranch = await git(wtPath, "branch --show-current"); - expect(wtBranch).toBe("feature-wt"); - - await removeGitWorktree({ cwd: tmp.path, path: wtPath }); - }); - }); - - // ── Full flow: thread switching simulation ── - - describe("full flow: thread switching (checkout toggling)", () => { - it("checkout a → checkout b → checkout a → current matches", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - await createGitBranch({ cwd: tmp.path, branch: "branch-a" }); - await createGitBranch({ cwd: tmp.path, branch: "branch-b" }); - - // Simulate switching to thread A's branch - await checkoutGitBranch({ cwd: tmp.path, branch: "branch-a" }); - let branches = await listGitBranches({ cwd: tmp.path }); - expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); - - // Simulate switching to thread B's branch - await checkoutGitBranch({ cwd: tmp.path, branch: "branch-b" }); - branches = await listGitBranches({ cwd: tmp.path }); - expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b"); - - // Switch back to thread A - await checkoutGitBranch({ cwd: tmp.path, branch: "branch-a" }); - branches = await listGitBranches({ cwd: tmp.path }); - expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); - }); - }); - - // ── Full flow: checkout conflict ── - - describe("full flow: checkout conflict", () => { - it("uncommitted changes prevent checkout to a diverged branch", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - await createGitBranch({ cwd: tmp.path, branch: "diverged" }); - - // Make diverged branch have different file content - await checkoutGitBranch({ cwd: tmp.path, branch: "diverged" }); - await writeFile(path.join(tmp.path, "README.md"), "diverged content\n"); - await git(tmp.path, "add ."); - await git(tmp.path, "commit -m 'diverge'"); - - // Actually, let's just get back to the initial branch explicitly - const allBranches = await listGitBranches({ cwd: tmp.path }); - const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name; - await checkoutGitBranch({ cwd: tmp.path, branch: initialBranch }); - - // Make local uncommitted changes to the same file - await writeFile(path.join(tmp.path, "README.md"), "local uncommitted\n"); - - // Attempt checkout should fail - await expect(checkoutGitBranch({ cwd: tmp.path, branch: "diverged" })).rejects.toThrow(); - - // Current branch should still be the initial one - const result = await listGitBranches({ cwd: tmp.path }); - expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); - }); - }); - - describe("GitCoreService", () => { - it("supports branch lifecycle operations through the service API", async () => { - await using tmp = await makeTmpDir(); - const core = new GitCoreService(); - - await core.initRepo({ cwd: tmp.path }); - await git(tmp.path, "config user.email 'test@test.com'"); - await git(tmp.path, "config user.name 'Test'"); - await writeFile(path.join(tmp.path, "README.md"), "# test\n"); - await git(tmp.path, "add ."); - await git(tmp.path, "commit -m 'initial commit'"); - - await core.createBranch({ cwd: tmp.path, branch: "feature/service-api" }); - await core.checkoutBranch({ cwd: tmp.path, branch: "feature/service-api" }); - const branches = await core.listBranches({ cwd: tmp.path }); - - expect(branches.isRepo).toBe(true); - expect(branches.branches.find((branch) => branch.current)?.name).toBe("feature/service-api"); - }); - - it("reports status details and dirty state", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - const core = new GitCoreService(); - - const clean = await core.status({ cwd: tmp.path }); - expect(clean.hasWorkingTreeChanges).toBe(false); - expect(clean.branch).toBeTruthy(); - - await writeFile(path.join(tmp.path, "README.md"), "updated\n"); - const dirty = await core.statusDetails(tmp.path); - expect(dirty.hasWorkingTreeChanges).toBe(true); - }); - - it("includes command context when worktree removal fails", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - const core = new GitCoreService(); - const missingWorktreePath = path.join(tmp.path, "missing-worktree"); - - const error = await core - .removeWorktree({ cwd: tmp.path, path: missingWorktreePath }) - .then(() => null) - .catch((err: unknown) => err); - - expect(error).toBeInstanceOf(Error); - const message = (error as Error).message; - expect(message).toContain("git worktree remove"); - expect(message).toContain(`cwd: ${tmp.path}`); - expect(message).toContain(missingWorktreePath); - }); - - it("refreshes upstream before statusDetails so behind count reflects remote updates", async () => { - await using remote = await makeTmpDir(); - await using source = await makeTmpDir(); - await using clone = await makeTmpDir(); - await git(remote.path, "init --bare"); - - await initRepoWithCommit(source.path); - const initialBranch = (await listGitBranches({ cwd: source.path })).branches.find( - (branch) => branch.current, - )!.name; - await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); - await git(source.path, `push -u origin ${initialBranch}`); - - await git(clone.path, `clone ${JSON.stringify(remote.path)} .`); - await git(clone.path, "config user.email 'test@test.com'"); - await git(clone.path, "config user.name 'Test'"); - await git(clone.path, `checkout -B ${initialBranch} --track origin/${initialBranch}`); - await writeFile(path.join(clone.path, "CHANGELOG.md"), "remote change\n"); - await git(clone.path, "add CHANGELOG.md"); - await git(clone.path, "commit -m 'remote update'"); - await git(clone.path, `push origin ${initialBranch}`); - - const core = new GitCoreService(); - const details = await core.statusDetails(source.path); - expect(details.branch).toBe(initialBranch); - expect(details.aheadCount).toBe(0); - expect(details.behindCount).toBe(1); - }); - - it("prepares commit context by auto-staging and creates commit", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - const core = new GitCoreService(); - - await writeFile(path.join(tmp.path, "README.md"), "new content\n"); - const context = await core.prepareCommitContext(tmp.path); - expect(context).not.toBeNull(); - expect(context!.stagedSummary.length).toBeGreaterThan(0); - expect(context!.stagedPatch.length).toBeGreaterThan(0); - - const created = await core.commit(tmp.path, "Add README update", "- include updated content"); - expect(created.commitSha.length).toBeGreaterThan(0); - expect(await git(tmp.path, "log -1 --pretty=%s")).toBe("Add README update"); - }); - - it("pushes with upstream setup and then skips when up to date", async () => { - await using tmp = await makeTmpDir(); - await using remote = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - await git(remote.path, "init --bare"); - await git(tmp.path, `remote add origin ${JSON.stringify(remote.path)}`); - await createGitBranch({ cwd: tmp.path, branch: "feature/core-push" }); - await checkoutGitBranch({ cwd: tmp.path, branch: "feature/core-push" }); - - await writeFile(path.join(tmp.path, "feature.txt"), "push me\n"); - const core = new GitCoreService(); - const context = await core.prepareCommitContext(tmp.path); - expect(context).not.toBeNull(); - await core.commit(tmp.path, "Add feature file", ""); - - const pushed = await core.pushCurrentBranch(tmp.path, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(true); - expect(await git(tmp.path, "rev-parse --abbrev-ref @{upstream}")).toBe( - "origin/feature/core-push", - ); - - const skipped = await core.pushCurrentBranch(tmp.path, null); - expect(skipped.status).toBe("skipped_up_to_date"); - }); - - it("pulls behind branch and then reports up-to-date", async () => { - await using remote = await makeTmpDir(); - await using source = await makeTmpDir(); - await using clone = await makeTmpDir(); - await git(remote.path, "init --bare"); - - await initRepoWithCommit(source.path); - const initialBranch = (await listGitBranches({ cwd: source.path })).branches.find( - (branch) => branch.current, - )!.name; - await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); - await git(source.path, `push -u origin ${initialBranch}`); - - await git(clone.path, `clone ${JSON.stringify(remote.path)} .`); - await git(clone.path, "config user.email 'test@test.com'"); - await git(clone.path, "config user.name 'Test'"); - await writeFile(path.join(clone.path, "CHANGELOG.md"), "remote change\n"); - await git(clone.path, "add CHANGELOG.md"); - await git(clone.path, "commit -m 'remote update'"); - await git(clone.path, `push origin ${initialBranch}`); - - const core = new GitCoreService(); - const pulled = await core.pullCurrentBranch(source.path); - expect(pulled.status).toBe("pulled"); - expect((await core.statusDetails(source.path)).behindCount).toBe(0); - - const skipped = await core.pullCurrentBranch(source.path); - expect(skipped.status).toBe("skipped_up_to_date"); - }); - - it("top-level pullGitBranch rejects when no upstream exists", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - await expect(pullGitBranch({ cwd: tmp.path })).rejects.toThrow("no upstream"); - }); - - it("lists branches when recency lookup fails", async () => { - await using tmp = await makeTmpDir(); - await initRepoWithCommit(tmp.path); - const core = new GitCoreService(); - const recencySpy = vi - .spyOn(core as any, "readBranchRecency") - .mockRejectedValueOnce(new Error("timeout")); - - const result = await core.listBranches({ cwd: tmp.path }); - - expect(result.isRepo).toBe(true); - expect(result.branches.length).toBeGreaterThan(0); - expect(result.branches[0]?.current).toBe(true); - recencySpy.mockRestore(); - }); - }); -}); diff --git a/apps/server/src/git.ts b/apps/server/src/git.ts deleted file mode 100644 index 908088112c6773376238ac06993c5f5877796c61..0000000000000000000000000000000000000000 --- a/apps/server/src/git.ts +++ /dev/null @@ -1,730 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { - GitPullInput, - GitPullResult, - GitStatusInput, - GitStatusResult, - type GitCheckoutInput, - type GitCreateBranchInput, - type GitCreateWorktreeInput, - type GitCreateWorktreeResult, - type GitInitInput, - type GitListBranchesInput, - type GitListBranchesResult, - type GitRemoveWorktreeInput, -} from "@t3tools/contracts"; -import { type ProcessRunResult, runProcess } from "./processRunner"; - -export interface TerminalCommandInput { - command: string; - cwd: string; - timeoutMs?: number; - maxOutputBytes?: number; -} - -export interface GitStatusDetails extends Omit { - upstreamRef: string | null; -} - -export interface GitPreparedCommitContext { - stagedSummary: string; - stagedPatch: string; -} - -export interface GitPushResult { - status: "pushed" | "skipped_up_to_date"; - branch: string; - upstreamBranch?: string | undefined; - setUpstream?: boolean | undefined; -} - -export interface GitRangeContext { - commitSummary: string; - diffSummary: string; - diffPatch: string; -} - -interface RunGitOptions { - timeoutMs?: number | undefined; - allowNonZeroExit?: boolean | undefined; -} - -interface ExecuteGitOptions { - timeoutMs?: number | undefined; - allowNonZeroExit?: boolean | undefined; - fallbackErrorMessage?: string | undefined; -} - -const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; -const STATUS_UPSTREAM_REFRESH_INTERVAL_MS = 15_000; -const STATUS_UPSTREAM_REFRESH_TIMEOUT_MS = 5_000; - -/** Spawn git directly with an argv array — no shell, no quoting needed. */ -function runGit( - args: readonly string[], - cwd: string, - timeoutMs = 30_000, -): Promise { - return runProcess("git", args, { - cwd, - timeoutMs, - allowNonZeroExit: true, - maxBufferBytes: DEFAULT_MAX_OUTPUT_BYTES, - outputMode: "truncate", - }); -} - -function trimStdout(value: string): string { - return value.trim(); -} - -function parseBranchAb(value: string): { ahead: number; behind: number } { - const match = value.match(/^\+(\d+)\s+-(\d+)$/); - if (!match) return { ahead: 0, behind: 0 }; - return { - ahead: Number(match[1] ?? "0"), - behind: Number(match[2] ?? "0"), - }; -} - -function parseNumstatEntries( - stdout: string, -): Array<{ path: string; insertions: number; deletions: number }> { - const entries: Array<{ path: string; insertions: number; deletions: number }> = []; - for (const line of stdout.split(/\r?\n/g)) { - if (line.trim().length === 0) continue; - const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); - const rawPath = - pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); - if (rawPath.length === 0) continue; - const added = Number.parseInt(addedRaw ?? "0", 10); - const deleted = Number.parseInt(deletedRaw ?? "0", 10); - const renameArrowIndex = rawPath.indexOf(" => "); - const normalizedPath = - renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; - entries.push({ - path: normalizedPath.length > 0 ? normalizedPath : rawPath, - insertions: Number.isFinite(added) ? added : 0, - deletions: Number.isFinite(deleted) ? deleted : 0, - }); - } - return entries; -} - -function parsePorcelainPath(line: string): string | null { - if (line.startsWith("? ") || line.startsWith("! ")) { - const simple = line.slice(2).trim(); - return simple.length > 0 ? simple : null; - } - - if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { - return null; - } - - const tabIndex = line.indexOf("\t"); - if (tabIndex >= 0) { - const fromTab = line.slice(tabIndex + 1); - const [path] = fromTab.split("\t"); - return path?.trim().length ? path.trim() : null; - } - - const parts = line.trim().split(/\s+/g); - const path = parts.at(-1) ?? ""; - return path.length > 0 ? path : null; -} - -function commandLabel(args: readonly string[]): string { - return `git ${args.join(" ")}`; -} - -function normalizeGitSpawnError(cwd: string, error: unknown): Error { - if (!(error instanceof Error)) { - return new Error("Failed to run git command."); - } - if (!fs.existsSync(cwd)) { - return new Error(`Working directory does not exist: ${cwd}`); - } - if (error.message.includes("Command not found: git")) { - return new Error("Git is required but not available on PATH."); - } - return new Error(`Failed to run git command: ${error.message}`); -} - -function normalizeGitExecutionError(args: readonly string[], result: ProcessRunResult): Error { - const stderr = result.stderr.trim(); - if (stderr.toLowerCase().includes("not a git repository")) { - return new Error("Current folder is not a git repository."); - } - if (result.timedOut) { - return new Error(`${commandLabel(args)} timed out.`); - } - const detail = - stderr.length > 0 ? stderr : `code=${result.code ?? "null"}, signal=${result.signal ?? "null"}`; - return new Error(`${commandLabel(args)} failed: ${detail}`); -} - -async function runGitOrThrow( - cwd: string, - args: readonly string[], - options: RunGitOptions = {}, -): Promise { - let result: ProcessRunResult; - try { - result = await runGit(args, cwd, options.timeoutMs); - } catch (error) { - throw normalizeGitSpawnError(cwd, error); - } - - if (result.timedOut) { - throw normalizeGitExecutionError(args, result); - } - if (!options.allowNonZeroExit && result.code !== 0) { - throw normalizeGitExecutionError(args, result); - } - return result; -} - -async function executeGit( - cwd: string, - args: readonly string[], - options: ExecuteGitOptions = {}, -): Promise { - const result = await runGitOrThrow(cwd, args, { - timeoutMs: options.timeoutMs, - allowNonZeroExit: true, - }); - - if (options.allowNonZeroExit || result.code === 0) { - return result; - } - - const stderr = result.stderr.trim(); - if (stderr.length > 0) { - throw new Error(stderr); - } - if (options.fallbackErrorMessage) { - throw new Error(options.fallbackErrorMessage); - } - throw normalizeGitExecutionError(args, result); -} - -export class GitCoreService { - private readonly lastUpstreamRefreshAt = new Map(); - - async status(input: GitStatusInput): Promise { - const details = await this.statusDetails(input.cwd); - return { - branch: details.branch, - hasWorkingTreeChanges: details.hasWorkingTreeChanges, - workingTree: details.workingTree, - hasUpstream: details.hasUpstream, - aheadCount: details.aheadCount, - behindCount: details.behindCount, - openPr: null, - }; - } - - async statusDetails(cwd: string): Promise { - await this.refreshStatusUpstreamIfStale(cwd).catch(() => undefined); - - const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = await Promise.all([ - this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]), - this.gitStdout(cwd, ["diff", "--numstat"]), - this.gitStdout(cwd, ["diff", "--cached", "--numstat"]), - ]); - - let branch: string | null = null; - let upstreamRef: string | null = null; - let aheadCount = 0; - let behindCount = 0; - let hasWorkingTreeChanges = false; - const changedFilesWithoutNumstat = new Set(); - - for (const line of statusStdout.split(/\r?\n/g)) { - if (line.startsWith("# branch.head ")) { - const value = line.slice("# branch.head ".length).trim(); - branch = value.startsWith("(") ? null : value; - continue; - } - if (line.startsWith("# branch.upstream ")) { - const value = line.slice("# branch.upstream ".length).trim(); - upstreamRef = value.length > 0 ? value : null; - continue; - } - if (line.startsWith("# branch.ab ")) { - const value = line.slice("# branch.ab ".length).trim(); - const parsed = parseBranchAb(value); - aheadCount = parsed.ahead; - behindCount = parsed.behind; - continue; - } - if (line.trim().length > 0 && !line.startsWith("#")) { - hasWorkingTreeChanges = true; - const pathValue = parsePorcelainPath(line); - if (pathValue) changedFilesWithoutNumstat.add(pathValue); - } - } - const stagedEntries = parseNumstatEntries(stagedNumstatStdout); - const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); - const fileStatMap = new Map(); - for (const entry of [...stagedEntries, ...unstagedEntries]) { - const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; - existing.insertions += entry.insertions; - existing.deletions += entry.deletions; - fileStatMap.set(entry.path, existing); - } - - let insertions = 0; - let deletions = 0; - const files = Array.from(fileStatMap.entries()) - .map(([path, stat]) => { - insertions += stat.insertions; - deletions += stat.deletions; - return { path, insertions: stat.insertions, deletions: stat.deletions }; - }) - .toSorted((a, b) => a.path.localeCompare(b.path)); - - for (const filePath of changedFilesWithoutNumstat) { - if (fileStatMap.has(filePath)) continue; - files.push({ path: filePath, insertions: 0, deletions: 0 }); - } - files.sort((a, b) => a.path.localeCompare(b.path)); - - return { - branch, - upstreamRef, - hasWorkingTreeChanges, - workingTree: { - files, - insertions, - deletions, - }, - hasUpstream: upstreamRef !== null, - aheadCount, - behindCount, - }; - } - - async prepareCommitContext(cwd: string): Promise { - await this.git(cwd, ["add", "-A"]); - - const stagedSummary = await this.gitStdout(cwd, ["diff", "--cached", "--name-status"]); - if (trimStdout(stagedSummary).length === 0) { - return null; - } - - const stagedPatch = await this.gitStdout(cwd, ["diff", "--cached", "--patch", "--minimal"]); - - return { - stagedSummary, - stagedPatch, - }; - } - - async commit(cwd: string, subject: string, body: string): Promise<{ commitSha: string }> { - const args = ["commit", "-m", subject]; - const trimmedBody = body.trim(); - if (trimmedBody.length > 0) { - args.push("-m", trimmedBody); - } - await this.git(cwd, args); - const commitSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"])); - return { commitSha }; - } - - async pushCurrentBranch(cwd: string, fallbackBranch: string | null): Promise { - const details = await this.statusDetails(cwd); - const branch = details.branch ?? fallbackBranch; - if (!branch) { - throw new Error("Cannot push from detached HEAD."); - } - - if (details.hasUpstream && details.aheadCount === 0 && details.behindCount === 0) { - return { - status: "skipped_up_to_date", - branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), - }; - } - - if (!details.hasUpstream) { - await this.git(cwd, ["push", "-u", "origin", branch]); - return { - status: "pushed", - branch, - upstreamBranch: `origin/${branch}`, - setUpstream: true, - }; - } - - await this.git(cwd, ["push"]); - return { - status: "pushed", - branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), - setUpstream: false, - }; - } - - async pullCurrentBranch(cwd: string): Promise { - const details = await this.statusDetails(cwd); - const branch = details.branch; - if (!branch) { - throw new Error("Cannot pull from detached HEAD."); - } - if (!details.hasUpstream) { - throw new Error("Current branch has no upstream configured. Push with upstream first."); - } - const beforeSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); - await executeGit(cwd, ["pull", "--ff-only"], { - timeoutMs: 30_000, - fallbackErrorMessage: "git pull failed", - }); - const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); - const refreshed = await this.statusDetails(cwd); - return { - status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", - branch, - upstreamBranch: refreshed.upstreamRef, - }; - } - - async readRangeContext(cwd: string, baseBranch: string): Promise { - const range = `${baseBranch}..HEAD`; - const [commitSummary, diffSummary, diffPatch] = await Promise.all([ - this.gitStdout(cwd, ["log", "--oneline", range]), - this.gitStdout(cwd, ["diff", "--stat", range]), - this.gitStdout(cwd, ["diff", "--patch", "--minimal", range]), - ]); - - return { - commitSummary, - diffSummary, - diffPatch, - }; - } - - async readConfigValue(cwd: string, key: string): Promise { - const stdout = await this.gitStdout(cwd, ["config", "--get", key], true); - const value = trimStdout(stdout); - return value.length > 0 ? value : null; - } - - private async readBranchRecency(cwd: string): Promise> { - const branchRecency = await executeGit( - cwd, - ["for-each-ref", "--format=%(refname:short)%09%(committerdate:unix)", "refs/heads"], - { - timeoutMs: 15_000, - allowNonZeroExit: true, - }, - ); - - const branchLastCommit = new Map(); - if (branchRecency.code !== 0) { - return branchLastCommit; - } - - for (const line of branchRecency.stdout.split("\n")) { - if (line.length === 0) { - continue; - } - const [name, lastCommitRaw] = line.split("\t"); - if (!name) { - continue; - } - const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); - branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); - } - - return branchLastCommit; - } - - async listBranches(input: GitListBranchesInput): Promise { - const branchRecencyPromise = this.readBranchRecency(input.cwd).catch( - () => new Map(), - ); - const result = await executeGit(input.cwd, ["branch", "--no-color"], { - timeoutMs: 10_000, - allowNonZeroExit: true, - }); - - if (result.code !== 0) { - const stderr = result.stderr.trim(); - if (stderr.toLowerCase().includes("not a git repository")) { - return { branches: [], isRepo: false }; - } - throw new Error(stderr || "git branch failed"); - } - - const [defaultRef, worktreeList, branchLastCommit] = await Promise.all([ - executeGit(input.cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], { - timeoutMs: 5_000, - allowNonZeroExit: true, - }), - executeGit(input.cwd, ["worktree", "list", "--porcelain"], { - timeoutMs: 5_000, - allowNonZeroExit: true, - }), - branchRecencyPromise, - ]); - const defaultBranch = - defaultRef.code === 0 - ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") - : null; - - const worktreeMap = new Map(); - if (worktreeList.code === 0) { - let currentPath: string | null = null; - for (const line of worktreeList.stdout.split("\n")) { - if (line.startsWith("worktree ")) { - const candidatePath = line.slice("worktree ".length); - currentPath = fs.existsSync(candidatePath) ? candidatePath : null; - } else if (line.startsWith("branch refs/heads/") && currentPath) { - worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); - } else if (line === "") { - currentPath = null; - } - } - } - - const branches = result.stdout - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => { - const name = line.replace(/^[*+]\s+/, ""); - return { - name, - current: line.startsWith("* "), - isDefault: name === defaultBranch, - worktreePath: worktreeMap.get(name) ?? null, - }; - }) - .toSorted((a, b) => { - const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; - const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; - if (aPriority !== bPriority) return aPriority - bPriority; - - const aLastCommit = branchLastCommit.get(a.name) ?? 0; - const bLastCommit = branchLastCommit.get(b.name) ?? 0; - if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; - return a.name.localeCompare(b.name); - }); - - return { branches, isRepo: true }; - } - - async createWorktree(input: GitCreateWorktreeInput): Promise { - const sanitizedBranch = input.newBranch.replace(/\//g, "-"); - const repoName = path.basename(input.cwd); - const worktreePath = - input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch); - - await executeGit( - input.cwd, - ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch], - { fallbackErrorMessage: "git worktree add failed" }, - ); - - return { - worktree: { - path: worktreePath, - branch: input.newBranch, - }, - }; - } - - async removeWorktree(input: GitRemoveWorktreeInput): Promise { - const args = ["worktree", "remove"]; - if (input.force) { - args.push("--force"); - } - args.push(input.path); - try { - await executeGit(input.cwd, args, { - timeoutMs: 15_000, - fallbackErrorMessage: "git worktree remove failed", - }); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`${commandLabel(args)} failed (cwd: ${input.cwd}): ${detail}`, { - cause: error, - }); - } - } - - async createBranch(input: GitCreateBranchInput): Promise { - await executeGit(input.cwd, ["branch", input.branch], { - timeoutMs: 10_000, - fallbackErrorMessage: "git branch create failed", - }); - } - - private async resolveCurrentUpstream( - cwd: string, - ): Promise<{ upstreamRef: string; remoteName: string; upstreamBranch: string } | null> { - const upstreamRef = trimStdout( - await this.gitStdout( - cwd, - ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], - true, - ), - ); - if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { - return null; - } - - const separatorIndex = upstreamRef.indexOf("/"); - if (separatorIndex <= 0) { - return null; - } - const remoteName = upstreamRef.slice(0, separatorIndex); - const upstreamBranch = upstreamRef.slice(separatorIndex + 1); - if (remoteName.length === 0) { - return null; - } - if (upstreamBranch.length === 0) { - return null; - } - - return { - upstreamRef, - remoteName, - upstreamBranch, - }; - } - - private async fetchUpstreamRef( - cwd: string, - upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, - ): Promise { - const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; - await this.git(cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], true); - } - - private async fetchUpstreamRefForStatus( - cwd: string, - upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, - ): Promise { - const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; - await executeGit(cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], { - allowNonZeroExit: true, - timeoutMs: STATUS_UPSTREAM_REFRESH_TIMEOUT_MS, - }); - } - - private async refreshStatusUpstreamIfStale(cwd: string): Promise { - const upstream = await this.resolveCurrentUpstream(cwd); - if (!upstream) { - return; - } - - const cacheKey = `${cwd}\u0000${upstream.upstreamRef}`; - const now = Date.now(); - const lastRefreshAt = this.lastUpstreamRefreshAt.get(cacheKey) ?? 0; - if (now - lastRefreshAt < STATUS_UPSTREAM_REFRESH_INTERVAL_MS) { - return; - } - - // Record attempt time first to avoid repeated fetch spikes when status is polled often. - this.lastUpstreamRefreshAt.set(cacheKey, now); - await this.fetchUpstreamRefForStatus(cwd, upstream); - } - - private async refreshCheckedOutBranchUpstream(cwd: string): Promise { - const upstream = await this.resolveCurrentUpstream(cwd); - if (!upstream) { - return; - } - await this.fetchUpstreamRef(cwd, upstream); - } - - async checkoutBranch(input: GitCheckoutInput): Promise { - await executeGit(input.cwd, ["checkout", input.branch], { - timeoutMs: 10_000, - fallbackErrorMessage: "git checkout failed", - }); - // Refresh upstream refs in the background so checkout remains responsive. - void this.refreshCheckedOutBranchUpstream(input.cwd).catch(() => undefined); - } - - async initRepo(input: GitInitInput): Promise { - await executeGit(input.cwd, ["init"], { - timeoutMs: 10_000, - fallbackErrorMessage: "git init failed", - }); - } - - async git(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise { - await executeGit(cwd, args, { allowNonZeroExit }); - } - - async gitStdout(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise { - const result = await executeGit(cwd, args, { allowNonZeroExit }); - return result.stdout; - } -} - -const defaultGitCoreService = new GitCoreService(); - -export async function runTerminalCommand(input: TerminalCommandInput): Promise { - const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; - const shellPath = - process.platform === "win32" - ? (process.env.ComSpec ?? "cmd.exe") - : (process.env.SHELL ?? "/bin/sh"); - - const args = - process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command]; - - const result = await runProcess(shellPath, args, { - cwd: input.cwd, - timeoutMs: input.timeoutMs ?? 30_000, - allowNonZeroExit: true, - maxBufferBytes: maxOutputBytes, - outputMode: "truncate", - }); - - if (!result.stdoutTruncated && !result.stderrTruncated) { - return result; - } - - return { - ...result, - stderr: `${result.stderr}\n[output truncated at ${maxOutputBytes} bytes]`, - }; -} - -export async function listGitBranches(input: GitListBranchesInput): Promise { - return defaultGitCoreService.listBranches(input); -} - -export async function pullGitBranch(input: GitPullInput): Promise { - return defaultGitCoreService.pullCurrentBranch(input.cwd); -} - -export async function createGitWorktree( - input: GitCreateWorktreeInput, -): Promise { - return defaultGitCoreService.createWorktree(input); -} - -export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise { - await defaultGitCoreService.removeWorktree(input); -} - -export async function createGitBranch(input: GitCreateBranchInput): Promise { - await defaultGitCoreService.createBranch(input); -} - -export async function checkoutGitBranch(input: GitCheckoutInput): Promise { - await defaultGitCoreService.checkoutBranch(input); -} - -export async function initGitRepo(input: GitInitInput): Promise { - await defaultGitCoreService.initRepo(input); -} diff --git a/apps/server/src/git/Errors.ts b/apps/server/src/git/Errors.ts index 9e2ee14f72826d8c5fa915aa37d30900e634cf5e..106c0ab45383e555bd35e1d2d3e0fa6854993d6a 100644 --- a/apps/server/src/git/Errors.ts +++ b/apps/server/src/git/Errors.ts @@ -14,3 +14,69 @@ export class GitCommandError extends Schema.TaggedErrorClass()( return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; } } + +/** + * GitHubCliError - GitHub CLI execution or authentication failed. + */ +export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) { + override get message(): string { + return `GitHub CLI failed in ${this.operation}: ${this.detail}`; + } +} + +/** + * TextGenerationError - Commit or PR text generation failed. + */ +export class TextGenerationError extends Schema.TaggedErrorClass()( + "TextGenerationError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Text generation failed in ${this.operation}: ${this.detail}`; + } +} + +/** + * GitManagerError - Stacked Git workflow orchestration failed. + */ +export class GitManagerError extends Schema.TaggedErrorClass()("GitManagerError", { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) { + override get message(): string { + return `Git manager failed in ${this.operation}: ${this.detail}`; + } +} + +/** + * GitCoreError - Errors emitted by low-level Git repository operations. + */ +export type GitCoreError = GitCommandError; + +/** + * GitHubCliServiceError - Errors emitted by GitHub CLI service helpers. + */ +export type GitHubCliServiceError = GitHubCliError; + +/** + * TextGenerationServiceError - Errors emitted by commit/PR text generation. + */ +export type TextGenerationServiceError = TextGenerationError; + +/** + * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration. + */ +export type GitManagerServiceError = + | GitManagerError + | GitCoreError + | GitHubCliServiceError + | TextGenerationServiceError; diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bba843110caecd63af3198c06e22e5625dffe973 --- /dev/null +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -0,0 +1,205 @@ +import { NodeServices } from "@effect/platform-node"; +import { it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; +import { expect } from "vitest"; + +import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; +import { TextGenerationError } from "../Errors.ts"; +import { TextGeneration } from "../Services/TextGeneration.ts"; + +function makeFakeCodexBinary(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = path.join(dir, "bin"); + const codexPath = path.join(binDir, "codex"); + yield* fs.makeDirectory(binDir, { recursive: true }); + + yield* fs.writeFileString( + codexPath, + [ + "#!/bin/sh", + 'output_path=""', + "while [ $# -gt 0 ]; do", + ' if [ "$1" = "--output-last-message" ]; then', + " shift", + ' output_path="$1"', + " fi", + " shift", + "done", + "cat >/dev/null", + 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then', + ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2', + "fi", + 'if [ -n "$output_path" ]; then', + ' node -e \'const fs=require("node:fs"); const value=process.argv[2] ?? ""; fs.writeFileSync(process.argv[1], Buffer.from(value, "base64"));\' "$output_path" "${T3_FAKE_CODEX_OUTPUT_B64:-e30=}"', + "fi", + 'exit "${T3_FAKE_CODEX_EXIT_CODE:-0}"', + "", + ].join("\n"), + ); + yield* fs.chmod(codexPath, 0o755); + return binDir; + }); +} + +function withFakeCodexEnv( + input: { + output: string; + exitCode?: number; + stderr?: string; + }, + effect: Effect.Effect, +) { + return Effect.acquireUseRelease( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); + const binDir = yield* makeFakeCodexBinary(tempDir); + const previousPath = process.env.PATH; + const previousOutput = process.env.T3_FAKE_CODEX_OUTPUT_B64; + const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE; + const previousStderr = process.env.T3_FAKE_CODEX_STDERR; + + yield* Effect.sync(() => { + process.env.PATH = `${binDir}:${previousPath ?? ""}`; + process.env.T3_FAKE_CODEX_OUTPUT_B64 = Buffer.from(input.output, "utf8").toString("base64"); + + if (input.exitCode !== undefined) { + process.env.T3_FAKE_CODEX_EXIT_CODE = String(input.exitCode); + } else { + delete process.env.T3_FAKE_CODEX_EXIT_CODE; + } + + if (input.stderr !== undefined) { + process.env.T3_FAKE_CODEX_STDERR = input.stderr; + } else { + delete process.env.T3_FAKE_CODEX_STDERR; + } + }); + + return { + previousPath, + previousOutput, + previousExitCode, + previousStderr, + }; + }), + () => effect, + (previous) => + Effect.sync(() => { + process.env.PATH = previous.previousPath; + + if (previous.previousOutput === undefined) { + delete process.env.T3_FAKE_CODEX_OUTPUT_B64; + } else { + process.env.T3_FAKE_CODEX_OUTPUT_B64 = previous.previousOutput; + } + + if (previous.previousExitCode === undefined) { + delete process.env.T3_FAKE_CODEX_EXIT_CODE; + } else { + process.env.T3_FAKE_CODEX_EXIT_CODE = previous.previousExitCode; + } + + if (previous.previousStderr === undefined) { + delete process.env.T3_FAKE_CODEX_STDERR; + } else { + process.env.T3_FAKE_CODEX_STDERR = previous.previousStderr; + } + }), + ); +} + +const CodexTextGenerationTestLayer = Layer.provideMerge( + CodexTextGenerationLive, + NodeServices.layer, +); + +it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { + it.effect("generates and sanitizes commit messages", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: + " Add important change to the system with too much detail and a trailing period.\nsecondary line", + body: "\n- added migration\n- updated tests\n", + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + }); + + expect(generated.subject.length).toBeLessThanOrEqual(72); + expect(generated.subject.endsWith(".")).toBe(false); + expect(generated.body).toBe("- added migration\n- updated tests"); + }), + ), + ); + + it.effect("generates PR content and trims markdown body", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: " Improve orchestration flow\nwith ignored suffix", + body: "\n## Summary\n- improve flow\n\n## Testing\n- bun test\n\n", + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "feature/codex-effect", + commitSummary: "feat: improve orchestration flow", + diffSummary: "2 files changed", + diffPatch: "diff --git a/a.ts b/a.ts", + }); + + expect(generated.title).toBe("Improve orchestration flow"); + expect(generated.body.startsWith("## Summary")).toBe(true); + expect(generated.body.endsWith("\n\n")).toBe(false); + }), + ), + ); + + it.effect("returns typed TextGenerationError when codex exits non-zero", () => + withFakeCodexEnv( + { + output: JSON.stringify({ subject: "ignored", body: "" }), + exitCode: 1, + stderr: "codex execution failed", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const result = yield* textGeneration + .generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-error", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + }) + .pipe( + Effect.match({ + onFailure: (error) => ({ _tag: "Left" as const, left: error }), + onSuccess: (value) => ({ _tag: "Right" as const, right: value }), + }), + ); + + expect(result._tag).toBe("Left"); + if (result._tag === "Left") { + expect(result.left).toBeInstanceOf(TextGenerationError); + expect(result.left.message).toContain("Codex CLI command failed: codex execution failed"); + } + }), + ), + ); +}); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a0fdc5805ca3c523470f0f5104032e1c627ebe6 --- /dev/null +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -0,0 +1,393 @@ +import { randomUUID } from "node:crypto"; + +import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { TextGenerationError } from "../Errors.ts"; +import { + type CommitMessageGenerationResult, + type PrContentGenerationResult, + type TextGenerationShape, + TextGeneration, +} from "../Services/TextGeneration.ts"; + +const CODEX_MODEL = "gpt-5.3-codex"; +const CODEX_REASONING_EFFORT = "low"; +const CODEX_TIMEOUT_MS = 180_000; + +const COMMIT_OUTPUT_SCHEMA_JSON = { + type: "object", + properties: { + subject: { type: "string" }, + body: { type: "string" }, + }, + required: ["subject", "body"], + additionalProperties: false, +} as const; + +const PR_OUTPUT_SCHEMA_JSON = { + type: "object", + properties: { + title: { type: "string" }, + body: { type: "string" }, + }, + required: ["title", "body"], + additionalProperties: false, +} as const; + +function normalizeCodexError( + operation: string, + error: unknown, + fallback: string, +): TextGenerationError { + if (error instanceof TextGenerationError) { + return error; + } + + if (error instanceof Error) { + const lower = error.message.toLowerCase(); + if ( + error.message.includes("Command not found: codex") || + lower.includes("spawn codex") || + lower.includes("enoent") + ) { + return new TextGenerationError({ + operation, + detail: "Codex CLI (`codex`) is required but not available on PATH.", + cause: error, + }); + } + return new TextGenerationError({ + operation, + detail: `${fallback}: ${error.message}`, + cause: error, + }); + } + + return new TextGenerationError({ + operation, + detail: fallback, + cause: error, + }); +} + +function parseCommitOutput(raw: unknown): { subject: string; body: string } { + if (!raw || typeof raw !== "object") { + throw new Error("Codex returned a non-object commit message payload."); + } + const record = raw as Record; + const subject = typeof record.subject === "string" ? record.subject.trim() : ""; + const body = typeof record.body === "string" ? record.body : ""; + if (subject.length === 0) { + throw new Error("Codex returned an empty commit subject."); + } + return { subject, body }; +} + +function parsePrOutput(raw: unknown): { title: string; body: string } { + if (!raw || typeof raw !== "object") { + throw new Error("Codex returned a non-object PR payload."); + } + const record = raw as Record; + const title = typeof record.title === "string" ? record.title.trim() : ""; + const body = typeof record.body === "string" ? record.body.trim() : ""; + if (title.length === 0 || body.length === 0) { + throw new Error("Codex returned an invalid PR title/body payload."); + } + return { title, body }; +} + +function limitSection(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + const truncated = value.slice(0, maxChars); + return `${truncated}\n\n[truncated]`; +} + +function sanitizeCommitSubject(raw: string): string { + const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); + if (withoutTrailingPeriod.length === 0) { + return "Update project files"; + } + + if (withoutTrailingPeriod.length <= 72) { + return withoutTrailingPeriod; + } + return withoutTrailingPeriod.slice(0, 72).trimEnd(); +} + +function sanitizePrTitle(raw: string): string { + const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + if (singleLine.length > 0) { + return singleLine; + } + return "Update project changes"; +} + +const makeCodexTextGeneration = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const readStreamAsString = ( + operation: string, + stream: Stream.Stream, + ): Effect.Effect => + Effect.gen(function* () { + let text = ""; + yield* Stream.runForEach(stream, (chunk) => + Effect.sync(() => { + text += Buffer.from(chunk).toString("utf8"); + }), + ).pipe( + Effect.mapError((cause) => + normalizeCodexError(operation, cause, "Failed to collect process output"), + ), + ); + return text; + }); + + const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; + + const writeTempFile = ( + operation: string, + prefix: string, + content: string, + ): Effect.Effect => { + const filePath = path.join(tempDir, `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`); + return fileSystem.writeFileString(filePath, content).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: `Failed to write temp file at ${filePath}.`, + cause, + }), + ), + Effect.as(filePath), + ); + }; + + const safeUnlink = (filePath: string): Effect.Effect => + fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); + + const runCodexJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + parse, + }: { + operation: "generateCommitMessage" | "generatePrContent"; + cwd: string; + prompt: string; + outputSchemaJson: object; + parse: (raw: unknown) => T; + }): Effect.Effect => + Effect.gen(function* () { + const schemaPath = yield* writeTempFile( + operation, + "codex-schema", + JSON.stringify(outputSchemaJson), + ); + const outputPath = yield* writeTempFile(operation, "codex-output", ""); + + const runCodexCommand = Effect.gen(function* () { + const command = ChildProcess.make( + "codex", + [ + "exec", + "--ephemeral", + "-s", + "read-only", + "--model", + CODEX_MODEL, + "--config", + `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`, + "--output-schema", + schemaPath, + "--output-last-message", + outputPath, + "-", + ], + { + cwd, + stdin: { + stream: Stream.make(new TextEncoder().encode(prompt)), + }, + }, + ); + + const child = yield* commandSpawner + .spawn(command) + .pipe( + Effect.mapError((cause) => + normalizeCodexError(operation, cause, "Failed to spawn Codex CLI process"), + ), + ); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + readStreamAsString(operation, child.stdout), + readStreamAsString(operation, child.stderr), + child.exitCode.pipe( + Effect.map((value) => Number(value)), + Effect.mapError((cause) => + normalizeCodexError(operation, cause, "Failed to read Codex CLI exit code"), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + if (exitCode !== 0) { + const stderrDetail = stderr.trim(); + const stdoutDetail = stdout.trim(); + const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; + return yield* new TextGenerationError({ + operation, + detail: + detail.length > 0 + ? `Codex CLI command failed: ${detail}` + : `Codex CLI command failed with code ${exitCode}.`, + }); + } + }); + + const cleanup = Effect.all([safeUnlink(schemaPath), safeUnlink(outputPath)], { + concurrency: "unbounded", + }).pipe(Effect.asVoid); + + return yield* Effect.gen(function* () { + yield* runCodexCommand.pipe( + Effect.scoped, + Effect.timeoutOption(CODEX_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ operation, detail: "Codex CLI request timed out." }), + ), + onSome: () => Effect.void, + }), + ), + ); + + const rawOutput = yield* fileSystem.readFileString(outputPath).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "Failed to read Codex output file.", + cause, + }), + ), + ); + const trimmed = rawOutput.trim(); + if (trimmed.length === 0) { + return yield* new TextGenerationError({ + operation, + detail: "Codex returned an empty response.", + }); + } + + const parsedJson = yield* Effect.try({ + try: () => JSON.parse(trimmed) as unknown, + catch: (cause) => + new TextGenerationError({ + operation, + detail: "Codex returned invalid JSON output.", + cause, + }), + }); + + return yield* Effect.try({ + try: () => parse(parsedJson), + catch: (cause) => + normalizeCodexError(operation, cause, "Codex returned invalid structured output"), + }); + }).pipe(Effect.ensuring(cleanup)); + }); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => { + const prompt = [ + "You write concise git commit messages.", + "Return a JSON object with keys: subject, body.", + "Rules:", + "- subject must be imperative, <= 72 chars, and no trailing period", + "- body can be empty string or short bullet points", + "- capture the primary user-visible or developer-visible change", + "", + `Branch: ${input.branch ?? "(detached)"}`, + "", + "Staged files:", + limitSection(input.stagedSummary, 6_000), + "", + "Staged patch:", + limitSection(input.stagedPatch, 40_000), + ].join("\n"); + + return runCodexJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON, + parse: (raw) => parseCommitOutput(raw), + }).pipe( + Effect.map( + (generated) => + ({ + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + }) satisfies CommitMessageGenerationResult, + ), + ); + }; + + const generatePrContent: TextGenerationShape["generatePrContent"] = (input) => { + const prompt = [ + "You write GitHub pull request content.", + "Return a JSON object with keys: title, body.", + "Rules:", + "- title should be concise and specific", + "- body must be markdown and include headings '## Summary' and '## Testing'", + "- under Summary, provide short bullet points", + "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", + "", + `Base branch: ${input.baseBranch}`, + `Head branch: ${input.headBranch}`, + "", + "Commits:", + limitSection(input.commitSummary, 12_000), + "", + "Diff stat:", + limitSection(input.diffSummary, 12_000), + "", + "Diff patch:", + limitSection(input.diffPatch, 40_000), + ].join("\n"); + + return runCodexJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: PR_OUTPUT_SCHEMA_JSON, + parse: (raw) => parsePrOutput(raw), + }).pipe( + Effect.map( + (generated) => + ({ + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }) satisfies PrContentGenerationResult, + ), + ); + }; + + return { + generateCommitMessage, + generatePrContent, + } satisfies TextGenerationShape; +}); + +export const CodexTextGenerationLive = Layer.effect(TextGeneration, makeCodexTextGeneration); diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb5a1385ace042d5b435bd93964b1f24935f5461 --- /dev/null +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -0,0 +1,1157 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; + +import { NodeServices } from "@effect/platform-node"; +import { it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; +import { describe, expect, vi } from "vitest"; + +import { GitServiceLive } from "./GitService.ts"; +import { GitService, type GitServiceShape } from "../Services/GitService.ts"; +import { GitCoreLive } from "./GitCore.ts"; +import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; +import { GitCommandError } from "../Errors.ts"; +import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; + +// ── Helpers ── + +const GitServiceTestLayer = GitServiceLive.pipe(Layer.provide(NodeServices.layer)); +const GitCoreTestLayer = GitCoreLive.pipe( + Layer.provide(GitServiceTestLayer), + Layer.provide(NodeServices.layer), +); +const TestLayer = Layer.mergeAll(NodeServices.layer, GitServiceTestLayer, GitCoreTestLayer); + +function makeTmpDir( + prefix = "git-test-", +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); +} + +function writeTextFile( + filePath: string, + contents: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(filePath, contents); + }); +} + +/** Run a raw git command for test setup (not under test). */ +function git( + cwd: string, + args: ReadonlyArray, + env?: NodeJS.ProcessEnv, +): Effect.Effect { + return Effect.gen(function* () { + const gitService = yield* GitService; + const result = yield* gitService.execute({ + operation: "GitCore.test.git", + cwd, + args, + ...(env ? { env } : {}), + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); +} + +function runShellCommand(input: { + command: string; + cwd: string; + timeoutMs?: number; + maxOutputBytes?: number; +}): Effect.Effect { + return Effect.promise(() => { + const shellPath = + process.platform === "win32" + ? (process.env.ComSpec ?? "cmd.exe") + : (process.env.SHELL ?? "/bin/sh"); + + const args = + process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command]; + + return runProcess(shellPath, args, { + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? 30_000, + allowNonZeroExit: true, + maxBufferBytes: input.maxOutputBytes ?? 1_000_000, + outputMode: "truncate", + }); + }); +} + +const makeIsolatedGitCore = (gitService: GitServiceShape) => + Effect.promise(async () => { + const gitServiceLayer = Layer.succeed(GitService, gitService); + const coreLayer = GitCoreLive.pipe( + Layer.provide(gitServiceLayer), + Layer.provide(NodeServices.layer), + ); + const core = await Effect.runPromise(Effect.service(GitCore).pipe(Effect.provide(coreLayer))); + + return { + status: (input) => core.status(input), + statusDetails: (cwd) => core.statusDetails(cwd), + prepareCommitContext: (cwd) => core.prepareCommitContext(cwd), + commit: (cwd, subject, body) => core.commit(cwd, subject, body), + pushCurrentBranch: (cwd, fallbackBranch) => core.pushCurrentBranch(cwd, fallbackBranch), + pullCurrentBranch: (cwd) => core.pullCurrentBranch(cwd), + readRangeContext: (cwd, baseBranch) => core.readRangeContext(cwd, baseBranch), + readConfigValue: (cwd, key) => core.readConfigValue(cwd, key), + listBranches: (input) => core.listBranches(input), + createWorktree: (input) => core.createWorktree(input), + removeWorktree: (input) => core.removeWorktree(input), + createBranch: (input) => core.createBranch(input), + checkoutBranch: (input) => core.checkoutBranch(input), + initRepo: (input) => core.initRepo(input), + } satisfies GitCoreShape; + }); + +function listGitBranches(input: Parameters[0]) { + return Effect.gen(function* () { + const core = yield* GitCore; + return yield* core.listBranches(input); + }); +} + +function initGitRepo(input: Parameters[0]) { + return Effect.gen(function* () { + const core = yield* GitCore; + return yield* core.initRepo(input); + }); +} + +function createGitBranch(input: Parameters[0]) { + return Effect.gen(function* () { + const core = yield* GitCore; + return yield* core.createBranch(input); + }); +} + +function checkoutGitBranch(input: Parameters[0]) { + return Effect.gen(function* () { + const core = yield* GitCore; + return yield* core.checkoutBranch(input); + }); +} + +function createGitWorktree(input: Parameters[0]) { + return Effect.gen(function* () { + const core = yield* GitCore; + return yield* core.createWorktree(input); + }); +} + +function removeGitWorktree(input: Parameters[0]) { + return Effect.gen(function* () { + const core = yield* GitCore; + return yield* core.removeWorktree(input); + }); +} + +function pullGitBranch({ cwd }: { cwd: string }) { + return Effect.gen(function* () { + const core = yield* GitCore; + return yield* core.pullCurrentBranch(cwd); + }); +} + +/** Create a repo with an initial commit so branches work. */ +function initRepoWithCommit( + cwd: string, +): Effect.Effect< + void, + GitCommandError | PlatformError.PlatformError, + GitCore | GitService | FileSystem.FileSystem +> { + return Effect.gen(function* () { + yield* initGitRepo({ cwd }); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial commit"]); + }); +} + +function commitWithDate( + cwd: string, + fileName: string, + fileContents: string, + dateIsoString: string, + message: string, +): Effect.Effect< + void, + GitCommandError | PlatformError.PlatformError, + GitService | FileSystem.FileSystem +> { + return Effect.gen(function* () { + yield* writeTextFile(path.join(cwd, fileName), fileContents); + yield* git(cwd, ["add", fileName]); + yield* git(cwd, ["commit", "-m", message], { + ...process.env, + GIT_AUTHOR_DATE: dateIsoString, + GIT_COMMITTER_DATE: dateIsoString, + }); + }); +} + +// ── Tests ── + +it.layer(TestLayer)("git integration", (it) => { + describe("shell process execution", () => { + it.effect("caps captured output when maxOutputBytes is exceeded", () => + Effect.gen(function* () { + const result = yield* runShellCommand({ + command: `node -e "process.stdout.write('x'.repeat(2000))"`, + cwd: process.cwd(), + timeoutMs: 10_000, + maxOutputBytes: 128, + }); + + expect(result.code).toBe(0); + expect(result.stdout.length).toBeLessThanOrEqual(128); + expect(result.stdoutTruncated || result.stderrTruncated).toBe(true); + }), + ); + }); + + // ── initGitRepo ── + + describe("initGitRepo", () => { + it.effect("creates a valid git repo", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initGitRepo({ cwd: tmp }); + expect(existsSync(path.join(tmp, ".git"))).toBe(true); + }), + ); + + it.effect("listGitBranches reports isRepo: true after init + commit", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const result = yield* listGitBranches({ cwd: tmp }); + expect(result.isRepo).toBe(true); + expect(result.branches.length).toBeGreaterThanOrEqual(1); + }), + ); + }); + + // ── listGitBranches ── + + describe("listGitBranches", () => { + it.effect("returns isRepo: false for non-git directory", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const result = yield* listGitBranches({ cwd: tmp }); + expect(result.isRepo).toBe(false); + expect(result.branches).toEqual([]); + }), + ); + + it.effect("returns the current branch with current: true", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const result = yield* listGitBranches({ cwd: tmp }); + const current = result.branches.find((b) => b.current); + expect(current).toBeDefined(); + expect(current!.current).toBe(true); + }), + ); + + it.effect("keeps current branch first and sorts the remaining branches by recency", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + (branch) => branch.current, + )!.name; + + yield* createGitBranch({ cwd: tmp, branch: "older-branch" }); + yield* checkoutGitBranch({ cwd: tmp, branch: "older-branch" }); + yield* commitWithDate( + tmp, + "older.txt", + "older branch change\n", + "Thu, 1 Jan 2037 00:00:00 +0000", + "older branch change", + ); + + yield* checkoutGitBranch({ cwd: tmp, branch: initialBranch }); + yield* createGitBranch({ cwd: tmp, branch: "newer-branch" }); + yield* checkoutGitBranch({ cwd: tmp, branch: "newer-branch" }); + yield* commitWithDate( + tmp, + "newer.txt", + "newer branch change\n", + "Fri, 1 Jan 2038 00:00:00 +0000", + "newer branch change", + ); + + // Switch away to show current branch is pinned, then remaining branches are recency-sorted. + yield* checkoutGitBranch({ cwd: tmp, branch: "older-branch" }); + + const result = yield* listGitBranches({ cwd: tmp }); + expect(result.branches[0]!.name).toBe("older-branch"); + expect(result.branches[1]!.name).toBe("newer-branch"); + }), + ); + + it.effect("keeps default branch right after current branch", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const remote = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + (branch) => branch.current, + )!.name; + + yield* git(remote, ["init", "--bare"]); + yield* git(tmp, ["remote", "add", "origin", remote]); + yield* git(tmp, ["push", "-u", "origin", defaultBranch]); + yield* git(tmp, ["remote", "set-head", "origin", defaultBranch]); + + yield* createGitBranch({ cwd: tmp, branch: "current-branch" }); + yield* checkoutGitBranch({ cwd: tmp, branch: "current-branch" }); + yield* commitWithDate( + tmp, + "current.txt", + "current change\n", + "Thu, 1 Jan 2037 00:00:00 +0000", + "current change", + ); + + yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch }); + yield* createGitBranch({ cwd: tmp, branch: "newer-branch" }); + yield* checkoutGitBranch({ cwd: tmp, branch: "newer-branch" }); + yield* commitWithDate( + tmp, + "newer.txt", + "newer change\n", + "Fri, 1 Jan 2038 00:00:00 +0000", + "newer change", + ); + + yield* checkoutGitBranch({ cwd: tmp, branch: "current-branch" }); + + const result = yield* listGitBranches({ cwd: tmp }); + expect(result.branches[0]!.name).toBe("current-branch"); + expect(result.branches[1]!.name).toBe(defaultBranch); + expect(result.branches[2]!.name).toBe("newer-branch"); + }), + ); + + it.effect("lists multiple branches after creating them", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* createGitBranch({ cwd: tmp, branch: "feature-a" }); + yield* createGitBranch({ cwd: tmp, branch: "feature-b" }); + + const result = yield* listGitBranches({ cwd: tmp }); + const names = result.branches.map((b) => b.name); + expect(names).toContain("feature-a"); + expect(names).toContain("feature-b"); + }), + ); + + it.effect("isDefault is false when no remote exists", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const result = yield* listGitBranches({ cwd: tmp }); + expect(result.branches.every((b) => b.isDefault === false)).toBe(true); + }), + ); + }); + + // ── checkoutGitBranch ── + + describe("checkoutGitBranch", () => { + it.effect("checks out an existing branch", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* createGitBranch({ cwd: tmp, branch: "feature" }); + + yield* checkoutGitBranch({ cwd: tmp, branch: "feature" }); + + const result = yield* listGitBranches({ cwd: tmp }); + const current = result.branches.find((b) => b.current); + expect(current!.name).toBe("feature"); + }), + ); + + it.effect("refreshes upstream behind count after checkout when remote branch advanced", () => + Effect.gen(function* () { + const remote = yield* makeTmpDir(); + const source = yield* makeTmpDir(); + const clone = yield* makeTmpDir(); + yield* git(remote, ["init", "--bare"]); + + yield* initRepoWithCommit(source); + const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + (branch) => branch.current, + )!.name; + yield* git(source, ["remote", "add", "origin", remote]); + yield* git(source, ["push", "-u", "origin", defaultBranch]); + + const featureBranch = "feature-behind"; + yield* createGitBranch({ cwd: source, branch: featureBranch }); + yield* checkoutGitBranch({ cwd: source, branch: featureBranch }); + yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); + yield* git(source, ["add", "feature.txt"]); + yield* git(source, ["commit", "-m", "feature base"]); + yield* git(source, ["push", "-u", "origin", featureBranch]); + yield* checkoutGitBranch({ cwd: source, branch: defaultBranch }); + + yield* git(clone, ["clone", remote, "."]); + yield* git(clone, ["config", "user.email", "test@test.com"]); + yield* git(clone, ["config", "user.name", "Test"]); + yield* git(clone, ["checkout", "-b", featureBranch, "--track", `origin/${featureBranch}`]); + yield* writeTextFile(path.join(clone, "feature.txt"), "feature from remote\n"); + yield* git(clone, ["add", "feature.txt"]); + yield* git(clone, ["commit", "-m", "remote feature update"]); + yield* git(clone, ["push", "origin", featureBranch]); + + yield* checkoutGitBranch({ cwd: source, branch: featureBranch }); + const core = yield* GitCore; + yield* Effect.promise(() => + vi.waitFor(async () => { + const details = await Effect.runPromise(core.statusDetails(source)); + expect(details.branch).toBe(featureBranch); + expect(details.aheadCount).toBe(0); + expect(details.behindCount).toBe(1); + }), + ); + }), + ); + + it.effect("keeps checkout successful when upstream refresh fails", () => + Effect.gen(function* () { + const remote = yield* makeTmpDir(); + const source = yield* makeTmpDir(); + yield* git(remote, ["init", "--bare"]); + + yield* initRepoWithCommit(source); + const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + (branch) => branch.current, + )!.name; + yield* git(source, ["remote", "add", "origin", remote]); + yield* git(source, ["push", "-u", "origin", defaultBranch]); + + const featureBranch = "feature-refresh-failure"; + yield* git(source, ["branch", featureBranch]); + yield* git(source, ["checkout", featureBranch]); + yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); + yield* git(source, ["add", "feature.txt"]); + yield* git(source, ["commit", "-m", "feature base"]); + yield* git(source, ["push", "-u", "origin", featureBranch]); + yield* git(source, ["checkout", defaultBranch]); + + const realGitService = yield* GitService; + let refreshFetchAttempts = 0; + const core = yield* makeIsolatedGitCore({ + execute: (input) => { + if (input.args[0] === "fetch") { + refreshFetchAttempts += 1; + return Effect.fail( + new GitCommandError({ + operation: "git.test.refreshFailure", + command: `git ${input.args.join(" ")}`, + cwd: input.cwd, + detail: "simulated fetch timeout", + }), + ); + } + return realGitService.execute(input); + }, + }); + yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); + yield* Effect.promise(() => + vi.waitFor(() => { + expect(refreshFetchAttempts).toBe(1); + }), + ); + expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); + }), + ); + + it.effect("refresh fetch is scoped to the checked out branch upstream refspec", () => + Effect.gen(function* () { + const remote = yield* makeTmpDir(); + const source = yield* makeTmpDir(); + yield* git(remote, ["init", "--bare"]); + + yield* initRepoWithCommit(source); + const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + (branch) => branch.current, + )!.name; + yield* git(source, ["remote", "add", "origin", remote]); + yield* git(source, ["push", "-u", "origin", defaultBranch]); + + const featureBranch = "feature/scoped-fetch"; + yield* git(source, ["checkout", "-b", featureBranch]); + yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); + yield* git(source, ["add", "feature.txt"]); + yield* git(source, ["commit", "-m", "feature base"]); + yield* git(source, ["push", "-u", "origin", featureBranch]); + yield* git(source, ["checkout", defaultBranch]); + + const realGitService = yield* GitService; + let fetchArgs: readonly string[] | null = null; + const core = yield* makeIsolatedGitCore({ + execute: (input) => { + if (input.args[0] === "fetch") { + fetchArgs = [...input.args]; + return Effect.succeed({ code: 0, stdout: "", stderr: "" }); + } + return realGitService.execute(input); + }, + }); + yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); + yield* Effect.promise(() => + vi.waitFor(() => { + expect(fetchArgs).not.toBeNull(); + }), + ); + + expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); + expect(fetchArgs).toEqual([ + "fetch", + "--quiet", + "--no-tags", + "origin", + `+refs/heads/${featureBranch}:refs/remotes/origin/${featureBranch}`, + ]); + }), + ); + + it.effect("returns checkout result before background upstream refresh completes", () => + Effect.gen(function* () { + const remote = yield* makeTmpDir(); + const source = yield* makeTmpDir(); + yield* git(remote, ["init", "--bare"]); + + yield* initRepoWithCommit(source); + const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + (branch) => branch.current, + )!.name; + yield* git(source, ["remote", "add", "origin", remote]); + yield* git(source, ["push", "-u", "origin", defaultBranch]); + + const featureBranch = "feature/background-refresh"; + yield* git(source, ["checkout", "-b", featureBranch]); + yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); + yield* git(source, ["add", "feature.txt"]); + yield* git(source, ["commit", "-m", "feature base"]); + yield* git(source, ["push", "-u", "origin", featureBranch]); + yield* git(source, ["checkout", defaultBranch]); + + const realGitService = yield* GitService; + let fetchStarted = false; + let releaseFetch: () => void = () => undefined; + const waitForReleasePromise = new Promise((resolve) => { + releaseFetch = resolve; + }); + const core = yield* makeIsolatedGitCore({ + execute: (input) => { + if (input.args[0] === "fetch") { + fetchStarted = true; + return Effect.promise(() => + waitForReleasePromise.then(() => ({ code: 0, stdout: "", stderr: "" })), + ); + } + return realGitService.execute(input); + }, + }); + yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); + yield* Effect.promise(() => + vi.waitFor(() => { + expect(fetchStarted).toBe(true); + }), + ); + expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); + releaseFetch(); + }), + ); + + it.effect("throws when branch does not exist", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const result = yield* Effect.result(checkoutGitBranch({ cwd: tmp, branch: "nonexistent" })); + expect(result._tag).toBe("Failure"); + }), + ); + + it.effect("throws when checkout would overwrite uncommitted changes", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* createGitBranch({ cwd: tmp, branch: "other" }); + + // Create a conflicting change: modify README on current branch + yield* writeTextFile(path.join(tmp, "README.md"), "modified\n"); + yield* git(tmp, ["add", "README.md"]); + + // First, checkout other branch cleanly + yield* git(tmp, ["stash"]); + yield* checkoutGitBranch({ cwd: tmp, branch: "other" }); + yield* writeTextFile(path.join(tmp, "README.md"), "other content\n"); + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", "other change"]); + + // Go back to default branch + const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + (b) => !b.current, + )!.name; + yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch }); + + // Make uncommitted changes to the same file + yield* writeTextFile(path.join(tmp, "README.md"), "conflicting local\n"); + + // Checkout should fail due to uncommitted changes + const result = yield* Effect.result(checkoutGitBranch({ cwd: tmp, branch: "other" })); + expect(result._tag).toBe("Failure"); + }), + ); + }); + + // ── createGitBranch ── + + describe("createGitBranch", () => { + it.effect("creates a new branch visible in listGitBranches", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* createGitBranch({ cwd: tmp, branch: "new-feature" }); + + const result = yield* listGitBranches({ cwd: tmp }); + expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); + }), + ); + + it.effect("throws when branch already exists", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* createGitBranch({ cwd: tmp, branch: "dupe" }); + const result = yield* Effect.result(createGitBranch({ cwd: tmp, branch: "dupe" })); + expect(result._tag).toBe("Failure"); + }), + ); + }); + + // ── createGitWorktree + removeGitWorktree ── + + describe("createGitWorktree", () => { + it.effect("creates a worktree with a new branch from the base branch", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + + const wtPath = path.join(tmp, "worktree-out"); + const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + (b) => b.current, + )!.name; + + const result = yield* createGitWorktree({ + cwd: tmp, + branch: currentBranch, + newBranch: "wt-branch", + path: wtPath, + }); + + expect(result.worktree.path).toBe(wtPath); + expect(result.worktree.branch).toBe("wt-branch"); + expect(existsSync(wtPath)).toBe(true); + expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); + + // Clean up worktree before tmp dir disposal + yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + }), + ); + + it.effect("worktree has the new branch checked out", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + + const wtPath = path.join(tmp, "wt-check-dir"); + const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + (b) => b.current, + )!.name; + + yield* createGitWorktree({ + cwd: tmp, + branch: currentBranch, + newBranch: "wt-check", + path: wtPath, + }); + + // Verify the worktree is on the new branch + const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); + expect(branchOutput).toBe("wt-check"); + + yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + }), + ); + + it.effect("throws when new branch name already exists", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* createGitBranch({ cwd: tmp, branch: "existing" }); + + const wtPath = path.join(tmp, "wt-conflict"); + const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + (b) => b.current, + )!.name; + + const result = yield* Effect.result( + createGitWorktree({ + cwd: tmp, + branch: currentBranch, + newBranch: "existing", + path: wtPath, + }), + ); + expect(result._tag).toBe("Failure"); + }), + ); + + it.effect("listGitBranches from worktree cwd reports worktree branch as current", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + + const wtPath = path.join(tmp, "wt-list-dir"); + const mainBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + (b) => b.current, + )!.name; + + yield* createGitWorktree({ + cwd: tmp, + branch: mainBranch, + newBranch: "wt-list", + path: wtPath, + }); + + // listGitBranches from the worktree should show wt-list as current + const wtBranches = yield* listGitBranches({ cwd: wtPath }); + expect(wtBranches.isRepo).toBe(true); + const wtCurrent = wtBranches.branches.find((b) => b.current); + expect(wtCurrent!.name).toBe("wt-list"); + + // Main repo should still show the original branch as current + const mainBranches = yield* listGitBranches({ cwd: tmp }); + const mainCurrent = mainBranches.branches.find((b) => b.current); + expect(mainCurrent!.name).toBe(mainBranch); + + yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + }), + ); + + it.effect("removeGitWorktree cleans up the worktree", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + + const wtPath = path.join(tmp, "wt-remove-dir"); + const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + (b) => b.current, + )!.name; + + yield* createGitWorktree({ + cwd: tmp, + branch: currentBranch, + newBranch: "wt-remove", + path: wtPath, + }); + expect(existsSync(wtPath)).toBe(true); + + yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + expect(existsSync(wtPath)).toBe(false); + }), + ); + + it.effect("removeGitWorktree force removes a dirty worktree", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + + const wtPath = path.join(tmp, "wt-dirty-dir"); + const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + (b) => b.current, + )!.name; + + yield* createGitWorktree({ + cwd: tmp, + branch: currentBranch, + newBranch: "wt-dirty", + path: wtPath, + }); + expect(existsSync(wtPath)).toBe(true); + + yield* writeTextFile(path.join(wtPath, "README.md"), "dirty change\n"); + + const failedRemove = yield* Effect.result(removeGitWorktree({ cwd: tmp, path: wtPath })); + expect(failedRemove._tag).toBe("Failure"); + expect(existsSync(wtPath)).toBe(true); + + yield* removeGitWorktree({ cwd: tmp, path: wtPath, force: true }); + expect(existsSync(wtPath)).toBe(false); + }), + ); + }); + + // ── Full flow: local branch checkout ── + + describe("full flow: local branch checkout", () => { + it.effect("init → commit → create branch → checkout → verify current", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* createGitBranch({ cwd: tmp, branch: "feature-login" }); + yield* checkoutGitBranch({ cwd: tmp, branch: "feature-login" }); + + const result = yield* listGitBranches({ cwd: tmp }); + const current = result.branches.find((b) => b.current); + expect(current!.name).toBe("feature-login"); + }), + ); + }); + + // ── Full flow: worktree creation from base branch ── + + describe("full flow: worktree creation", () => { + it.effect("creates worktree with new branch from current branch", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + + const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + (b) => b.current, + )!.name; + + const wtPath = path.join(tmp, "my-worktree"); + const result = yield* createGitWorktree({ + cwd: tmp, + branch: currentBranch, + newBranch: "feature-wt", + path: wtPath, + }); + + // Worktree exists + expect(existsSync(result.worktree.path)).toBe(true); + + // Main repo still on original branch + const mainBranches = yield* listGitBranches({ cwd: tmp }); + const mainCurrent = mainBranches.branches.find((b) => b.current); + expect(mainCurrent!.name).toBe(currentBranch); + + // Worktree is on the new branch + const wtBranch = yield* git(wtPath, ["branch", "--show-current"]); + expect(wtBranch).toBe("feature-wt"); + + yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + }), + ); + }); + + // ── Full flow: thread switching simulation ── + + describe("full flow: thread switching (checkout toggling)", () => { + it.effect("checkout a → checkout b → checkout a → current matches", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* createGitBranch({ cwd: tmp, branch: "branch-a" }); + yield* createGitBranch({ cwd: tmp, branch: "branch-b" }); + + // Simulate switching to thread A's branch + yield* checkoutGitBranch({ cwd: tmp, branch: "branch-a" }); + let branches = yield* listGitBranches({ cwd: tmp }); + expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); + + // Simulate switching to thread B's branch + yield* checkoutGitBranch({ cwd: tmp, branch: "branch-b" }); + branches = yield* listGitBranches({ cwd: tmp }); + expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b"); + + // Switch back to thread A + yield* checkoutGitBranch({ cwd: tmp, branch: "branch-a" }); + branches = yield* listGitBranches({ cwd: tmp }); + expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); + }), + ); + }); + + // ── Full flow: checkout conflict ── + + describe("full flow: checkout conflict", () => { + it.effect("uncommitted changes prevent checkout to a diverged branch", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* createGitBranch({ cwd: tmp, branch: "diverged" }); + + // Make diverged branch have different file content + yield* checkoutGitBranch({ cwd: tmp, branch: "diverged" }); + yield* writeTextFile(path.join(tmp, "README.md"), "diverged content\n"); + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", "diverge"]); + + // Actually, let's just get back to the initial branch explicitly + const allBranches = yield* listGitBranches({ cwd: tmp }); + const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name; + yield* checkoutGitBranch({ cwd: tmp, branch: initialBranch }); + + // Make local uncommitted changes to the same file + yield* writeTextFile(path.join(tmp, "README.md"), "local uncommitted\n"); + + // Attempt checkout should fail + const failedCheckout = yield* Effect.result( + checkoutGitBranch({ cwd: tmp, branch: "diverged" }), + ); + expect(failedCheckout._tag).toBe("Failure"); + + // Current branch should still be the initial one + const result = yield* listGitBranches({ cwd: tmp }); + expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); + }), + ); + }); + + describe("GitCore", () => { + it.effect("supports branch lifecycle operations through the service API", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const core = yield* GitCore; + + yield* core.initRepo({ cwd: tmp }); + yield* git(tmp, ["config", "user.email", "test@test.com"]); + yield* git(tmp, ["config", "user.name", "Test"]); + yield* writeTextFile(path.join(tmp, "README.md"), "# test\n"); + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", "initial commit"]); + + yield* core.createBranch({ cwd: tmp, branch: "feature/service-api" }); + yield* core.checkoutBranch({ cwd: tmp, branch: "feature/service-api" }); + const branches = yield* core.listBranches({ cwd: tmp }); + + expect(branches.isRepo).toBe(true); + expect( + branches.branches.find((branch: { current: boolean; name: string }) => branch.current) + ?.name, + ).toBe("feature/service-api"); + }), + ); + + it.effect("reports status details and dirty state", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + const clean = yield* core.status({ cwd: tmp }); + expect(clean.hasWorkingTreeChanges).toBe(false); + expect(clean.branch).toBeTruthy(); + + yield* writeTextFile(path.join(tmp, "README.md"), "updated\n"); + const dirty = yield* core.statusDetails(tmp); + expect(dirty.hasWorkingTreeChanges).toBe(true); + }), + ); + + it.effect("includes command context when worktree removal fails", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + const missingWorktreePath = path.join(tmp, "missing-worktree"); + + const removeResult = yield* Effect.result( + core.removeWorktree({ cwd: tmp, path: missingWorktreePath }), + ); + expect(removeResult._tag).toBe("Failure"); + if (removeResult._tag !== "Failure") { + return; + } + const message = removeResult.failure.message; + expect(message).toContain("git worktree remove"); + expect(message).toContain(`cwd: ${tmp}`); + expect(message).toContain(missingWorktreePath); + }), + ); + + it.effect( + "refreshes upstream before statusDetails so behind count reflects remote updates", + () => + Effect.gen(function* () { + const remote = yield* makeTmpDir(); + const source = yield* makeTmpDir(); + const clone = yield* makeTmpDir(); + yield* git(remote, ["init", "--bare"]); + + yield* initRepoWithCommit(source); + const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( + (branch) => branch.current, + )!.name; + yield* git(source, ["remote", "add", "origin", remote]); + yield* git(source, ["push", "-u", "origin", initialBranch]); + + yield* git(clone, ["clone", remote, "."]); + yield* git(clone, ["config", "user.email", "test@test.com"]); + yield* git(clone, ["config", "user.name", "Test"]); + yield* git(clone, [ + "checkout", + "-B", + initialBranch, + "--track", + `origin/${initialBranch}`, + ]); + yield* writeTextFile(path.join(clone, "CHANGELOG.md"), "remote change\n"); + yield* git(clone, ["add", "CHANGELOG.md"]); + yield* git(clone, ["commit", "-m", "remote update"]); + yield* git(clone, ["push", "origin", initialBranch]); + + const core = yield* GitCore; + const details = yield* core.statusDetails(source); + expect(details.branch).toBe(initialBranch); + expect(details.aheadCount).toBe(0); + expect(details.behindCount).toBe(1); + }), + ); + + it.effect("prepares commit context by auto-staging and creates commit", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* writeTextFile(path.join(tmp, "README.md"), "new content\n"); + const context = yield* core.prepareCommitContext(tmp); + expect(context).not.toBeNull(); + expect(context!.stagedSummary.length).toBeGreaterThan(0); + expect(context!.stagedPatch.length).toBeGreaterThan(0); + + const created = yield* core.commit(tmp, "Add README update", "- include updated content"); + expect(created.commitSha.length).toBeGreaterThan(0); + expect(yield* git(tmp, ["log", "-1", "--pretty=%s"])).toBe("Add README update"); + }), + ); + + it.effect("pushes with upstream setup and then skips when up to date", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const remote = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* git(remote, ["init", "--bare"]); + yield* git(tmp, ["remote", "add", "origin", remote]); + yield* createGitBranch({ cwd: tmp, branch: "feature/core-push" }); + yield* checkoutGitBranch({ cwd: tmp, branch: "feature/core-push" }); + + yield* writeTextFile(path.join(tmp, "feature.txt"), "push me\n"); + const core = yield* GitCore; + const context = yield* core.prepareCommitContext(tmp); + expect(context).not.toBeNull(); + yield* core.commit(tmp, "Add feature file", ""); + + const pushed = yield* core.pushCurrentBranch(tmp, null); + expect(pushed.status).toBe("pushed"); + expect(pushed.setUpstream).toBe(true); + expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( + "origin/feature/core-push", + ); + + const skipped = yield* core.pushCurrentBranch(tmp, null); + expect(skipped.status).toBe("skipped_up_to_date"); + }), + ); + + it.effect("pulls behind branch and then reports up-to-date", () => + Effect.gen(function* () { + const remote = yield* makeTmpDir(); + const source = yield* makeTmpDir(); + const clone = yield* makeTmpDir(); + yield* git(remote, ["init", "--bare"]); + + yield* initRepoWithCommit(source); + const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( + (branch) => branch.current, + )!.name; + yield* git(source, ["remote", "add", "origin", remote]); + yield* git(source, ["push", "-u", "origin", initialBranch]); + + yield* git(clone, ["clone", remote, "."]); + yield* git(clone, ["config", "user.email", "test@test.com"]); + yield* git(clone, ["config", "user.name", "Test"]); + yield* writeTextFile(path.join(clone, "CHANGELOG.md"), "remote change\n"); + yield* git(clone, ["add", "CHANGELOG.md"]); + yield* git(clone, ["commit", "-m", "remote update"]); + yield* git(clone, ["push", "origin", initialBranch]); + + const core = yield* GitCore; + const pulled = yield* core.pullCurrentBranch(source); + expect(pulled.status).toBe("pulled"); + expect((yield* core.statusDetails(source)).behindCount).toBe(0); + + const skipped = yield* core.pullCurrentBranch(source); + expect(skipped.status).toBe("skipped_up_to_date"); + }), + ); + + it.effect("top-level pullGitBranch rejects when no upstream exists", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const result = yield* Effect.result(pullGitBranch({ cwd: tmp })); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure.message.toLowerCase()).toContain("no upstream"); + } + }), + ); + + it.effect("lists branches when recency lookup fails", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const realGitService = yield* GitService; + let didFailRecency = false; + const core = yield* makeIsolatedGitCore({ + execute: (input) => { + if (!didFailRecency && input.args[0] === "for-each-ref") { + didFailRecency = true; + return Effect.fail( + new GitCommandError({ + operation: "git.test.listBranchesRecency", + command: `git ${input.args.join(" ")}`, + cwd: input.cwd, + detail: "timeout", + }), + ); + } + return realGitService.execute(input); + }, + }); + + const result = yield* core.listBranches({ cwd: tmp }); + + expect(result.isRepo).toBe(true); + expect(result.branches.length).toBeGreaterThan(0); + expect(result.branches[0]?.current).toBe(true); + expect(didFailRecency).toBe(true); + }), + ); + }); +}); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts new file mode 100644 index 0000000000000000000000000000000000000000..0624bd91fb1c9a1a39b2bcfeb6ac29101c4116ae --- /dev/null +++ b/apps/server/src/git/Layers/GitCore.ts @@ -0,0 +1,746 @@ +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { GitCommandError } from "../Errors.ts"; +import { GitService } from "../Services/GitService.ts"; +import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; + +const STATUS_UPSTREAM_REFRESH_INTERVAL_MS = 15_000; +const STATUS_UPSTREAM_REFRESH_TIMEOUT_MS = 5_000; + +interface ExecuteGitOptions { + timeoutMs?: number | undefined; + allowNonZeroExit?: boolean | undefined; + fallbackErrorMessage?: string | undefined; +} + +function trimStdout(value: string): string { + return value.trim(); +} + +function parseBranchAb(value: string): { ahead: number; behind: number } { + const match = value.match(/^\+(\d+)\s+-(\d+)$/); + if (!match) return { ahead: 0, behind: 0 }; + return { + ahead: Number(match[1] ?? "0"), + behind: Number(match[2] ?? "0"), + }; +} + +function parseNumstatEntries( + stdout: string, +): Array<{ path: string; insertions: number; deletions: number }> { + const entries: Array<{ path: string; insertions: number; deletions: number }> = []; + for (const line of stdout.split(/\r?\n/g)) { + if (line.trim().length === 0) continue; + const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); + const rawPath = + pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); + if (rawPath.length === 0) continue; + const added = Number.parseInt(addedRaw ?? "0", 10); + const deleted = Number.parseInt(deletedRaw ?? "0", 10); + const renameArrowIndex = rawPath.indexOf(" => "); + const normalizedPath = + renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; + entries.push({ + path: normalizedPath.length > 0 ? normalizedPath : rawPath, + insertions: Number.isFinite(added) ? added : 0, + deletions: Number.isFinite(deleted) ? deleted : 0, + }); + } + return entries; +} + +function parsePorcelainPath(line: string): string | null { + if (line.startsWith("? ") || line.startsWith("! ")) { + const simple = line.slice(2).trim(); + return simple.length > 0 ? simple : null; + } + + if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { + return null; + } + + const tabIndex = line.indexOf("\t"); + if (tabIndex >= 0) { + const fromTab = line.slice(tabIndex + 1); + const [filePath] = fromTab.split("\t"); + return filePath?.trim().length ? filePath.trim() : null; + } + + const parts = line.trim().split(/\s+/g); + const filePath = parts.at(-1) ?? ""; + return filePath.length > 0 ? filePath : null; +} + +function commandLabel(args: readonly string[]): string { + return `git ${args.join(" ")}`; +} + +function createGitCommandError( + operation: string, + cwd: string, + args: readonly string[], + detail: string, + cause?: unknown, +): GitCommandError { + return new GitCommandError({ + operation, + command: commandLabel(args), + cwd, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +const makeGitCore = Effect.gen(function* () { + const git = yield* GitService; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const lastUpstreamRefreshAt = new Map(); + + const executeGit = ( + operation: string, + cwd: string, + args: readonly string[], + options: ExecuteGitOptions = {}, + ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> => + git + .execute({ + operation, + cwd, + args, + allowNonZeroExit: true, + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + }) + .pipe( + Effect.flatMap((result) => { + if (options.allowNonZeroExit || result.code === 0) { + return Effect.succeed(result); + } + const stderr = result.stderr.trim(); + if (stderr.length > 0) { + return Effect.fail(createGitCommandError(operation, cwd, args, stderr)); + } + if (options.fallbackErrorMessage) { + return Effect.fail( + createGitCommandError(operation, cwd, args, options.fallbackErrorMessage), + ); + } + return Effect.fail( + createGitCommandError( + operation, + cwd, + args, + `${commandLabel(args)} failed: code=${result.code ?? "null"}`, + ), + ); + }), + ); + + const runGit = ( + operation: string, + cwd: string, + args: readonly string[], + allowNonZeroExit = false, + ): Effect.Effect => + executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid); + + const runGitStdout = ( + operation: string, + cwd: string, + args: readonly string[], + allowNonZeroExit = false, + ): Effect.Effect => + executeGit(operation, cwd, args, { allowNonZeroExit }).pipe( + Effect.map((result) => result.stdout), + ); + + const resolveCurrentUpstream = ( + cwd: string, + ): Effect.Effect< + { upstreamRef: string; remoteName: string; upstreamBranch: string } | null, + GitCommandError + > => + Effect.gen(function* () { + const upstreamRef = trimStdout( + yield* runGitStdout( + "GitCore.resolveCurrentUpstream", + cwd, + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], + true, + ), + ); + if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { + return null; + } + + const separatorIndex = upstreamRef.indexOf("/"); + if (separatorIndex <= 0) { + return null; + } + const remoteName = upstreamRef.slice(0, separatorIndex); + const upstreamBranch = upstreamRef.slice(separatorIndex + 1); + if (remoteName.length === 0 || upstreamBranch.length === 0) { + return null; + } + + return { + upstreamRef, + remoteName, + upstreamBranch, + }; + }); + + const fetchUpstreamRef = ( + cwd: string, + upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, + ): Effect.Effect => { + const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; + return runGit( + "GitCore.fetchUpstreamRef", + cwd, + ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], + true, + ); + }; + + const fetchUpstreamRefForStatus = ( + cwd: string, + upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, + ): Effect.Effect => { + const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; + return executeGit( + "GitCore.fetchUpstreamRefForStatus", + cwd, + ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], + { + allowNonZeroExit: true, + timeoutMs: STATUS_UPSTREAM_REFRESH_TIMEOUT_MS, + }, + ).pipe(Effect.asVoid); + }; + + const refreshStatusUpstreamIfStale = (cwd: string): Effect.Effect => + Effect.gen(function* () { + const upstream = yield* resolveCurrentUpstream(cwd); + if (!upstream) { + return; + } + + const cacheKey = `${cwd}\u0000${upstream.upstreamRef}`; + const now = Date.now(); + const lastRefreshAt = lastUpstreamRefreshAt.get(cacheKey) ?? 0; + if (now - lastRefreshAt < STATUS_UPSTREAM_REFRESH_INTERVAL_MS) { + return; + } + + // Record attempt time first to avoid repeated fetch spikes when status is polled often. + lastUpstreamRefreshAt.set(cacheKey, now); + yield* fetchUpstreamRefForStatus(cwd, upstream); + }); + + const refreshCheckedOutBranchUpstream = (cwd: string): Effect.Effect => + Effect.gen(function* () { + const upstream = yield* resolveCurrentUpstream(cwd); + if (!upstream) { + return; + } + yield* fetchUpstreamRef(cwd, upstream); + }); + + const readBranchRecency = (cwd: string): Effect.Effect, GitCommandError> => + Effect.gen(function* () { + const branchRecency = yield* executeGit( + "GitCore.readBranchRecency", + cwd, + ["for-each-ref", "--format=%(refname:short)%09%(committerdate:unix)", "refs/heads"], + { + timeoutMs: 15_000, + allowNonZeroExit: true, + }, + ); + + const branchLastCommit = new Map(); + if (branchRecency.code !== 0) { + return branchLastCommit; + } + + for (const line of branchRecency.stdout.split("\n")) { + if (line.length === 0) { + continue; + } + const [name, lastCommitRaw] = line.split("\t"); + if (!name) { + continue; + } + const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); + branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); + } + + return branchLastCommit; + }); + + const statusDetails: GitCoreShape["statusDetails"] = (cwd) => + Effect.gen(function* () { + yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.catch(() => Effect.void)); + + const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all( + [ + runGitStdout("GitCore.statusDetails.status", cwd, [ + "status", + "--porcelain=2", + "--branch", + ]), + runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), + runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [ + "diff", + "--cached", + "--numstat", + ]), + ], + { concurrency: "unbounded" }, + ); + + let branch: string | null = null; + let upstreamRef: string | null = null; + let aheadCount = 0; + let behindCount = 0; + let hasWorkingTreeChanges = false; + const changedFilesWithoutNumstat = new Set(); + + for (const line of statusStdout.split(/\r?\n/g)) { + if (line.startsWith("# branch.head ")) { + const value = line.slice("# branch.head ".length).trim(); + branch = value.startsWith("(") ? null : value; + continue; + } + if (line.startsWith("# branch.upstream ")) { + const value = line.slice("# branch.upstream ".length).trim(); + upstreamRef = value.length > 0 ? value : null; + continue; + } + if (line.startsWith("# branch.ab ")) { + const value = line.slice("# branch.ab ".length).trim(); + const parsed = parseBranchAb(value); + aheadCount = parsed.ahead; + behindCount = parsed.behind; + continue; + } + if (line.trim().length > 0 && !line.startsWith("#")) { + hasWorkingTreeChanges = true; + const pathValue = parsePorcelainPath(line); + if (pathValue) changedFilesWithoutNumstat.add(pathValue); + } + } + const stagedEntries = parseNumstatEntries(stagedNumstatStdout); + const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); + const fileStatMap = new Map(); + for (const entry of [...stagedEntries, ...unstagedEntries]) { + const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; + existing.insertions += entry.insertions; + existing.deletions += entry.deletions; + fileStatMap.set(entry.path, existing); + } + + let insertions = 0; + let deletions = 0; + const files = Array.from(fileStatMap.entries()) + .map(([filePath, stat]) => { + insertions += stat.insertions; + deletions += stat.deletions; + return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; + }) + .toSorted((a, b) => a.path.localeCompare(b.path)); + + for (const filePath of changedFilesWithoutNumstat) { + if (fileStatMap.has(filePath)) continue; + files.push({ path: filePath, insertions: 0, deletions: 0 }); + } + files.sort((a, b) => a.path.localeCompare(b.path)); + + return { + branch, + upstreamRef, + hasWorkingTreeChanges, + workingTree: { + files, + insertions, + deletions, + }, + hasUpstream: upstreamRef !== null, + aheadCount, + behindCount, + }; + }); + + const status: GitCoreShape["status"] = (input) => + statusDetails(input.cwd).pipe( + Effect.map((details) => ({ + branch: details.branch, + hasWorkingTreeChanges: details.hasWorkingTreeChanges, + workingTree: details.workingTree, + hasUpstream: details.hasUpstream, + aheadCount: details.aheadCount, + behindCount: details.behindCount, + openPr: null, + })), + ); + + const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd) => + Effect.gen(function* () { + yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); + + const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [ + "diff", + "--cached", + "--name-status", + ]); + if (trimStdout(stagedSummary).length === 0) { + return null; + } + + const stagedPatch = yield* runGitStdout("GitCore.prepareCommitContext.stagedPatch", cwd, [ + "diff", + "--cached", + "--patch", + "--minimal", + ]); + + return { + stagedSummary, + stagedPatch, + }; + }); + + const commit: GitCoreShape["commit"] = (cwd, subject, body) => + Effect.gen(function* () { + const args = ["commit", "-m", subject]; + const trimmedBody = body.trim(); + if (trimmedBody.length > 0) { + args.push("-m", trimmedBody); + } + yield* runGit("GitCore.commit.commit", cwd, args); + const commitSha = trimStdout( + yield* runGitStdout("GitCore.commit.revParseHead", cwd, ["rev-parse", "HEAD"]), + ); + return { commitSha }; + }); + + const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = (cwd, fallbackBranch) => + Effect.gen(function* () { + const details = yield* statusDetails(cwd); + const branch = details.branch ?? fallbackBranch; + if (!branch) { + return yield* createGitCommandError( + "GitCore.pushCurrentBranch", + cwd, + ["push"], + "Cannot push from detached HEAD.", + ); + } + + if (details.hasUpstream && details.aheadCount === 0 && details.behindCount === 0) { + return { + status: "skipped_up_to_date" as const, + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + }; + } + + if (!details.hasUpstream) { + yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [ + "push", + "-u", + "origin", + branch, + ]); + return { + status: "pushed" as const, + branch, + upstreamBranch: `origin/${branch}`, + setUpstream: true, + }; + } + + yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]); + return { + status: "pushed" as const, + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + setUpstream: false, + }; + }); + + const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = (cwd) => + Effect.gen(function* () { + const details = yield* statusDetails(cwd); + const branch = details.branch; + if (!branch) { + return yield* createGitCommandError( + "GitCore.pullCurrentBranch", + cwd, + ["pull", "--ff-only"], + "Cannot pull from detached HEAD.", + ); + } + if (!details.hasUpstream) { + return yield* createGitCommandError( + "GitCore.pullCurrentBranch", + cwd, + ["pull", "--ff-only"], + "Current branch has no upstream configured. Push with upstream first.", + ); + } + const beforeSha = trimStdout( + yield* runGitStdout( + "GitCore.pullCurrentBranch.beforeSha", + cwd, + ["rev-parse", "HEAD"], + true, + ), + ); + yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { + timeoutMs: 30_000, + fallbackErrorMessage: "git pull failed", + }); + const afterSha = trimStdout( + yield* runGitStdout("GitCore.pullCurrentBranch.afterSha", cwd, ["rev-parse", "HEAD"], true), + ); + const refreshed = yield* statusDetails(cwd); + return { + status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", + branch, + upstreamBranch: refreshed.upstreamRef, + }; + }); + + const readRangeContext: GitCoreShape["readRangeContext"] = (cwd, baseBranch) => + Effect.gen(function* () { + const range = `${baseBranch}..HEAD`; + const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( + [ + runGitStdout("GitCore.readRangeContext.log", cwd, ["log", "--oneline", range]), + runGitStdout("GitCore.readRangeContext.diffStat", cwd, ["diff", "--stat", range]), + runGitStdout("GitCore.readRangeContext.diffPatch", cwd, [ + "diff", + "--patch", + "--minimal", + range, + ]), + ], + { concurrency: "unbounded" }, + ); + + return { + commitSummary, + diffSummary, + diffPatch, + }; + }); + + const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) => + runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe( + Effect.map((stdout) => { + const value = trimStdout(stdout); + return value.length > 0 ? value : null; + }), + ); + + const listBranches: GitCoreShape["listBranches"] = (input) => + Effect.gen(function* () { + const branchRecencyPromise = readBranchRecency(input.cwd).pipe( + Effect.catch(() => Effect.succeed(new Map())), + ); + const result = yield* executeGit( + "GitCore.listBranches.branchNoColor", + input.cwd, + ["branch", "--no-color"], + { + timeoutMs: 10_000, + allowNonZeroExit: true, + }, + ); + + if (result.code !== 0) { + const stderr = result.stderr.trim(); + if (stderr.toLowerCase().includes("not a git repository")) { + return { branches: [], isRepo: false }; + } + return yield* createGitCommandError( + "GitCore.listBranches", + input.cwd, + ["branch", "--no-color"], + stderr || "git branch failed", + ); + } + + const [defaultRef, worktreeList, branchLastCommit] = yield* Effect.all( + [ + executeGit( + "GitCore.listBranches.defaultRef", + input.cwd, + ["symbolic-ref", "refs/remotes/origin/HEAD"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + executeGit( + "GitCore.listBranches.worktreeList", + input.cwd, + ["worktree", "list", "--porcelain"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + branchRecencyPromise, + ], + { concurrency: "unbounded" }, + ); + + const defaultBranch = + defaultRef.code === 0 + ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") + : null; + + const worktreeMap = new Map(); + if (worktreeList.code === 0) { + let currentPath: string | null = null; + for (const line of worktreeList.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + const candidatePath = line.slice("worktree ".length); + const exists = yield* fileSystem.stat(candidatePath).pipe( + Effect.map(() => true), + Effect.catch(() => Effect.succeed(false)), + ); + currentPath = exists ? candidatePath : null; + } else if (line.startsWith("branch refs/heads/") && currentPath) { + worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); + } else if (line === "") { + currentPath = null; + } + } + } + + const branches = result.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const name = line.replace(/^[*+]\s+/, ""); + return { + name, + current: line.startsWith("* "), + isDefault: name === defaultBranch, + worktreePath: worktreeMap.get(name) ?? null, + }; + }) + .toSorted((a, b) => { + const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; + const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; + if (aPriority !== bPriority) return aPriority - bPriority; + + const aLastCommit = branchLastCommit.get(a.name) ?? 0; + const bLastCommit = branchLastCommit.get(b.name) ?? 0; + if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; + return a.name.localeCompare(b.name); + }); + + return { branches, isRepo: true }; + }); + + const createWorktree: GitCoreShape["createWorktree"] = (input) => + Effect.gen(function* () { + const sanitizedBranch = input.newBranch.replace(/\//g, "-"); + const repoName = path.basename(input.cwd); + const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp"; + const worktreePath = + input.path ?? path.join(homeDir, ".t3", "worktrees", repoName, sanitizedBranch); + + yield* executeGit( + "GitCore.createWorktree", + input.cwd, + ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch], + { + fallbackErrorMessage: "git worktree add failed", + }, + ); + + return { + worktree: { + path: worktreePath, + branch: input.newBranch, + }, + }; + }); + + const removeWorktree: GitCoreShape["removeWorktree"] = (input) => + Effect.gen(function* () { + const args = ["worktree", "remove"]; + if (input.force) { + args.push("--force"); + } + args.push(input.path); + yield* executeGit("GitCore.removeWorktree", input.cwd, args, { + timeoutMs: 15_000, + fallbackErrorMessage: "git worktree remove failed", + }).pipe( + Effect.mapError((error) => + createGitCommandError( + "GitCore.removeWorktree", + input.cwd, + args, + `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`, + error, + ), + ), + ); + }); + + const createBranch: GitCoreShape["createBranch"] = (input) => + executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { + timeoutMs: 10_000, + fallbackErrorMessage: "git branch create failed", + }).pipe(Effect.asVoid); + + const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) => + Effect.gen(function* () { + yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, ["checkout", input.branch], { + timeoutMs: 10_000, + fallbackErrorMessage: "git checkout failed", + }); + + // Refresh upstream refs in the background so checkout remains responsive. + yield* Effect.sync(() => { + void Effect.runPromise( + refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.catch(() => Effect.void)), + ); + }); + }); + + const initRepo: GitCoreShape["initRepo"] = (input) => + executeGit("GitCore.initRepo", input.cwd, ["init"], { + timeoutMs: 10_000, + fallbackErrorMessage: "git init failed", + }).pipe(Effect.asVoid); + + return { + status, + statusDetails, + prepareCommitContext, + commit, + pushCurrentBranch, + pullCurrentBranch, + readRangeContext, + readConfigValue, + listBranches, + createWorktree, + removeWorktree, + createBranch, + checkoutBranch, + initRepo, + } satisfies GitCoreShape; +}); + +export const GitCoreLive = Layer.effect(GitCore, makeGitCore); diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0e6ebef6f6c158d5684fb8b70a71355f3e6dd43 --- /dev/null +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -0,0 +1,178 @@ +import { Effect, Layer } from "effect"; + +import { runProcess } from "../../processRunner"; +import { GitHubCliError } from "../Errors.ts"; +import { GitHubCli, type GitHubCliShape } from "../Services/GitHubCli.ts"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown): GitHubCliError { + if (error instanceof Error) { + if (error.message.includes("Command not found: gh")) { + return new GitHubCliError({ + operation, + detail: "GitHub CLI (`gh`) is required but not available on PATH.", + cause: error, + }); + } + + const lower = error.message.toLowerCase(); + if ( + lower.includes("authentication failed") || + lower.includes("not logged in") || + lower.includes("gh auth login") || + lower.includes("no oauth token") + ) { + return new GitHubCliError({ + operation, + detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", + cause: error, + }); + } + + return new GitHubCliError({ + operation, + detail: `GitHub CLI command failed: ${error.message}`, + cause: error, + }); + } + + return new GitHubCliError({ + operation, + detail: "GitHub CLI command failed.", + cause: error, + }); +} + +function parseOpenPullRequests(raw: string): ReadonlyArray<{ + number: number; + title: string; + url: string; + baseRefName: string; + headRefName: string; +}> { + const trimmed = raw.trim(); + if (trimmed.length === 0) return []; + + const parsed: unknown = JSON.parse(trimmed); + if (!Array.isArray(parsed)) { + throw new Error("GitHub CLI returned non-array JSON."); + } + + const result: Array<{ + number: number; + title: string; + url: string; + baseRefName: string; + headRefName: string; + }> = []; + for (const entry of parsed) { + if (!entry || typeof entry !== "object") { + continue; + } + const record = entry as Record; + const number = record.number; + const title = record.title; + const url = record.url; + const baseRefName = record.baseRefName; + const headRefName = record.headRefName; + if ( + typeof number !== "number" || + !Number.isInteger(number) || + number <= 0 || + typeof title !== "string" || + typeof url !== "string" || + typeof baseRefName !== "string" || + typeof headRefName !== "string" + ) { + continue; + } + result.push({ + number, + title, + url, + baseRefName, + headRefName, + }); + } + + return result; +} + +const makeGitHubCli = Effect.sync(() => { + const execute: GitHubCliShape["execute"] = (input) => + Effect.tryPromise({ + try: () => + runProcess("gh", input.args, { + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }), + catch: (error) => normalizeGitHubCliError("execute", error), + }); + + const service = { + execute, + listOpenPullRequests: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + input.headBranch, + "--state", + "open", + "--limit", + String(input.limit ?? 1), + "--json", + "number,title,url,baseRefName,headRefName", + ], + }).pipe( + Effect.map((result) => result.stdout), + Effect.flatMap((raw) => + Effect.try({ + try: () => parseOpenPullRequests(raw), + catch: (error: unknown) => + new GitHubCliError({ + operation: "listOpenPullRequests", + detail: + error instanceof Error + ? `GitHub CLI returned invalid PR list JSON: ${error.message}` + : "GitHub CLI returned invalid PR list JSON.", + ...(error !== undefined ? { cause: error } : {}), + }), + }), + ), + ), + createPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "create", + "--base", + input.baseBranch, + "--head", + input.headBranch, + "--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((value) => { + const trimmed = value.stdout.trim(); + return trimmed.length > 0 ? trimmed : null; + }), + ), + } satisfies GitHubCliShape; + + return service; +}); + +export const GitHubCliLive = Layer.effect(GitHubCli, makeGitHubCli); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d2dec1b05b2be57bba5be608fc01cc008833c77 --- /dev/null +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -0,0 +1,660 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { NodeServices } from "@effect/platform-node"; +import { it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; +import { expect } from "vitest"; + +import { GitHubCliError, TextGenerationError } from "../Errors.ts"; +import { type GitManagerShape } from "../Services/GitManager.ts"; +import { + type GitHubCliShape, + type GitHubPullRequestSummary, + GitHubCli, +} from "../Services/GitHubCli.ts"; +import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; +import { GitServiceLive } from "./GitService.ts"; +import { GitService } from "../Services/GitService.ts"; +import { GitCoreLive } from "./GitCore.ts"; +import { makeGitManager } from "./GitManager.ts"; + +interface FakeGhScenario { + prListSequence?: string[]; + createdPrUrl?: string; + defaultBranch?: string; + failWith?: Error; +} + +interface FakeGitTextGeneration { + generateCommitMessage: (input: { + cwd: string; + branch: string | null; + stagedSummary: string; + stagedPatch: string; + }) => Effect.Effect<{ subject: string; body: string }, unknown>; + generatePrContent: (input: { + cwd: string; + baseBranch: string; + headBranch: string; + commitSummary: string; + diffSummary: string; + diffPatch: string; + }) => Effect.Effect<{ title: string; body: string }, unknown>; +} + +function makeTempDir( + prefix: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); +} + +function runGit( + cwd: string, + args: readonly string[], + allowNonZeroExit = false, +): Effect.Effect< + { readonly code: number; readonly stdout: string; readonly stderr: string }, + unknown, + GitService +> { + return Effect.gen(function* () { + const gitService = yield* GitService; + return yield* gitService.execute({ + operation: "GitManager.test.runGit", + cwd, + args, + allowNonZeroExit, + }); + }); +} + +function initRepo( + cwd: string, +): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* runGit(cwd, ["init", "--initial-branch=main"]); + yield* runGit(cwd, ["config", "user.email", "test@example.com"]); + yield* runGit(cwd, ["config", "user.name", "Test User"]); + yield* fs.writeFileString(path.join(cwd, "README.md"), "hello\n"); + yield* runGit(cwd, ["add", "README.md"]); + yield* runGit(cwd, ["commit", "-m", "Initial commit"]); + }); +} + +function createBareRemote(): Effect.Effect< + string, + unknown, + FileSystem.FileSystem | Scope.Scope | GitService +> { + return Effect.gen(function* () { + const remoteDir = yield* makeTempDir("t3code-git-remote-"); + yield* runGit(remoteDir, ["init", "--bare"]); + return remoteDir; + }); +} + +function createTextGeneration(overrides: Partial = {}): TextGenerationShape { + const implementation: FakeGitTextGeneration = { + generateCommitMessage: () => + Effect.succeed({ + subject: "Implement stacked git actions", + body: "", + }), + generatePrContent: () => + Effect.succeed({ + title: "Add stacked git actions", + body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", + }), + ...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 } : {}), + }), + ), + ), + }; +} + +const normalizeGhError = (error: Error): GitHubCliError => { + if (error.message.includes("Command not found: gh")) { + return new GitHubCliError({ + operation: "execute", + detail: "GitHub CLI (`gh`) is required but not available on PATH.", + cause: error, + }); + } + const lower = error.message.toLowerCase(); + if ( + lower.includes("authentication failed") || + lower.includes("not logged in") || + lower.includes("gh auth login") || + lower.includes("no oauth token") + ) { + return new GitHubCliError({ + operation: "execute", + detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", + cause: error, + }); + } + return new GitHubCliError({ + operation: "execute", + detail: error.message, + cause: error, + }); +}; + +function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { + service: GitHubCliShape; + ghCalls: string[]; +} { + const prListQueue = [...(scenario.prListSequence ?? [])]; + const ghCalls: string[] = []; + + const execute: GitHubCliShape["execute"] = (input) => { + const args = [...input.args]; + ghCalls.push(args.join(" ")); + + if (scenario.failWith) { + return Effect.fail(normalizeGhError(scenario.failWith)); + } + + if (args[0] === "pr" && args[1] === "list") { + const stdout = (prListQueue.shift() ?? "[]") + "\n"; + return Effect.succeed({ + stdout, + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + } + + if (args[0] === "pr" && args[1] === "create") { + return Effect.succeed({ + stdout: + (scenario.createdPrUrl ?? "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + } + + if (args[0] === "pr" && args[1] === "view") { + return Effect.succeed({ + stdout: "", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + } + + if (args[0] === "repo" && args[1] === "view") { + return Effect.succeed({ + stdout: `${scenario.defaultBranch ?? "main"}\n`, + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + } + + return Effect.fail( + new GitHubCliError({ + operation: "execute", + detail: `Unexpected gh command: ${args.join(" ")}`, + }), + ); + }; + + return { + service: { + execute, + listOpenPullRequests: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + input.headBranch, + "--state", + "open", + "--limit", + String(input.limit ?? 1), + "--json", + "number,title,url,baseRefName,headRefName", + ], + }).pipe( + Effect.map( + (result) => JSON.parse(result.stdout) as ReadonlyArray, + ), + ), + createPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "create", + "--base", + input.baseBranch, + "--head", + input.headBranch, + "--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; + }), + ), + }, + ghCalls, + }; +} + +function runStackedAction( + manager: GitManagerShape, + input: { + cwd: string; + action: "commit" | "commit_push" | "commit_push_pr"; + commitMessage?: string; + }, +) { + return manager.runStackedAction(input); +} + +function makeManager(input?: { + ghScenario?: FakeGhScenario; + textGeneration?: Partial; +}) { + const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); + const textGeneration = createTextGeneration(input?.textGeneration); + + const gitCoreLayer = GitCoreLive.pipe( + Layer.provideMerge(GitServiceLive), + Layer.provideMerge(NodeServices.layer), + ); + + const managerLayer = Layer.mergeAll( + Layer.succeed(GitHubCli, gitHubCli), + Layer.succeed(TextGeneration, textGeneration), + gitCoreLayer, + NodeServices.layer, + ); + + return makeGitManager.pipe( + Effect.provide(managerLayer), + Effect.map((manager) => ({ manager, ghCalls })), + ); +} + +const GitManagerTestLayer = Layer.provideMerge(GitServiceLive, NodeServices.layer); + +it.layer(GitManagerTestLayer)("GitManager", (it) => { + it.effect("status includes open PR metadata when branch already has an open PR", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 13, + title: "Existing PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/13", + baseRefName: "main", + headRefName: "feature/status-open-pr", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.branch).toBe("feature/status-open-pr"); + expect(status.openPr).toEqual({ + number: 13, + title: "Existing PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/13", + baseBranch: "main", + headBranch: "feature/status-open-pr", + }); + }), + ); + + it.effect("status is resilient to gh lookup failures and returns openPr 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 Error("Command not found: gh"), + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.branch).toBe("feature/status-no-gh"); + expect(status.openPr).toBeNull(); + }), + ); + + it.effect("creates a commit when working tree is dirty", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n"); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + }); + + expect(result.commit.status).toBe("created"); + expect(result.push.status).toBe("skipped_not_requested"); + expect(result.pr.status).toBe("skipped_not_requested"); + expect( + yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( + Effect.map((result) => result.stdout.trim()), + ), + ).toBe("Implement stacked git actions"); + }), + ); + + it.effect("uses custom commit message when provided", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n"); + let generatedCount = 0; + + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: () => + Effect.sync(() => { + generatedCount += 1; + return { + subject: "this should not be used", + body: "", + }; + }), + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + commitMessage: "feat: custom summary line\n\n- details from user", + }); + + 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("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.commit.status).toBe("skipped_no_changes"); + expect(result.push.status).toBe("skipped_not_requested"); + expect(result.pr.status).toBe("skipped_not_requested"); + }), + ); + + it.effect("commits and pushes with upstream auto-setup when needed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push", + }); + + expect(result.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("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.commit.status).toBe("skipped_no_changes"); + expect(result.push.status).toBe("skipped_up_to_date"); + }), + ); + + it.effect("returns existing PR metadata for commit/push/pr action", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 42, + title: "Existing PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/existing-pr", + }, + ]), + ], + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("opened_existing"); + expect(result.pr.number).toBe(42); + expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); + }), + ); + + it.effect("creates PR when one does not already exist", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); + yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + JSON.stringify([ + { + number: 88, + title: "Add stacked git actions", + url: "https://github.com/pingdotgg/codething-mvp/pull/88", + baseRefName: "main", + headRefName: "feature-create-pr", + }, + ]), + ], + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(88); + expect( + ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), + ).toBe(true); + expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); + }), + ); + + it.effect("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 instanceof Error ? error.message : String(error))), + ); + 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 Error("Command not found: gh"), + }, + }); + + const errorMessage = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }).pipe( + Effect.flip, + Effect.map((error) => (error instanceof Error ? error.message : String(error))), + ); + 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 Error("authentication failed"), + }, + }); + + const errorMessage = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }).pipe( + Effect.flip, + Effect.map((error) => (error instanceof Error ? error.message : String(error))), + ); + expect(errorMessage).toContain("gh auth login"); + }), + ); +}); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts new file mode 100644 index 0000000000000000000000000000000000000000..375dbdb8dddf37cb550b26b7cf60007ddfb7b96f --- /dev/null +++ b/apps/server/src/git/Layers/GitManager.ts @@ -0,0 +1,318 @@ +import { randomUUID } from "node:crypto"; + +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { GitManagerError } from "../Errors.ts"; +import { GitManager, type GitManagerShape } from "../Services/GitManager.ts"; +import { GitCore } from "../Services/GitCore.ts"; +import { GitHubCli } from "../Services/GitHubCli.ts"; +import { TextGeneration } from "../Services/TextGeneration.ts"; + +interface OpenPrInfo { + number: number; + title: string; + url: string; + baseRefName: string; + headRefName: string; +} + +function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { + return new GitManagerError({ + operation, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function limitContext(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}\n\n[truncated]`; +} + +function sanitizeCommitMessage(generated: { + subject: string; + body: string; +}): { + subject: string; + body: string; +} { + const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + const subject = rawSubject.replace(/[.]+$/g, "").trim(); + const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files"; + return { + subject: safeSubject, + body: generated.body.trim(), + }; +} + +function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null { + const normalized = raw.replace(/\r\n/g, "\n").trim(); + if (normalized.length === 0) { + return null; + } + + const [firstLine, ...rest] = normalized.split("\n"); + const subject = firstLine?.trim() ?? ""; + if (subject.length === 0) { + return null; + } + + return { + subject, + body: rest.join("\n").trim(), + }; +} + +function extractBranchFromRef(ref: string): string { + const normalized = ref.trim(); + + if (normalized.startsWith("refs/remotes/")) { + const withoutPrefix = normalized.slice("refs/remotes/".length); + const firstSlash = withoutPrefix.indexOf("/"); + if (firstSlash === -1) { + return withoutPrefix.trim(); + } + return withoutPrefix.slice(firstSlash + 1).trim(); + } + + const firstSlash = normalized.indexOf("/"); + if (firstSlash === -1) { + return normalized; + } + return normalized.slice(firstSlash + 1).trim(); +} + +function toStatusOpenPr(pr: OpenPrInfo): { + number: number; + title: string; + url: string; + baseBranch: string; + headBranch: string; +} { + return { + number: pr.number, + title: pr.title, + url: pr.url, + baseBranch: pr.baseRefName, + headBranch: pr.headRefName, + }; +} + +export const makeGitManager = Effect.gen(function* () { + const gitCore = yield* GitCore; + const gitHubCli = yield* GitHubCli; + const textGeneration = yield* TextGeneration; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; + + const findOpenPr = (cwd: string, branch: string) => + gitHubCli + .listOpenPullRequests({ + cwd, + headBranch: branch, + limit: 1, + }) + .pipe( + Effect.map((prs) => { + const [first] = prs; + if (!first) { + return null; + } + return { + number: first.number, + title: first.title, + url: first.url, + baseRefName: first.baseRefName, + headRefName: first.headRefName, + } satisfies OpenPrInfo; + }), + ); + + const resolveBaseBranch = (cwd: string, branch: string, upstreamRef: string | null) => + Effect.gen(function* () { + const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); + if (configured) return configured; + + if (upstreamRef) { + const upstreamBranch = extractBranchFromRef(upstreamRef); + if (upstreamBranch.length > 0 && upstreamBranch !== branch) { + return upstreamBranch; + } + } + + const defaultFromGh = yield* gitHubCli + .getDefaultBranch({ cwd }) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (defaultFromGh) { + return defaultFromGh; + } + + return "main"; + }); + + const runCommitStep = (cwd: string, branch: string | null, commitMessage?: string) => + Effect.gen(function* () { + const context = yield* gitCore.prepareCommitContext(cwd); + if (!context) { + return { status: "skipped_no_changes" as const }; + } + + let generated = parseCustomCommitMessage(commitMessage ?? ""); + if (!generated) { + generated = yield* textGeneration.generateCommitMessage({ + cwd, + branch, + stagedSummary: limitContext(context.stagedSummary, 8_000), + stagedPatch: limitContext(context.stagedPatch, 50_000), + }).pipe(Effect.map((result) => sanitizeCommitMessage(result))); + } + + const { commitSha } = yield* gitCore.commit(cwd, generated.subject, generated.body); + return { + status: "created" as const, + commitSha, + subject: generated.subject, + }; + }); + + const runPrStep = (cwd: string, fallbackBranch: string | null) => + Effect.gen(function* () { + const details = yield* gitCore.statusDetails(cwd); + const branch = details.branch ?? fallbackBranch; + if (!branch) { + return yield* gitManagerError("runPrStep", "Cannot create a pull request from detached HEAD."); + } + if (!details.hasUpstream) { + return yield* gitManagerError( + "runPrStep", + "Current branch has not been pushed. Push before creating a PR.", + ); + } + + const existing = yield* findOpenPr(cwd, branch); + if (existing) { + return { + status: "opened_existing" as const, + url: existing.url, + number: existing.number, + baseBranch: existing.baseRefName, + headBranch: existing.headRefName, + title: existing.title, + }; + } + + const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef); + const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); + + const generated = yield* textGeneration.generatePrContent({ + cwd, + baseBranch, + headBranch: branch, + commitSummary: limitContext(rangeContext.commitSummary, 20_000), + diffSummary: limitContext(rangeContext.diffSummary, 20_000), + diffPatch: limitContext(rangeContext.diffPatch, 60_000), + }); + + const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`); + yield* fileSystem + .writeFileString(bodyFile, generated.body) + .pipe( + Effect.mapError((cause) => + gitManagerError( + "runPrStep", + "Failed to write pull request body temp file.", + cause, + ), + ), + ); + yield* gitHubCli + .createPullRequest({ + cwd, + baseBranch, + headBranch: branch, + title: generated.title, + bodyFile, + }) + .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); + + const created = yield* findOpenPr(cwd, branch); + if (!created) { + return { + status: "created" as const, + baseBranch, + headBranch: branch, + title: generated.title, + }; + } + + return { + status: "created" as const, + url: created.url, + number: created.number, + baseBranch: created.baseRefName, + headBranch: created.headRefName, + title: created.title, + }; + }); + + const status: GitManagerShape["status"] = Effect.fnUntraced(function* (input) { + const details = yield* gitCore.statusDetails(input.cwd); + + const openPr = + details.branch && details.hasUpstream + ? yield* findOpenPr(input.cwd, details.branch).pipe( + Effect.map((pr) => (pr ? toStatusOpenPr(pr) : null)), + Effect.catch(() => Effect.succeed(null)), + ) + : null; + + return { + branch: details.branch, + hasWorkingTreeChanges: details.hasWorkingTreeChanges, + workingTree: details.workingTree, + hasUpstream: details.hasUpstream, + aheadCount: details.aheadCount, + behindCount: details.behindCount, + openPr, + }; + }); + + const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced(function* (input) { + const wantsPush = input.action !== "commit"; + const wantsPr = input.action === "commit_push_pr"; + + const initialStatus = yield* gitCore.statusDetails(input.cwd); + if (wantsPush && !initialStatus.branch) { + return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); + } + if (wantsPr && !initialStatus.branch) { + return yield* gitManagerError("runStackedAction", "Cannot create a pull request from detached HEAD."); + } + + const commit = yield* runCommitStep(input.cwd, initialStatus.branch, input.commitMessage); + + const push = wantsPush + ? yield* gitCore.pushCurrentBranch(input.cwd, initialStatus.branch) + : { status: "skipped_not_requested" as const }; + + const pr = wantsPr + ? yield* runPrStep(input.cwd, initialStatus.branch) + : { status: "skipped_not_requested" as const }; + + return { + action: input.action, + commit, + push, + pr, + }; + }); + + return { + status, + runStackedAction, + } satisfies GitManagerShape; +}); + +export const GitManagerLive = Layer.effect(GitManager, makeGitManager); diff --git a/apps/server/src/git/Layer.test.ts b/apps/server/src/git/Layers/GitService.test.ts similarity index 92% rename from apps/server/src/git/Layer.test.ts rename to apps/server/src/git/Layers/GitService.test.ts index 1426e28705f2c833100a543c4855147d7f3876fa..adaf32edd72afb783a840ceca8a9f68cc5bf7f28 100644 --- a/apps/server/src/git/Layer.test.ts +++ b/apps/server/src/git/Layers/GitService.test.ts @@ -2,9 +2,9 @@ import { NodeServices } from "@effect/platform-node"; import { it, assert } from "@effect/vitest"; import { Effect, Layer } from "effect"; -import { GitCommandError } from "./Errors.ts"; -import { GitServiceLive } from "./Layer.ts"; -import { GitService } from "./Service.ts"; +import { GitCommandError } from "../Errors.ts"; +import { GitServiceLive } from "./GitService.ts"; +import { GitService } from "../Services/GitService.ts"; const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer)); diff --git a/apps/server/src/git/Layer.ts b/apps/server/src/git/Layers/GitService.ts similarity index 96% rename from apps/server/src/git/Layer.ts rename to apps/server/src/git/Layers/GitService.ts index a367e86162003590e31820f91cbbe4469842d463..0f62d90e5253347a63edfa79354d516e978243a6 100644 --- a/apps/server/src/git/Layer.ts +++ b/apps/server/src/git/Layers/GitService.ts @@ -8,8 +8,13 @@ */ import { Effect, Layer, Option, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError } from "./Errors.ts"; -import { ExecuteGitInput, ExecuteGitResult, GitService, GitServiceShape } from "./Service.ts"; +import { GitCommandError } from "../Errors.ts"; +import { + ExecuteGitInput, + ExecuteGitResult, + GitService, + GitServiceShape, +} from "../Services/GitService.ts"; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d43ad19f3ab2f53ac944945e3d8aa9f004ab5b7 --- /dev/null +++ b/apps/server/src/git/Services/GitCore.ts @@ -0,0 +1,146 @@ +/** + * GitCore - Effect service contract for low-level Git operations. + * + * Wraps core repository primitives used by higher-level orchestration + * services and WebSocket routes. + * + * @module GitCore + */ +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; +import type { + GitCheckoutInput, + GitCreateBranchInput, + GitCreateWorktreeInput, + GitCreateWorktreeResult, + GitInitInput, + GitListBranchesInput, + GitListBranchesResult, + GitPullResult, + GitRemoveWorktreeInput, + GitStatusInput, + GitStatusResult, +} from "@t3tools/contracts"; + +import type { GitCommandError } from "../Errors.ts"; + +export interface GitStatusDetails extends Omit { + upstreamRef: string | null; +} + +export interface GitPreparedCommitContext { + stagedSummary: string; + stagedPatch: string; +} + +export interface GitPushResult { + status: "pushed" | "skipped_up_to_date"; + branch: string; + upstreamBranch?: string | undefined; + setUpstream?: boolean | undefined; +} + +export interface GitRangeContext { + commitSummary: string; + diffSummary: string; + diffPatch: string; +} + +/** + * GitCoreShape - Service API for low-level Git repository interactions. + */ +export interface GitCoreShape { + /** + * Read Git status for a repository. + */ + readonly status: (input: GitStatusInput) => Effect.Effect; + + /** + * Read detailed working tree / branch status for a repository. + */ + readonly statusDetails: (cwd: string) => Effect.Effect; + + /** + * Build staged change context for commit generation. + */ + readonly prepareCommitContext: ( + cwd: string, + ) => Effect.Effect; + + /** + * Create a commit with provided subject/body. + */ + readonly commit: ( + cwd: string, + subject: string, + body: string, + ) => Effect.Effect<{ commitSha: string }, GitCommandError>; + + /** + * Push current branch, setting upstream if needed. + */ + readonly pushCurrentBranch: ( + cwd: string, + fallbackBranch: string | null, + ) => Effect.Effect; + + /** + * Collect commit/diff context between base branch and current HEAD. + */ + readonly readRangeContext: ( + cwd: string, + baseBranch: string, + ) => Effect.Effect; + + /** + * Read a Git config value from the local repository. + */ + readonly readConfigValue: ( + cwd: string, + key: string, + ) => Effect.Effect; + + /** + * List local branches and branch metadata. + */ + readonly listBranches: ( + input: GitListBranchesInput, + ) => Effect.Effect; + + /** + * Pull current branch from upstream using fast-forward only. + */ + readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + + /** + * Create a worktree and branch from a base branch. + */ + readonly createWorktree: ( + input: GitCreateWorktreeInput, + ) => Effect.Effect; + + /** + * Remove an existing worktree. + */ + readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect; + + /** + * Create a local branch. + */ + readonly createBranch: (input: GitCreateBranchInput) => Effect.Effect; + + /** + * Checkout an existing branch and refresh its upstream metadata in background. + */ + readonly checkoutBranch: (input: GitCheckoutInput) => Effect.Effect; + + /** + * Initialize a repository in the provided directory. + */ + readonly initRepo: (input: GitInitInput) => Effect.Effect; +} + +/** + * GitCore - Service tag for low-level Git repository operations. + */ +export class GitCore extends ServiceMap.Service()("git/GitCore") {} diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f63e2ba65767298afb932a60417a7481c3fcda2 --- /dev/null +++ b/apps/server/src/git/Services/GitHubCli.ts @@ -0,0 +1,66 @@ +/** + * GitHubCli - Effect service contract for `gh` process interactions. + * + * Provides thin command execution helpers used by Git workflow orchestration. + * + * @module GitHubCli + */ +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProcessRunResult } from "../../processRunner"; +import type { GitHubCliError } from "../Errors.ts"; + +export interface GitHubPullRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; +} + +/** + * GitHubCliShape - Service API for executing GitHub CLI commands. + */ +export interface GitHubCliShape { + /** + * Execute a GitHub CLI command and return full process output. + */ + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + /** + * List open pull requests for a head branch. + */ + readonly listOpenPullRequests: (input: { + readonly cwd: string; + readonly headBranch: string; + readonly limit?: number; + }) => Effect.Effect, GitHubCliError>; + + /** + * Create a pull request from branch context and body file. + */ + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headBranch: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + /** + * Resolve repository default branch through GitHub metadata. + */ + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; +} + +/** + * GitHubCli - Service tag for GitHub CLI process execution. + */ +export class GitHubCli extends ServiceMap.Service()("git/GitHubCli") {} diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts new file mode 100644 index 0000000000000000000000000000000000000000..9dfb7b668fb0a4ffa3e7495381d8c14ec1954195 --- /dev/null +++ b/apps/server/src/git/Services/GitManager.ts @@ -0,0 +1,41 @@ +/** + * GitManager - Effect service contract for stacked Git workflows. + * + * Orchestrates status inspection and commit/push/PR flows by composing + * lower-level Git and external tool services. + * + * @module GitManager + */ +import { + GitRunStackedActionInput, + GitRunStackedActionResult, + GitStatusInput, + GitStatusResult, +} from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; +import type { GitManagerServiceError } from "../Errors.ts"; + +/** + * GitManagerShape - Service API for high-level Git workflow actions. + */ +export interface GitManagerShape { + /** + * Read current repository Git status plus open PR metadata when available. + */ + readonly status: (input: GitStatusInput) => Effect.Effect; + + /** + * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`). + */ + readonly runStackedAction: ( + input: GitRunStackedActionInput, + ) => Effect.Effect; +} + +/** + * GitManager - Service tag for stacked Git workflow orchestration. + */ +export class GitManager extends ServiceMap.Service()( + "git/GitManager", +) {} diff --git a/apps/server/src/git/Service.ts b/apps/server/src/git/Services/GitService.ts similarity index 95% rename from apps/server/src/git/Service.ts rename to apps/server/src/git/Services/GitService.ts index 2619e13cd4f7e9183ac1251d8da6d25d9fd1f4b8..185ec1d14552289daa2c812f1ba12289d564392b 100644 --- a/apps/server/src/git/Service.ts +++ b/apps/server/src/git/Services/GitService.ts @@ -9,7 +9,7 @@ import { ServiceMap } from "effect"; import type { Effect } from "effect"; -import type { GitCommandError } from "./Errors.ts"; +import type { GitCommandError } from "../Errors.ts"; export interface ExecuteGitInput { readonly operation: string; diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a64f2bda79e43badae933f4bee563c2b51d8355 --- /dev/null +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -0,0 +1,71 @@ +/** + * TextGeneration - Effect service contract for AI-generated Git content. + * + * Generates commit messages and pull request titles/bodies from repository + * context prepared by Git services. + * + * @module TextGeneration + */ +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { TextGenerationError } from "../Errors.ts"; + +export interface CommitMessageGenerationInput { + cwd: string; + branch: string | null; + stagedSummary: string; + stagedPatch: string; +} + +export interface CommitMessageGenerationResult { + subject: string; + body: string; +} + +export interface PrContentGenerationInput { + cwd: string; + baseBranch: string; + headBranch: string; + commitSummary: string; + diffSummary: string; + diffPatch: string; +} + +export interface PrContentGenerationResult { + title: string; + body: string; +} + +export interface TextGenerationService { + generateCommitMessage( + input: CommitMessageGenerationInput, + ): Promise; + generatePrContent(input: PrContentGenerationInput): Promise; +} + +/** + * TextGenerationShape - Service API for commit/PR text generation. + */ +export interface TextGenerationShape { + /** + * Generate a commit message from staged change context. + */ + readonly generateCommitMessage: ( + input: CommitMessageGenerationInput, + ) => Effect.Effect; + + /** + * Generate pull request title/body from branch and diff context. + */ + readonly generatePrContent: ( + input: PrContentGenerationInput, + ) => Effect.Effect; +} + +/** + * TextGeneration - Service tag for commit and PR text generation. + */ +export class TextGeneration extends ServiceMap.Service()( + "git/TextGeneration", +) {} diff --git a/apps/server/src/gitManager.test.ts b/apps/server/src/gitManager.test.ts deleted file mode 100644 index f2d399c8b8762a023be50147826264d77a0f1910..0000000000000000000000000000000000000000 --- a/apps/server/src/gitManager.test.ts +++ /dev/null @@ -1,482 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { GitManager } from "./gitManager"; -import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner"; - -interface FakeGhScenario { - prListSequence?: string[]; - createdPrUrl?: string; - defaultBranch?: string; - failWith?: Error; -} - -interface FakeGitTextGenerator { - generateCommitMessage: (input: { - cwd: string; - branch: string | null; - stagedSummary: string; - stagedPatch: string; - }) => Promise<{ subject: string; body: string }>; - generatePrContent: (input: { - cwd: string; - baseBranch: string; - headBranch: string; - commitSummary: string; - diffSummary: string; - diffPatch: string; - }) => Promise<{ title: string; body: string }>; -} - -const tempDirs: string[] = []; - -function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -async function runGit( - cwd: string, - args: readonly string[], - allowNonZeroExit = false, -): Promise { - return runProcess("git", args, { cwd, allowNonZeroExit }); -} - -async function runGitStdout(cwd: string, args: readonly string[]): Promise { - const result = await runGit(cwd, args); - return result.stdout.trim(); -} - -async function initRepo(cwd: string): Promise { - await runGit(cwd, ["init", "--initial-branch=main"]); - await runGit(cwd, ["config", "user.email", "test@example.com"]); - await runGit(cwd, ["config", "user.name", "Test User"]); - fs.writeFileSync(path.join(cwd, "README.md"), "hello\n"); - await runGit(cwd, ["add", "README.md"]); - await runGit(cwd, ["commit", "-m", "Initial commit"]); -} - -async function createBareRemote(): Promise { - const remoteDir = makeTempDir("t3code-git-remote-"); - await runProcess("git", ["init", "--bare"], { cwd: remoteDir }); - return remoteDir; -} - -function createTextGenerator(overrides: Partial = {}): FakeGitTextGenerator { - return { - generateCommitMessage: async () => ({ - subject: "Implement stacked git actions", - body: "", - }), - generatePrContent: async () => ({ - title: "Add stacked git actions", - body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", - }), - ...overrides, - }; -} - -function createRunnerWithFakeGh(scenario: FakeGhScenario = {}): { - runner: ( - command: string, - args: readonly string[], - options?: ProcessRunOptions, - ) => Promise; - ghCalls: string[]; -} { - const prListQueue = [...(scenario.prListSequence ?? [])]; - const ghCalls: string[] = []; - - const runner = async ( - command: string, - args: readonly string[], - options?: ProcessRunOptions, - ): Promise => { - if (command !== "gh") { - return runProcess(command, args, options); - } - - ghCalls.push(args.join(" ")); - if (scenario.failWith) { - throw scenario.failWith; - } - - if (args[0] === "pr" && args[1] === "list") { - const stdout = (prListQueue.shift() ?? "[]") + "\n"; - return { - stdout, - stderr: "", - code: 0, - signal: null, - timedOut: false, - }; - } - - if (args[0] === "pr" && args[1] === "create") { - return { - stdout: - (scenario.createdPrUrl ?? "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }; - } - - if (args[0] === "pr" && args[1] === "view") { - return { - stdout: "", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }; - } - - if (args[0] === "repo" && args[1] === "view") { - return { - stdout: `${scenario.defaultBranch ?? "main"}\n`, - stderr: "", - code: 0, - signal: null, - timedOut: false, - }; - } - - throw new Error(`Unexpected gh command: ${args.join(" ")}`); - }; - - return { runner, ghCalls }; -} - -afterEach(() => { - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("GitManager", () => { - it("status includes open PR metadata when branch already has an open PR", async () => { - const repoDir = makeTempDir("t3code-git-manager-"); - await initRepo(repoDir); - await runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]); - const remoteDir = await createBareRemote(); - await runGit(repoDir, ["remote", "add", "origin", remoteDir]); - await runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); - - const { runner } = createRunnerWithFakeGh({ - prListSequence: [ - JSON.stringify([ - { - number: 13, - title: "Existing PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/13", - baseRefName: "main", - headRefName: "feature/status-open-pr", - }, - ]), - ], - }); - - const manager = new GitManager({ - runProcess: runner, - textGenerator: createTextGenerator(), - }); - - const status = await manager.status({ cwd: repoDir }); - expect(status.branch).toBe("feature/status-open-pr"); - expect(status.openPr).toEqual({ - number: 13, - title: "Existing PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/13", - baseBranch: "main", - headBranch: "feature/status-open-pr", - }); - }); - - it("status is resilient to gh lookup failures and returns openPr null", async () => { - const repoDir = makeTempDir("t3code-git-manager-"); - await initRepo(repoDir); - await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); - const remoteDir = await createBareRemote(); - await runGit(repoDir, ["remote", "add", "origin", remoteDir]); - await runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]); - - const { runner } = createRunnerWithFakeGh({ - failWith: new Error("Command not found: gh"), - }); - const manager = new GitManager({ - runProcess: runner, - textGenerator: createTextGenerator(), - }); - - const status = await manager.status({ cwd: repoDir }); - expect(status.branch).toBe("feature/status-no-gh"); - expect(status.openPr).toBeNull(); - }); - - it("creates a commit when working tree is dirty", async () => { - const repoDir = makeTempDir("t3code-git-manager-"); - await initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n"); - - const manager = new GitManager({ - textGenerator: createTextGenerator(), - }); - const result = await manager.runStackedAction({ - cwd: repoDir, - action: "commit", - }); - - expect(result.commit.status).toBe("created"); - expect(result.push.status).toBe("skipped_not_requested"); - expect(result.pr.status).toBe("skipped_not_requested"); - expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%s"])).toBe( - "Implement stacked git actions", - ); - }); - - it("uses custom commit message when provided", async () => { - const repoDir = makeTempDir("t3code-git-manager-"); - await initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n"); - let generatedCount = 0; - - const manager = new GitManager({ - textGenerator: createTextGenerator({ - generateCommitMessage: async () => { - generatedCount += 1; - return { - subject: "this should not be used", - body: "", - }; - }, - }), - }); - const result = await manager.runStackedAction({ - cwd: repoDir, - action: "commit", - commitMessage: "feat: custom summary line\n\n- details from user", - }); - - expect(result.commit.status).toBe("created"); - expect(result.commit.subject).toBe("feat: custom summary line"); - expect(generatedCount).toBe(0); - expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%s"])).toBe( - "feat: custom summary line", - ); - expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%b"])).toContain( - "- details from user", - ); - }); - - it("skips commit when there are no uncommitted changes", async () => { - const repoDir = makeTempDir("t3code-git-manager-"); - await initRepo(repoDir); - - const manager = new GitManager({ - textGenerator: createTextGenerator(), - }); - const result = await manager.runStackedAction({ - cwd: repoDir, - action: "commit", - }); - - 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("commits and pushes with upstream auto-setup when needed", async () => { - const repoDir = makeTempDir("t3code-git-manager-"); - await initRepo(repoDir); - await runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]); - const remoteDir = await createBareRemote(); - await runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); - - const manager = new GitManager({ - textGenerator: createTextGenerator(), - }); - const result = await manager.runStackedAction({ - cwd: repoDir, - action: "commit_push", - }); - - expect(result.commit.status).toBe("created"); - expect(result.push.status).toBe("pushed"); - expect(result.push.setUpstream).toBe(true); - expect(await runGitStdout(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - "origin/feature/stacked-flow", - ); - }); - - it("skips push when branch is already up to date", async () => { - const repoDir = makeTempDir("t3code-git-manager-"); - await initRepo(repoDir); - await runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]); - const remoteDir = await createBareRemote(); - await runGit(repoDir, ["remote", "add", "origin", remoteDir]); - await runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]); - - const manager = new GitManager({ - textGenerator: createTextGenerator(), - }); - const result = await manager.runStackedAction({ - cwd: repoDir, - action: "commit_push", - }); - - expect(result.commit.status).toBe("skipped_no_changes"); - expect(result.push.status).toBe("skipped_up_to_date"); - }); - - it("returns existing PR metadata for commit/push/pr action", async () => { - const repoDir = makeTempDir("t3code-git-manager-"); - await initRepo(repoDir); - await runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]); - const remoteDir = await createBareRemote(); - await runGit(repoDir, ["remote", "add", "origin", remoteDir]); - await runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]); - - const { runner, ghCalls } = createRunnerWithFakeGh({ - prListSequence: [ - JSON.stringify([ - { - number: 42, - title: "Existing PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/existing-pr", - }, - ]), - ], - }); - - const manager = new GitManager({ - runProcess: runner, - textGenerator: createTextGenerator(), - }); - const result = await manager.runStackedAction({ - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(42); - expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); - }); - - it("creates PR when one does not already exist", async () => { - const repoDir = makeTempDir("t3code-git-manager-"); - await initRepo(repoDir); - await runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); - const remoteDir = await createBareRemote(); - await runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); - await runGit(repoDir, ["add", "changes.txt"]); - await runGit(repoDir, ["commit", "-m", "Feature commit"]); - await runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); - await runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]); - - const { runner, ghCalls } = createRunnerWithFakeGh({ - prListSequence: [ - "[]", - JSON.stringify([ - { - number: 88, - title: "Add stacked git actions", - url: "https://github.com/pingdotgg/codething-mvp/pull/88", - baseRefName: "main", - headRefName: "feature-create-pr", - }, - ]), - ], - }); - - const manager = new GitManager({ - runProcess: runner, - textGenerator: createTextGenerator(), - }); - const result = await manager.runStackedAction({ - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("created"); - expect(result.pr.number).toBe(88); - expect( - ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), - ).toBe(true); - expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); - }); - - it("rejects push/pr actions from detached HEAD", async () => { - const repoDir = makeTempDir("t3code-git-manager-"); - await initRepo(repoDir); - await runGit(repoDir, ["checkout", "--detach", "HEAD"]); - - const manager = new GitManager({ - textGenerator: createTextGenerator(), - }); - await expect( - manager.runStackedAction({ - cwd: repoDir, - action: "commit_push", - }), - ).rejects.toThrow("detached HEAD"); - }); - - it("surfaces missing gh binary errors", async () => { - const repoDir = makeTempDir("t3code-git-manager-"); - await initRepo(repoDir); - await runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]); - const remoteDir = await createBareRemote(); - await runGit(repoDir, ["remote", "add", "origin", remoteDir]); - await runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]); - - const { runner } = createRunnerWithFakeGh({ - failWith: new Error("Command not found: gh"), - }); - const manager = new GitManager({ - runProcess: runner, - textGenerator: createTextGenerator(), - }); - - await expect( - manager.runStackedAction({ - cwd: repoDir, - action: "commit_push_pr", - }), - ).rejects.toThrow("GitHub CLI (`gh`) is required"); - }); - - it("surfaces gh auth errors with guidance", async () => { - const repoDir = makeTempDir("t3code-git-manager-"); - await initRepo(repoDir); - await runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]); - const remoteDir = await createBareRemote(); - await runGit(repoDir, ["remote", "add", "origin", remoteDir]); - await runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]); - - const { runner } = createRunnerWithFakeGh({ - failWith: new Error("authentication failed"), - }); - const manager = new GitManager({ - runProcess: runner, - textGenerator: createTextGenerator(), - }); - - await expect( - manager.runStackedAction({ - cwd: repoDir, - action: "commit_push_pr", - }), - ).rejects.toThrow("gh auth login"); - }); -}); diff --git a/apps/server/src/gitManager.ts b/apps/server/src/gitManager.ts deleted file mode 100644 index 0a39a942066275a37f95a9c20f3264592ba7d074..0000000000000000000000000000000000000000 --- a/apps/server/src/gitManager.ts +++ /dev/null @@ -1,462 +0,0 @@ -import { randomUUID } from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import { - GitRunStackedActionInput, - GitRunStackedActionResult, - GitStatusInput, - GitStatusResult, -} from "@t3tools/contracts"; - -import type { - CommitMessageGenerationResult, - PrContentGenerationResult, - TextGenerationService, -} from "./coreServices"; -import { CodexTextGenerator } from "./codexTextGenerator"; -import { GitCoreService } from "./git"; -import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner"; - -const GH_CLI_TIMEOUT_MS = 30_000; - -interface GitManagerDeps { - runProcess?: ( - command: string, - args: readonly string[], - options?: ProcessRunOptions, - ) => Promise; - textGenerator?: TextGenerationService; - gitCore?: GitCoreService; -} - -interface OpenPrInfo { - number: number; - title: string; - url: string; - baseRefName: string; - headRefName: string; -} - -function parseOpenPrList(raw: unknown): OpenPrInfo[] { - if (!Array.isArray(raw)) return []; - - const parsed: OpenPrInfo[] = []; - for (const entry of raw) { - if (!entry || typeof entry !== "object") continue; - const record = entry as Record; - const number = record.number; - const title = record.title; - const url = record.url; - const baseRefName = record.baseRefName; - const headRefName = record.headRefName; - if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) { - continue; - } - if ( - typeof title !== "string" || - typeof url !== "string" || - typeof baseRefName !== "string" || - typeof headRefName !== "string" - ) { - continue; - } - parsed.push({ - number, - title, - url, - baseRefName, - headRefName, - }); - } - return parsed; -} - -function trimStdout(value: string): string { - return value.trim(); -} - -function limitContext(value: string, maxChars: number): string { - if (value.length <= maxChars) return value; - return `${value.slice(0, maxChars)}\n\n[truncated]`; -} - -function sanitizeCommitMessage( - generated: CommitMessageGenerationResult, -): CommitMessageGenerationResult { - const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - const subject = rawSubject.replace(/[.]+$/g, "").trim(); - const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files"; - return { - subject: safeSubject, - body: generated.body.trim(), - }; -} - -function parseCustomCommitMessage(raw: string): CommitMessageGenerationResult | null { - const normalized = raw.replace(/\r\n/g, "\n").trim(); - if (normalized.length === 0) { - return null; - } - - const [firstLine, ...rest] = normalized.split("\n"); - const subject = firstLine?.trim() ?? ""; - if (subject.length === 0) { - return null; - } - - return { - subject, - body: rest.join("\n").trim(), - }; -} - -function extractBranchFromRef(ref: string): string { - const normalized = ref.trim(); - - if (normalized.startsWith("refs/remotes/")) { - const withoutPrefix = normalized.slice("refs/remotes/".length); - const firstSlash = withoutPrefix.indexOf("/"); - if (firstSlash === -1) { - return withoutPrefix.trim(); - } - return withoutPrefix.slice(firstSlash + 1).trim(); - } - - const firstSlash = normalized.indexOf("/"); - if (firstSlash === -1) { - return normalized; - } - return normalized.slice(firstSlash + 1).trim(); -} - -function asCommandNotFound(command: string, error: unknown): Error | undefined { - if (!(error instanceof Error)) return undefined; - if (!error.message.includes(`Command not found: ${command}`)) return undefined; - if (command === "gh") { - return new Error("GitHub CLI (`gh`) is required but not available on PATH."); - } - if (command === "codex") { - return new Error("Codex CLI (`codex`) is required but not available on PATH."); - } - return new Error(`${command} is required but not available on PATH.`); -} - -function normalizeGitHubAuthError(error: unknown): Error | undefined { - if (!(error instanceof Error)) return undefined; - const lower = error.message.toLowerCase(); - if ( - lower.includes("authentication failed") || - lower.includes("not logged in") || - lower.includes("gh auth login") || - lower.includes("no oauth token") - ) { - return new Error("GitHub CLI is not authenticated. Run `gh auth login` and retry."); - } - return undefined; -} - -function toStatusOpenPr(pr: OpenPrInfo): { - number: number; - title: string; - url: string; - baseBranch: string; - headBranch: string; -} { - return { - number: pr.number, - title: pr.title, - url: pr.url, - baseBranch: pr.baseRefName, - headBranch: pr.headRefName, - }; -} - -export class GitManager { - private readonly run: NonNullable; - private readonly textGenerator: TextGenerationService; - private readonly gitCore: GitCoreService; - - constructor(deps: GitManagerDeps = {}) { - this.run = deps.runProcess ?? runProcess; - this.textGenerator = deps.textGenerator ?? new CodexTextGenerator(); - this.gitCore = deps.gitCore ?? new GitCoreService(); - } - - async status(input: GitStatusInput): Promise { - const details = await this.gitCore.statusDetails(input.cwd); - - let openPr: ReturnType | null = null; - if (details.branch && details.hasUpstream) { - try { - const existing = await this.findOpenPr(input.cwd, details.branch); - if (existing) { - openPr = toStatusOpenPr(existing); - } - } catch { - // PR lookup is best-effort for status rendering. - } - } - - return { - branch: details.branch, - hasWorkingTreeChanges: details.hasWorkingTreeChanges, - workingTree: details.workingTree, - hasUpstream: details.hasUpstream, - aheadCount: details.aheadCount, - behindCount: details.behindCount, - openPr, - }; - } - - async runStackedAction(input: GitRunStackedActionInput): Promise { - const wantsPush = input.action !== "commit"; - const wantsPr = input.action === "commit_push_pr"; - - const initialStatus = await this.gitCore.statusDetails(input.cwd); - if (wantsPush && !initialStatus.branch) { - throw new Error("Cannot push from detached HEAD."); - } - if (wantsPr && !initialStatus.branch) { - throw new Error("Cannot create a pull request from detached HEAD."); - } - - const commit = await this.runCommitStep(input.cwd, initialStatus.branch, input.commitMessage); - - const push = wantsPush - ? await this.gitCore.pushCurrentBranch(input.cwd, initialStatus.branch) - : { status: "skipped_not_requested" as const }; - - const pr = wantsPr - ? await this.runPrStep(input.cwd, initialStatus.branch) - : { status: "skipped_not_requested" as const }; - - return { - action: input.action, - commit, - push, - pr, - }; - } - - private async runCommitStep( - cwd: string, - branch: string | null, - commitMessage?: string, - ): Promise<{ - status: "created" | "skipped_no_changes"; - commitSha?: string | undefined; - subject?: string | undefined; - }> { - const context = await this.gitCore.prepareCommitContext(cwd); - if (!context) { - return { status: "skipped_no_changes" }; - } - - let generated: CommitMessageGenerationResult | null = parseCustomCommitMessage( - commitMessage ?? "", - ); - - if (!generated) { - try { - generated = sanitizeCommitMessage( - await this.textGenerator.generateCommitMessage({ - cwd, - branch, - stagedSummary: limitContext(context.stagedSummary, 8_000), - stagedPatch: limitContext(context.stagedPatch, 50_000), - }), - ); - } catch (error) { - throw asCommandNotFound("codex", error) ?? error; - } - } - - const { commitSha } = await this.gitCore.commit(cwd, generated.subject, generated.body); - return { - status: "created", - commitSha, - subject: generated.subject, - }; - } - - private async runPrStep( - cwd: string, - fallbackBranch: string | null, - ): Promise<{ - status: "created" | "opened_existing" | "skipped_not_requested"; - url?: string | undefined; - number?: number | undefined; - baseBranch?: string | undefined; - headBranch?: string | undefined; - title?: string | undefined; - }> { - const details = await this.gitCore.statusDetails(cwd); - const branch = details.branch ?? fallbackBranch; - if (!branch) { - throw new Error("Cannot create a pull request from detached HEAD."); - } - if (!details.hasUpstream) { - throw new Error("Current branch has not been pushed. Push before creating a PR."); - } - - const existing = await this.findOpenPr(cwd, branch); - if (existing) { - return { - status: "opened_existing", - url: existing.url, - number: existing.number, - baseBranch: existing.baseRefName, - headBranch: existing.headRefName, - title: existing.title, - }; - } - - const baseBranch = await this.resolveBaseBranch(cwd, branch, details.upstreamRef); - const rangeContext = await this.gitCore.readRangeContext(cwd, baseBranch); - - let generated: PrContentGenerationResult; - try { - generated = await this.textGenerator.generatePrContent({ - cwd, - baseBranch, - headBranch: branch, - commitSummary: limitContext(rangeContext.commitSummary, 20_000), - diffSummary: limitContext(rangeContext.diffSummary, 20_000), - diffPatch: limitContext(rangeContext.diffPatch, 60_000), - }); - } catch (error) { - throw asCommandNotFound("codex", error) ?? error; - } - - const bodyFile = path.join(os.tmpdir(), `t3code-pr-body-${process.pid}-${randomUUID()}.md`); - await fs.writeFile(bodyFile, generated.body, "utf8"); - - try { - await this.runGh(cwd, [ - "pr", - "create", - "--base", - baseBranch, - "--head", - branch, - "--title", - generated.title, - "--body-file", - bodyFile, - ]); - } finally { - try { - await fs.unlink(bodyFile); - } catch { - // Best-effort cleanup. - } - } - - const created = await this.findOpenPr(cwd, branch); - - if (!created) { - return { - status: "created", - baseBranch, - headBranch: branch, - title: generated.title, - }; - } - - return { - status: "created", - url: created.url, - number: created.number, - baseBranch: created.baseRefName, - headBranch: created.headRefName, - title: created.title, - }; - } - - private async findOpenPr(cwd: string, branch: string): Promise { - const stdout = await this.runGhStdout(cwd, [ - "pr", - "list", - "--head", - branch, - "--state", - "open", - "--limit", - "1", - "--json", - "number,title,url,baseRefName,headRefName", - ]); - - const raw = trimStdout(stdout); - if (raw.length === 0) return null; - - let parsedJson: unknown; - try { - parsedJson = JSON.parse(raw); - } catch { - throw new Error("GitHub CLI returned invalid PR list JSON."); - } - - const parsed = parseOpenPrList(parsedJson); - return parsed[0] ?? null; - } - - private async resolveBaseBranch( - cwd: string, - branch: string, - upstreamRef: string | null, - ): Promise { - const configured = await this.gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); - if (configured) return configured; - - if (upstreamRef) { - const upstreamBranch = extractBranchFromRef(upstreamRef); - if (upstreamBranch.length > 0 && upstreamBranch !== branch) { - return upstreamBranch; - } - } - - try { - const ghDefault = await this.runGhStdout(cwd, [ - "repo", - "view", - "--json", - "defaultBranchRef", - "--jq", - ".defaultBranchRef.name", - ]); - const defaultBranch = trimStdout(ghDefault); - if (defaultBranch.length > 0) return defaultBranch; - } catch { - // Fall through to deterministic fallback. - } - - return "main"; - } - - private async runGh(cwd: string, args: readonly string[]): Promise { - await this.runGhCommand(cwd, args); - } - - private async runGhStdout(cwd: string, args: readonly string[]): Promise { - const result = await this.runGhCommand(cwd, args); - return result.stdout; - } - - private async runGhCommand(cwd: string, args: readonly string[]): Promise { - try { - return await this.run("gh", args, { - cwd, - timeoutMs: GH_CLI_TIMEOUT_MS, - }); - } catch (error) { - throw ( - asCommandNotFound("gh", error) ?? - normalizeGitHubAuthError(error) ?? - (error instanceof Error ? error : new Error("GitHub CLI command failed.")) - ); - } - } -} diff --git a/apps/server/src/ptyAdapter.ts b/apps/server/src/ptyAdapter.ts deleted file mode 100644 index 0e655f484e5de389f034146e6090513755d7e574..0000000000000000000000000000000000000000 --- a/apps/server/src/ptyAdapter.ts +++ /dev/null @@ -1,256 +0,0 @@ -import fs from "node:fs"; -import { createRequire } from "node:module"; -import path from "node:path"; - -import { Effect, Layer, ServiceMap } from "effect"; - -export interface PtyExitEvent { - exitCode: number; - signal: number | null; -} - -export interface PtyProcess { - readonly pid: number; - write(data: string): void; - resize(cols: number, rows: number): void; - kill(signal?: string): void; - onData(callback: (data: string) => void): () => void; - onExit(callback: (event: PtyExitEvent) => void): () => void; -} - -export interface PtySpawnInput { - shell: string; - args?: string[]; - cwd: string; - cols: number; - rows: number; - env: NodeJS.ProcessEnv; -} - -export interface PtyAdapterShape { - spawn(input: PtySpawnInput): PtyProcess; -} - -export class PtyAdapter extends ServiceMap.Service()( - "terminal/PtyAdapter", -) { - static readonly layer = () => - typeof Bun !== "undefined" ? BunPtyAdapterLive : NodePtyAdapterLive; -} - -const requireForNodePty = createRequire(import.meta.url); -let didEnsureSpawnHelperExecutable = false; - -function resolveNodePtySpawnHelperPath(): string | null { - try { - const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); - const packageDir = path.dirname(packageJsonPath); - const candidates = [ - path.join(packageDir, "build", "Release", "spawn-helper"), - path.join(packageDir, "build", "Debug", "spawn-helper"), - path.join(packageDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), - ]; - - for (const candidate of candidates) { - if (fs.existsSync(candidate)) { - return candidate; - } - } - - return null; - } catch { - return null; - } -} - -export function ensureNodePtySpawnHelperExecutable(explicitPath?: string): void { - if (process.platform === "win32") return; - if (!explicitPath && didEnsureSpawnHelperExecutable) return; - - const helperPath = explicitPath ?? resolveNodePtySpawnHelperPath(); - if (!helperPath) return; - if (!explicitPath) { - didEnsureSpawnHelperExecutable = true; - } - - try { - const stat = fs.statSync(helperPath); - const mode = stat.mode & 0o777; - if ((mode & 0o111) === 0) { - fs.chmodSync(helperPath, mode | 0o111); - } - } catch { - // Best effort only. If chmod fails, node-pty spawn will surface the real error. - } -} - -class NodePtyProcess implements PtyProcess { - constructor(private readonly process: import("node-pty").IPty) {} - - get pid(): number { - return this.process.pid; - } - - write(data: string): void { - this.process.write(data); - } - - resize(cols: number, rows: number): void { - this.process.resize(cols, rows); - } - - kill(signal?: string): void { - this.process.kill(signal); - } - - onData(callback: (data: string) => void): () => void { - const disposable = this.process.onData(callback); - return () => { - disposable.dispose(); - }; - } - - onExit(callback: (event: PtyExitEvent) => void): () => void { - const disposable = this.process.onExit((event) => { - callback({ - exitCode: event.exitCode, - signal: event.signal ?? null, - }); - }); - return () => { - disposable.dispose(); - }; - } -} - -class BunPtyProcess implements PtyProcess { - private readonly dataListeners = new Set<(data: string) => void>(); - private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); - private readonly decoder = new TextDecoder(); - private didExit = false; - - constructor(private readonly process: Bun.Subprocess) { - void this.process.exited - .then((exitCode) => { - this.emitExit({ - exitCode: Number.isInteger(exitCode) ? exitCode : 0, - signal: typeof this.process.signalCode === "number" ? this.process.signalCode : null, - }); - }) - .catch(() => { - this.emitExit({ exitCode: 1, signal: null }); - }); - } - - get pid(): number { - return this.process.pid; - } - - write(data: string): void { - if (!this.process.terminal) { - throw new Error("Bun PTY terminal handle is unavailable"); - } - this.process.terminal.write(data); - } - - resize(cols: number, rows: number): void { - if (!this.process.terminal?.resize) { - throw new Error("Bun PTY resize is unavailable"); - } - this.process.terminal.resize(cols, rows); - } - - kill(signal?: string): void { - if (!signal) { - this.process.kill(); - return; - } - this.process.kill(signal as NodeJS.Signals); - } - - onData(callback: (data: string) => void): () => void { - this.dataListeners.add(callback); - return () => { - this.dataListeners.delete(callback); - }; - } - - onExit(callback: (event: PtyExitEvent) => void): () => void { - this.exitListeners.add(callback); - return () => { - this.exitListeners.delete(callback); - }; - } - - emitData(data: Uint8Array): void { - if (this.didExit) return; - const text = this.decoder.decode(data, { stream: true }); - if (text.length === 0) return; - for (const listener of this.dataListeners) { - listener(text); - } - } - - private emitExit(event: PtyExitEvent): void { - if (this.didExit) return; - this.didExit = true; - - const remainder = this.decoder.decode(); - if (remainder.length > 0) { - for (const listener of this.dataListeners) { - listener(remainder); - } - } - - for (const listener of this.exitListeners) { - listener(event); - } - } -} -export const NodePtyAdapterLive = Layer.effect( - PtyAdapter, - Effect.gen(function* () { - const nodePty = yield* Effect.promise(() => import("node-pty")); - return { - spawn(input: PtySpawnInput): PtyProcess { - ensureNodePtySpawnHelperExecutable(); - const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { - cwd: input.cwd, - cols: input.cols, - rows: input.rows, - env: input.env, - name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", - }); - return new NodePtyProcess(ptyProcess); - }, - }; - }), -); - -export const BunPtyAdapterLive = Layer.effect( - PtyAdapter, - Effect.gen(function* () { - if (process.platform === "win32") { - return yield* Effect.die("Bun PTY terminal support is unavailable on Windows."); - } - return { - spawn(input: PtySpawnInput): PtyProcess { - let processHandle: BunPtyProcess | null = null; - const command = [input.shell, ...(input.args ?? [])]; - const subprocess = Bun.spawn(command, { - cwd: input.cwd, - env: input.env, - terminal: { - cols: input.cols, - rows: input.rows, - data: (_terminal, data) => { - processHandle?.emitData(data); - }, - }, - }); - processHandle = new BunPtyProcess(subprocess); - return processHandle; - }, - }; - }), -); diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 38bea2269587730c939eb58352ed5eb828d271dc..8fc259a6891635a397d230a628c417bae147fc87 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -7,8 +7,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; import { ServerConfig } from "./config"; -import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; -import { CheckpointStore } from "./checkpointing/Services/CheckpointStore"; import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; @@ -19,18 +17,21 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; -import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; -import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; import { ProviderService } from "./provider/Services/ProviderService"; -import { makeTerminalManagerLive, TerminalManager } from "./terminalManager"; -import { PtyAdapter } from "./ptyAdapter"; -import { Keybindings, KeybindingsLive } from "./keybindings"; +import { TerminalManagerLive } from "./terminal/Layers/Manager"; +import { KeybindingsLive } from "./keybindings"; +import { GitManagerLive } from "./git/Layers/GitManager"; +import { GitCoreLive } from "./git/Layers/GitCore"; +import { GitHubCliLive } from "./git/Layers/GitHubCli"; +import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration"; +import { GitServiceLive } from "./git/Layers/GitService"; +import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY"; +import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY"; export function makeServerProviderLayer(): Layer.Layer< ProviderService, @@ -53,17 +54,7 @@ export function makeServerProviderLayer(): Layer.Layer< }).pipe(Layer.unwrap); } -export function makeServerRuntimeServicesLayer(): Layer.Layer< - | OrchestrationEngineService - | ProjectionSnapshotQuery - | CheckpointStore - | CheckpointDiffQuery - | OrchestrationReactor - | TerminalManager - | Keybindings, - unknown, - SqlClient.SqlClient | ProviderService | ServerConfig -> { +export function makeServerRuntimeServicesLayer() { const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), @@ -96,14 +87,23 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer< Layer.provideMerge(checkpointReactorLayer), ); - const terminalLayer = Effect.gen(function* () { - const { stateDir } = yield* ServerConfig; - return makeTerminalManagerLive({ - logsDir: path.join(stateDir, "logs", "terminals"), - }).pipe(Layer.provide(PtyAdapter.layer())); - }).pipe(Layer.unwrap); + const terminalLayer = TerminalManagerLive.pipe( + Layer.provide(typeof Bun !== "undefined" ? BunPtyAdapterLive : NodePtyAdapterLive), + ); - const keybindingsLayer = KeybindingsLive.pipe(Layer.provide(NodeServices.layer)); + const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive)); - return Layer.mergeAll(orchestrationReactorLayer, terminalLayer, keybindingsLayer); + const gitManagerLayer = GitManagerLive.pipe( + Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(GitHubCliLive), + Layer.provideMerge(CodexTextGenerationLive), + ); + + return Layer.mergeAll( + orchestrationReactorLayer, + gitCoreLayer, + gitManagerLayer, + terminalLayer, + KeybindingsLive, + ).pipe(Layer.provideMerge(NodeServices.layer)); } diff --git a/apps/server/src/terminal/Layers/BunPTY.ts b/apps/server/src/terminal/Layers/BunPTY.ts new file mode 100644 index 0000000000000000000000000000000000000000..e77b822e7e789a62f809d5cca60c03c55483458a --- /dev/null +++ b/apps/server/src/terminal/Layers/BunPTY.ts @@ -0,0 +1,115 @@ +import { Effect, Layer } from "effect"; +import { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY"; + +class BunPtyProcess implements PtyProcess { + private readonly dataListeners = new Set<(data: string) => void>(); + private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); + private readonly decoder = new TextDecoder(); + private didExit = false; + + constructor(private readonly process: Bun.Subprocess) { + void this.process.exited + .then((exitCode) => { + this.emitExit({ + exitCode: Number.isInteger(exitCode) ? exitCode : 0, + signal: typeof this.process.signalCode === "number" ? this.process.signalCode : null, + }); + }) + .catch(() => { + this.emitExit({ exitCode: 1, signal: null }); + }); + } + + get pid(): number { + return this.process.pid; + } + + write(data: string): void { + if (!this.process.terminal) { + throw new Error("Bun PTY terminal handle is unavailable"); + } + this.process.terminal.write(data); + } + + resize(cols: number, rows: number): void { + if (!this.process.terminal?.resize) { + throw new Error("Bun PTY resize is unavailable"); + } + this.process.terminal.resize(cols, rows); + } + + kill(signal?: string): void { + if (!signal) { + this.process.kill(); + return; + } + this.process.kill(signal as NodeJS.Signals); + } + + onData(callback: (data: string) => void): () => void { + this.dataListeners.add(callback); + return () => { + this.dataListeners.delete(callback); + }; + } + + onExit(callback: (event: PtyExitEvent) => void): () => void { + this.exitListeners.add(callback); + return () => { + this.exitListeners.delete(callback); + }; + } + + emitData(data: Uint8Array): void { + if (this.didExit) return; + const text = this.decoder.decode(data, { stream: true }); + if (text.length === 0) return; + for (const listener of this.dataListeners) { + listener(text); + } + } + + private emitExit(event: PtyExitEvent): void { + if (this.didExit) return; + this.didExit = true; + + const remainder = this.decoder.decode(); + if (remainder.length > 0) { + for (const listener of this.dataListeners) { + listener(remainder); + } + } + + for (const listener of this.exitListeners) { + listener(event); + } + } +} + +export const BunPtyAdapterLive = Layer.effect( + PtyAdapter, + Effect.gen(function* () { + if (process.platform === "win32") { + return yield* Effect.die("Bun PTY terminal support is unavailable on Windows."); + } + return { + spawn(input: PtySpawnInput): PtyProcess { + let processHandle: BunPtyProcess | null = null; + const command = [input.shell, ...(input.args ?? [])]; + const subprocess = Bun.spawn(command, { + cwd: input.cwd, + env: input.env, + terminal: { + cols: input.cols, + rows: input.rows, + data: (_terminal, data) => { + processHandle?.emitData(data); + }, + }, + }); + processHandle = new BunPtyProcess(subprocess); + return processHandle; + }, + }; + }), +); diff --git a/apps/server/src/terminalManager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts similarity index 99% rename from apps/server/src/terminalManager.test.ts rename to apps/server/src/terminal/Layers/Manager.test.ts index b0e4bd1c05818cc0d15b2869b1fbce02257cbe1e..16c262ffca0fbef07a19ad3492780ec391089e4b 100644 --- a/apps/server/src/terminalManager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -9,8 +9,8 @@ import { } from "@t3tools/contracts"; import { afterEach, describe, expect, it } from "vitest"; -import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter"; -import { TerminalManagerRuntime } from "./terminalManager"; +import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY"; +import { TerminalManagerRuntime } from "./Manager"; import { Encoding } from "effect"; class FakePtyProcess implements PtyProcess { diff --git a/apps/server/src/terminalManager.ts b/apps/server/src/terminal/Layers/Manager.ts similarity index 91% rename from apps/server/src/terminalManager.ts rename to apps/server/src/terminal/Layers/Manager.ts index 0a531435e061a888913d32a9fd1a931c0e3738f8..da5910eba3d673c9813c09926b4317c3fde996ce 100644 --- a/apps/server/src/terminalManager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -11,13 +11,22 @@ import { TerminalWriteInput, type TerminalEvent, type TerminalSessionSnapshot, - type TerminalSessionStatus, } from "@t3tools/contracts"; -import { Effect, Encoding, Layer, Schema, ServiceMap } from "effect"; +import { Effect, Encoding, Layer, Path, Schema } from "effect"; -import { createLogger } from "./logger"; -import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; -import { runProcess } from "./processRunner"; +import { createLogger } from "../../logger"; +import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "../Services/PTY"; +import { runProcess } from "../../processRunner"; +import { ServerConfig } from "../../config"; +import { + ShellCandidate, + TerminalManager, + TerminalManagerEvents, + TerminalManagerOptions, + TerminalManagerShape, + TerminalSessionState, + TerminalStartInput, +} from "../Services/Manager"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; @@ -34,63 +43,6 @@ const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput); type TerminalSubprocessChecker = (terminalPid: number) => Promise; -export interface TerminalManagerEvents { - event: [event: TerminalEvent]; -} - -export interface TerminalManagerOptions { - logsDir?: string; - historyLineLimit?: number; - ptyAdapter: PtyAdapterShape; - shellResolver?: () => string; - subprocessChecker?: TerminalSubprocessChecker; - subprocessPollIntervalMs?: number; -} - -export interface TerminalManagerShape { - readonly open: (input: TerminalOpenInput) => Effect.Effect; - readonly write: (input: TerminalWriteInput) => Effect.Effect; - readonly resize: (input: TerminalResizeInput) => Effect.Effect; - readonly clear: (input: TerminalClearInput) => Effect.Effect; - readonly restart: (input: TerminalOpenInput) => Effect.Effect; - readonly close: (input: TerminalCloseInput) => Effect.Effect; - readonly subscribe: (listener: (event: TerminalEvent) => void) => Effect.Effect<() => void>; - readonly dispose: () => Effect.Effect; -} - -export class TerminalManager extends ServiceMap.Service()( - "terminal/TerminalManager", -) {} - -interface TerminalSessionState { - threadId: string; - terminalId: string; - cwd: string; - status: TerminalSessionStatus; - pid: number | null; - history: string; - exitCode: number | null; - exitSignal: number | null; - updatedAt: string; - cols: number; - rows: number; - process: PtyProcess | null; - unsubscribeData: (() => void) | null; - unsubscribeExit: (() => void) | null; - hasRunningSubprocess: boolean; - runtimeEnv: Record | null; -} - -interface ShellCandidate { - shell: string; - args?: string[]; -} - -interface TerminalStartInput extends TerminalOpenInput { - cols: number; - rows: number; -} - function defaultShellResolver(): string { if (process.platform === "win32") { return process.env.ComSpec ?? "cmd.exe"; @@ -1062,34 +1014,34 @@ export class TerminalManagerRuntime extends EventEmitter } } -export function makeTerminalManagerLive( - options: Omit = {}, -): Layer.Layer { - return Layer.effect( - TerminalManager, - Effect.gen(function* () { - const ptyAdapter = yield* PtyAdapter; - const runtime = new TerminalManagerRuntime({ ...options, ptyAdapter }); +export const TerminalManagerLive = Layer.effect( + TerminalManager, + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig; + const { join } = yield* Path.Path; + const logsDir = join(stateDir, "logs", "terminals"); - return { - open: (input) => Effect.promise(() => runtime.open(input)), - write: (input) => Effect.promise(() => runtime.write(input)), - resize: (input) => Effect.promise(() => runtime.resize(input)), - clear: (input) => Effect.promise(() => runtime.clear(input)), - restart: (input) => Effect.promise(() => runtime.restart(input)), - close: (input) => Effect.promise(() => runtime.close(input)), - subscribe: (listener) => - Effect.sync(() => { - runtime.on("event", listener); - return () => { - runtime.off("event", listener); - }; - }), - dispose: () => - Effect.sync(() => { - runtime.dispose(); - }), - } satisfies TerminalManagerShape; - }), - ); -} + const ptyAdapter = yield* PtyAdapter; + const runtime = new TerminalManagerRuntime({ logsDir, ptyAdapter }); + + return { + open: (input) => Effect.promise(() => runtime.open(input)), + write: (input) => Effect.promise(() => runtime.write(input)), + resize: (input) => Effect.promise(() => runtime.resize(input)), + clear: (input) => Effect.promise(() => runtime.clear(input)), + restart: (input) => Effect.promise(() => runtime.restart(input)), + close: (input) => Effect.promise(() => runtime.close(input)), + subscribe: (listener) => + Effect.sync(() => { + runtime.on("event", listener); + return () => { + runtime.off("event", listener); + }; + }), + dispose: () => + Effect.sync(() => { + runtime.dispose(); + }), + } satisfies TerminalManagerShape; + }), +); diff --git a/apps/server/src/ptyAdapter.test.ts b/apps/server/src/terminal/Layers/NodePTY.test.ts similarity index 96% rename from apps/server/src/ptyAdapter.test.ts rename to apps/server/src/terminal/Layers/NodePTY.test.ts index 25f30d6fe3abb274ef131c2af03f4ec29801d4b1..90e349cc5d80cede3ae504e8baa4153a49049eac 100644 --- a/apps/server/src/ptyAdapter.test.ts +++ b/apps/server/src/terminal/Layers/NodePTY.test.ts @@ -6,7 +6,7 @@ import { assert, it, vi } from "@effect/vitest"; import { Effect, Layer } from "effect"; import { afterEach } from "vitest"; -import { ensureNodePtySpawnHelperExecutable } from "./ptyAdapter"; +import { ensureNodePtySpawnHelperExecutable } from "./NodePTY"; function fileMode(filePath: string): number { return fs.statSync(filePath).mode & 0o777; diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/Layers/NodePTY.ts new file mode 100644 index 0000000000000000000000000000000000000000..86557e7e93378dd8e4f1a9fe8e68939a7f50416c --- /dev/null +++ b/apps/server/src/terminal/Layers/NodePTY.ts @@ -0,0 +1,111 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +import { Effect, Layer } from "effect"; +import { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY"; + +const requireForNodePty = createRequire(import.meta.url); +let didEnsureSpawnHelperExecutable = false; + +function resolveNodePtySpawnHelperPath(): string | null { + try { + const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); + const packageDir = path.dirname(packageJsonPath); + const candidates = [ + path.join(packageDir, "build", "Release", "spawn-helper"), + path.join(packageDir, "build", "Debug", "spawn-helper"), + path.join(packageDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return null; + } catch { + return null; + } +} + +export function ensureNodePtySpawnHelperExecutable(explicitPath?: string): void { + if (process.platform === "win32") return; + if (!explicitPath && didEnsureSpawnHelperExecutable) return; + + const helperPath = explicitPath ?? resolveNodePtySpawnHelperPath(); + if (!helperPath) return; + if (!explicitPath) { + didEnsureSpawnHelperExecutable = true; + } + + try { + const stat = fs.statSync(helperPath); + const mode = stat.mode & 0o777; + if ((mode & 0o111) === 0) { + fs.chmodSync(helperPath, mode | 0o111); + } + } catch { + // Best effort only. If chmod fails, node-pty spawn will surface the real error. + } +} + +class NodePtyProcess implements PtyProcess { + constructor(private readonly process: import("node-pty").IPty) {} + + get pid(): number { + return this.process.pid; + } + + write(data: string): void { + this.process.write(data); + } + + resize(cols: number, rows: number): void { + this.process.resize(cols, rows); + } + + kill(signal?: string): void { + this.process.kill(signal); + } + + onData(callback: (data: string) => void): () => void { + const disposable = this.process.onData(callback); + return () => { + disposable.dispose(); + }; + } + + onExit(callback: (event: PtyExitEvent) => void): () => void { + const disposable = this.process.onExit((event) => { + callback({ + exitCode: event.exitCode, + signal: event.signal ?? null, + }); + }); + return () => { + disposable.dispose(); + }; + } +} + +export const NodePtyAdapterLive = Layer.effect( + PtyAdapter, + Effect.gen(function* () { + const nodePty = yield* Effect.promise(() => import("node-pty")); + return { + spawn(input: PtySpawnInput): PtyProcess { + ensureNodePtySpawnHelperExecutable(); + const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { + cwd: input.cwd, + cols: input.cols, + rows: input.rows, + env: input.env, + name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", + }); + return new NodePtyProcess(ptyProcess); + }, + }; + }), +); diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a22ada5dc3499e6d0bc04c62b1f43ec0d53b8c4 --- /dev/null +++ b/apps/server/src/terminal/Services/Manager.ts @@ -0,0 +1,71 @@ +import { + TerminalClearInput, + TerminalCloseInput, + TerminalEvent, + TerminalOpenInput, + TerminalResizeInput, + TerminalSessionSnapshot, + TerminalSessionStatus, + TerminalWriteInput, +} from "@t3tools/contracts"; +import { PtyAdapterShape, PtyProcess } from "./PTY"; +import { Effect, ServiceMap } from "effect"; + +type TerminalSubprocessChecker = (terminalPid: number) => Promise; + +export interface TerminalManagerEvents { + event: [event: TerminalEvent]; +} + +export interface TerminalSessionState { + threadId: string; + terminalId: string; + cwd: string; + status: TerminalSessionStatus; + pid: number | null; + history: string; + exitCode: number | null; + exitSignal: number | null; + updatedAt: string; + cols: number; + rows: number; + process: PtyProcess | null; + unsubscribeData: (() => void) | null; + unsubscribeExit: (() => void) | null; + hasRunningSubprocess: boolean; + runtimeEnv: Record | null; +} + +export interface ShellCandidate { + shell: string; + args?: string[]; +} + +export interface TerminalStartInput extends TerminalOpenInput { + cols: number; + rows: number; +} + +export interface TerminalManagerOptions { + logsDir?: string; + historyLineLimit?: number; + ptyAdapter: PtyAdapterShape; + shellResolver?: () => string; + subprocessChecker?: TerminalSubprocessChecker; + subprocessPollIntervalMs?: number; +} + +export interface TerminalManagerShape { + readonly open: (input: TerminalOpenInput) => Effect.Effect; + readonly write: (input: TerminalWriteInput) => Effect.Effect; + readonly resize: (input: TerminalResizeInput) => Effect.Effect; + readonly clear: (input: TerminalClearInput) => Effect.Effect; + readonly restart: (input: TerminalOpenInput) => Effect.Effect; + readonly close: (input: TerminalCloseInput) => Effect.Effect; + readonly subscribe: (listener: (event: TerminalEvent) => void) => Effect.Effect<() => void>; + readonly dispose: () => Effect.Effect; +} + +export class TerminalManager extends ServiceMap.Service()( + "terminal/TerminalManager", +) {} diff --git a/apps/server/src/terminal/Services/PTY.ts b/apps/server/src/terminal/Services/PTY.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ab7fc55c52cae600f97e8d93c568926c24a2684 --- /dev/null +++ b/apps/server/src/terminal/Services/PTY.ts @@ -0,0 +1,32 @@ +import { ServiceMap } from "effect"; + +export interface PtyExitEvent { + exitCode: number; + signal: number | null; +} + +export interface PtyProcess { + readonly pid: number; + write(data: string): void; + resize(cols: number, rows: number): void; + kill(signal?: string): void; + onData(callback: (data: string) => void): () => void; + onExit(callback: (event: PtyExitEvent) => void): () => void; +} + +export interface PtySpawnInput { + shell: string; + args?: string[]; + cwd: string; + cols: number; + rows: number; + env: NodeJS.ProcessEnv; +} + +export interface PtyAdapterShape { + spawn(input: PtySpawnInput): PtyProcess; +} + +export class PtyAdapter extends ServiceMap.Service()( + "terminal/PtyAdapter", +) {} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index a8607c948c1228e8695b23c26299322f1364981e..121b75d07d98a333d11c93d6e3f2ceccfa47b4fc 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -37,11 +37,15 @@ import type { TerminalSessionSnapshot, TerminalWriteInput, } from "@t3tools/contracts"; -import type { TerminalManagerShape } from "./terminalManager"; +import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; import { SqlClient } from "effect/unstable/sql"; import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; import { Open, type OpenShape } from "./open"; +import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; +import type { GitCoreShape } from "./git/Services/GitCore.ts"; +import { GitCore } from "./git/Services/GitCore.ts"; +import { GitCommandError, GitManagerError } from "./git/Errors.ts"; interface PendingMessages { queue: unknown[]; @@ -339,10 +343,8 @@ describe("WebSocket Server", () => { stateDir?: string; providerLayer?: Layer.Layer; open?: OpenShape; - gitManager?: { - status: (input: { cwd: string }) => Promise; - runStackedAction: (input: { cwd: string; action: string }) => Promise; - }; + gitManager?: GitManagerShape; + gitCore?: Pick; terminalManager?: TerminalManagerShape; } = {}, ): Promise { @@ -368,9 +370,21 @@ describe("WebSocket Server", () => { authToken: options.authToken, } satisfies ServerConfigShape); const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); + const runtimeOverrides = Layer.mergeAll( + options.gitManager ? Layer.succeed(GitManager, options.gitManager) : Layer.empty, + options.gitCore + ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape) + : Layer.empty, + options.terminalManager + ? Layer.succeed(TerminalManager, options.terminalManager) + : Layer.empty, + ); const runtimeLayer = Layer.merge( - makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), - infrastructureLayer, + Layer.merge( + makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), + infrastructureLayer, + ), + runtimeOverrides, ); const dependenciesLayer = Layer.empty.pipe( Layer.provideMerge(runtimeLayer), @@ -385,8 +399,6 @@ describe("WebSocket Server", () => { const runtime = await Effect.runPromise( createServer({ autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false, - ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), - ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), }).pipe(Effect.provide(runtimeServices), Scope.provide(scope)), ); serverScope = scope; @@ -1049,10 +1061,33 @@ describe("WebSocket Server", () => { }); }); - it("supports git methods over websocket", async () => { - const repoCwd = makeTempDir("t3code-ws-git-project-"); + it("routes git core methods over websocket", async () => { + const listBranches = vi.fn(() => + Effect.succeed({ + branches: [], + isRepo: false, + }), + ); + const initRepo = vi.fn(() => Effect.void); + const pullCurrentBranch = vi.fn(() => + Effect.fail( + new GitCommandError({ + operation: "GitCore.test.pullCurrentBranch", + detail: "No upstream configured", + command: "git pull", + cwd: "/repo/path", + }), + ), + ); - server = await createTestServer({ cwd: "/test" }); + server = await createTestServer({ + cwd: "/test", + gitCore: { + listBranches, + initRepo, + pullCurrentBranch, + }, + }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -1060,23 +1095,19 @@ describe("WebSocket Server", () => { connections.push(ws); await waitForMessage(ws); - const beforeInit = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: repoCwd }); - expect(beforeInit.error).toBeUndefined(); - expect(beforeInit.result).toEqual({ branches: [], isRepo: false }); + const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" }); + expect(listResponse.error).toBeUndefined(); + expect(listResponse.result).toEqual({ branches: [], isRepo: false }); + expect(listBranches).toHaveBeenCalledWith({ cwd: "/repo/path" }); - const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: repoCwd }); + const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: "/repo/path" }); expect(initResponse.error).toBeUndefined(); + expect(initRepo).toHaveBeenCalledWith({ cwd: "/repo/path" }); - const afterInit = await sendRequest(ws, WS_METHODS.gitListBranches, { - cwd: repoCwd, - }); - expect(afterInit.error).toBeUndefined(); - expect((afterInit.result as { isRepo: boolean }).isRepo).toBe(true); - - const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: repoCwd }); + const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: "/repo/path" }); expect(pullResponse.result).toBeUndefined(); - expect(pullResponse.error?.message).toBeDefined(); - expect(pullResponse.error?.message).not.toContain("Unknown method"); + expect(pullResponse.error?.message).toContain("No upstream configured"); + expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path"); }); it("supports git.status over websocket", async () => { @@ -1094,10 +1125,9 @@ describe("WebSocket Server", () => { openPr: null, }; - const gitManager = { - status: vi.fn().mockResolvedValue(statusResult), - runStackedAction: vi.fn(), - }; + const status = vi.fn(() => Effect.succeed(statusResult)); + const runStackedAction = vi.fn(() => Effect.void as any); + const gitManager: GitManagerShape = { status, runStackedAction }; server = await createTestServer({ cwd: "/test", gitManager }); const addr = server.address(); @@ -1112,13 +1142,21 @@ describe("WebSocket Server", () => { }); expect(response.error).toBeUndefined(); expect(response.result).toEqual(statusResult); - expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" }); + expect(status).toHaveBeenCalledWith({ cwd: "/test" }); }); it("returns errors from git.runStackedAction", async () => { - const gitManager = { - status: vi.fn(), - runStackedAction: vi.fn().mockRejectedValue(new Error("Cannot push from detached HEAD.")), + const runStackedAction = vi.fn(() => + Effect.fail( + new GitManagerError({ + operation: "GitManager.test.runStackedAction", + detail: "Cannot push from detached HEAD.", + }), + ), + ); + const gitManager: GitManagerShape = { + status: vi.fn(() => Effect.void as any), + runStackedAction, }; server = await createTestServer({ cwd: "/test", gitManager }); @@ -1135,7 +1173,7 @@ describe("WebSocket Server", () => { }); expect(response.result).toBeUndefined(); expect(response.error?.message).toContain("detached HEAD"); - expect(gitManager.runStackedAction).toHaveBeenCalledWith({ + expect(runStackedAction).toHaveBeenCalledWith({ cwd: "/test", action: "commit_push", }); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index f86a6c0ad164fedd16be75f916ebe349ae1d324b..c78fe60b7267f947d367138f76a0db706c8bc0fe 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -20,17 +20,8 @@ import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream, Struct } import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger"; -import { GitManager } from "./gitManager"; -import { - checkoutGitBranch, - createGitBranch, - createGitWorktree, - initGitRepo, - listGitBranches, - pullGitBranch, - removeGitWorktree, -} from "./git"; -import { TerminalManager, type TerminalManagerShape } from "./terminalManager"; +import { GitManager } from "./git/Services/GitManager.ts"; +import { TerminalManager } from "./terminal/Services/Manager.ts"; import { Keybindings } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; @@ -41,6 +32,7 @@ import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuer import { clamp } from "effect/Number"; import { Open } from "./open"; import { ServerConfig } from "./config"; +import { GitCore } from "./git/Services/GitCore.ts"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -60,8 +52,6 @@ const MIME_TYPES: Record = { export interface ServerOptions { autoBootstrapProjectFromCwd?: boolean | undefined; logWebSocketEvents?: boolean | undefined; - gitManager?: GitManager | undefined; - terminalManager?: TerminalManagerShape | undefined; } export interface ServerShape { @@ -146,6 +136,8 @@ export type ServerCoreRuntimeServices = export type ServerRuntimeServices = | ServerCoreRuntimeServices + | GitManager + | GitCore | TerminalManager | Keybindings | Open; @@ -169,19 +161,18 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E > { const serverConfig = yield* ServerConfig; const { port, cwd, staticDir, devUrl, authToken, mode, host } = serverConfig; - const defaultTerminalManager = yield* TerminalManager; - const keybindingsManager = yield* Keybindings; const { - logWebSocketEvents: explicitLogWsEvents, - gitManager = new GitManager(), - terminalManager = defaultTerminalManager, + logWebSocketEvents = parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl), + autoBootstrapProjectFromCwd = mode === "web", } = options; - const autoBootstrapProjectFromCwd = options.autoBootstrapProjectFromCwd ?? mode === "web"; + + const gitManager = yield* GitManager; + const terminalManager = yield* TerminalManager; + const keybindingsManager = yield* Keybindings; + const git = yield* GitCore; const clients = new Set(); const logger = createLogger("ws"); - const logWebSocketEvents = - explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); function logOutgoingPush(push: WsPush, recipients: number) { if (!logWebSocketEvents) return; @@ -386,47 +377,47 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E case WS_METHODS.gitStatus: { const body = stripRequestTag(request.body); - return yield* Effect.promise(() => gitManager.status(body)); + return yield* gitManager.status(body); } case WS_METHODS.gitPull: { const body = stripRequestTag(request.body); - return yield* Effect.promise(() => pullGitBranch(body)); + return yield* git.pullCurrentBranch(body.cwd); } case WS_METHODS.gitRunStackedAction: { const body = stripRequestTag(request.body); - return yield* Effect.promise(() => gitManager.runStackedAction(body)); + return yield* gitManager.runStackedAction(body); } case WS_METHODS.gitListBranches: { const body = stripRequestTag(request.body); - return yield* Effect.promise(() => listGitBranches(body)); + return yield* git.listBranches(body); } case WS_METHODS.gitCreateWorktree: { const body = stripRequestTag(request.body); - return yield* Effect.promise(() => createGitWorktree(body)); + return yield* git.createWorktree(body); } case WS_METHODS.gitRemoveWorktree: { const body = stripRequestTag(request.body); - return yield* Effect.promise(() => removeGitWorktree(body)); + return yield* git.removeWorktree(body); } case WS_METHODS.gitCreateBranch: { const body = stripRequestTag(request.body); - return yield* Effect.promise(() => createGitBranch(body)); + return yield* git.createBranch(body); } case WS_METHODS.gitCheckout: { const body = stripRequestTag(request.body); - return yield* Effect.promise(() => checkoutGitBranch(body)); + return yield* git.checkoutBranch(body); } case WS_METHODS.gitInit: { const body = stripRequestTag(request.body); - return yield* Effect.promise(() => initGitRepo(body)); + return yield* git.initRepo(body); } case WS_METHODS.terminalOpen: { @@ -579,9 +570,7 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E unsubscribeTerminalEvents(); }), ); - yield* Effect.addFinalizer(() => - terminalManager.dispose().pipe(Effect.orElseSucceed(() => undefined)), - ); + yield* Effect.addFinalizer(terminalManager.dispose); yield* NodeHttpServer.make(() => httpServer, listenOptions); -- 2.54.0