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/Layer.test.ts b/apps/server/src/git/Layer.test.ts deleted file mode 100644 index 1426e28705f2c833100a543c4855147d7f3876fa..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layer.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -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"; - -const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer)); - -layer("GitServiceLive", (it) => { - it.effect("runGit executes successful git commands", () => - Effect.gen(function* () { - const gitService = yield* GitService; - const result = yield* gitService.execute({ - operation: "GitProcess.test.version", - cwd: process.cwd(), - args: ["--version"], - }); - - assert.equal(result.code, 0); - assert.ok(result.stdout.toLowerCase().includes("git version")); - }), - ); - - it.effect("runGit can return non-zero exit codes when allowed", () => - Effect.gen(function* () { - const gitService = yield* GitService; - const result = yield* gitService.execute({ - operation: "GitProcess.test.allowNonZero", - cwd: process.cwd(), - args: ["rev-parse", "--verify", "__definitely_missing_ref__"], - allowNonZeroExit: true, - }); - - assert.notEqual(result.code, 0); - }), - ); - - it.effect("runGit fails with GitCommandError when non-zero exits are not allowed", () => - Effect.gen(function* () { - const gitService = yield* GitService; - const result = yield* Effect.result( - gitService.execute({ - operation: "GitProcess.test.failOnNonZero", - cwd: process.cwd(), - args: ["rev-parse", "--verify", "__definitely_missing_ref__"], - }), - ); - - assert.equal(result._tag, "Failure"); - if (result._tag === "Failure") { - assert.ok(result.failure instanceof GitCommandError); - assert.equal(result.failure.operation, "GitProcess.test.failOnNonZero"); - assert.equal(result.failure.command, "git rev-parse --verify __definitely_missing_ref__"); - } - }), - ); -}); diff --git a/apps/server/src/git/Layer.ts b/apps/server/src/git/Layer.ts deleted file mode 100644 index a367e86162003590e31820f91cbbe4469842d463..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layer.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Git process helpers - Effect-native git execution with typed errors. - * - * Centralizes child-process git invocation for server modules. This module - * only executes git commands and reports structured failures. - * - * @module GitServiceLive - */ -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"; - -const DEFAULT_TIMEOUT_MS = 30_000; -const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; - -function quoteGitCommand(args: ReadonlyArray): string { - return `git ${args.join(" ")}`; -} - -function toGitCommandError( - input: Pick, - detail: string, -) { - return (cause: unknown) => - cause instanceof GitCommandError - ? cause - : new GitCommandError({ - operation: input.operation, - command: quoteGitCommand(input.args), - cwd: input.cwd, - detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, - ...(cause !== undefined ? { cause } : {}), - }); -} - -const collectOutput = Effect.fn(function* ( - input: Pick, - stream: Stream.Stream, - maxOutputBytes: number, -): Effect.fn.Return { - const decoder = new TextDecoder(); - let bytes = 0; - let text = ""; - - yield* Stream.runForEach(stream, (chunk) => - Effect.gen(function* () { - bytes += chunk.byteLength; - if (bytes > maxOutputBytes) { - return yield* new GitCommandError({ - operation: input.operation, - command: quoteGitCommand(input.args), - cwd: input.cwd, - detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, - }); - } - text += decoder.decode(chunk, { stream: true }); - }), - ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); - - text += decoder.decode(); - return text; -}); - -const makeGitService = Effect.gen(function* () { - const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - - const execute: GitServiceShape["execute"] = Effect.fnUntraced(function* (input) { - const normalizedCwd = input.cwd.trim(); - const commandInput = { - ...input, - cwd: normalizedCwd, - args: [...input.args], - } as const; - const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; - - const commandEffect = Effect.gen(function* () { - const child = yield* commandSpawner - .spawn( - ChildProcess.make("git", commandInput.args, { - cwd: commandInput.cwd, - ...(input.env ? { env: input.env } : {}), - }), - ) - .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectOutput(commandInput, child.stdout, maxOutputBytes), - collectOutput(commandInput, child.stderr, maxOutputBytes), - child.exitCode.pipe( - Effect.map((value) => Number(value)), - Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), - ), - ], - { concurrency: "unbounded" }, - ); - - if (!input.allowNonZeroExit && exitCode !== 0) { - const trimmedStderr = stderr.trim(); - return yield* new GitCommandError({ - operation: commandInput.operation, - command: quoteGitCommand(commandInput.args), - cwd: commandInput.cwd, - detail: - trimmedStderr.length > 0 - ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` - : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, - }); - } - - return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult; - }); - - return yield* commandEffect.pipe( - Effect.scoped, - Effect.timeoutOption(timeoutMs), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail( - new GitCommandError({ - operation: commandInput.operation, - command: quoteGitCommand(commandInput.args), - cwd: commandInput.cwd, - detail: `${quoteGitCommand(commandInput.args)} timed out.`, - }), - ), - onSome: Effect.succeed, - }), - ), - ); - }); - - return { - execute, - } satisfies GitServiceShape; -}); - -export const GitServiceLive = Layer.effect(GitService, makeGitService); 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/Layers/GitService.test.ts b/apps/server/src/git/Layers/GitService.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..adaf32edd72afb783a840ceca8a9f68cc5bf7f28 --- /dev/null +++ b/apps/server/src/git/Layers/GitService.test.ts @@ -0,0 +1,59 @@ +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 "./GitService.ts"; +import { GitService } from "../Services/GitService.ts"; + +const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer)); + +layer("GitServiceLive", (it) => { + it.effect("runGit executes successful git commands", () => + Effect.gen(function* () { + const gitService = yield* GitService; + const result = yield* gitService.execute({ + operation: "GitProcess.test.version", + cwd: process.cwd(), + args: ["--version"], + }); + + assert.equal(result.code, 0); + assert.ok(result.stdout.toLowerCase().includes("git version")); + }), + ); + + it.effect("runGit can return non-zero exit codes when allowed", () => + Effect.gen(function* () { + const gitService = yield* GitService; + const result = yield* gitService.execute({ + operation: "GitProcess.test.allowNonZero", + cwd: process.cwd(), + args: ["rev-parse", "--verify", "__definitely_missing_ref__"], + allowNonZeroExit: true, + }); + + assert.notEqual(result.code, 0); + }), + ); + + it.effect("runGit fails with GitCommandError when non-zero exits are not allowed", () => + Effect.gen(function* () { + const gitService = yield* GitService; + const result = yield* Effect.result( + gitService.execute({ + operation: "GitProcess.test.failOnNonZero", + cwd: process.cwd(), + args: ["rev-parse", "--verify", "__definitely_missing_ref__"], + }), + ); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.ok(result.failure instanceof GitCommandError); + assert.equal(result.failure.operation, "GitProcess.test.failOnNonZero"); + assert.equal(result.failure.command, "git rev-parse --verify __definitely_missing_ref__"); + } + }), + ); +}); diff --git a/apps/server/src/git/Layers/GitService.ts b/apps/server/src/git/Layers/GitService.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f62d90e5253347a63edfa79354d516e978243a6 --- /dev/null +++ b/apps/server/src/git/Layers/GitService.ts @@ -0,0 +1,146 @@ +/** + * Git process helpers - Effect-native git execution with typed errors. + * + * Centralizes child-process git invocation for server modules. This module + * only executes git commands and reports structured failures. + * + * @module GitServiceLive + */ +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 "../Services/GitService.ts"; + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; + +function quoteGitCommand(args: ReadonlyArray): string { + return `git ${args.join(" ")}`; +} + +function toGitCommandError( + input: Pick, + detail: string, +) { + return (cause: unknown) => + cause instanceof GitCommandError + ? cause + : new GitCommandError({ + operation: input.operation, + command: quoteGitCommand(input.args), + cwd: input.cwd, + detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, + ...(cause !== undefined ? { cause } : {}), + }); +} + +const collectOutput = Effect.fn(function* ( + input: Pick, + stream: Stream.Stream, + maxOutputBytes: number, +): Effect.fn.Return { + const decoder = new TextDecoder(); + let bytes = 0; + let text = ""; + + yield* Stream.runForEach(stream, (chunk) => + Effect.gen(function* () { + bytes += chunk.byteLength; + if (bytes > maxOutputBytes) { + return yield* new GitCommandError({ + operation: input.operation, + command: quoteGitCommand(input.args), + cwd: input.cwd, + detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, + }); + } + text += decoder.decode(chunk, { stream: true }); + }), + ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); + + text += decoder.decode(); + return text; +}); + +const makeGitService = Effect.gen(function* () { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const execute: GitServiceShape["execute"] = Effect.fnUntraced(function* (input) { + const normalizedCwd = input.cwd.trim(); + const commandInput = { + ...input, + cwd: normalizedCwd, + args: [...input.args], + } as const; + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + + const commandEffect = Effect.gen(function* () { + const child = yield* commandSpawner + .spawn( + ChildProcess.make("git", commandInput.args, { + cwd: commandInput.cwd, + ...(input.env ? { env: input.env } : {}), + }), + ) + .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectOutput(commandInput, child.stdout, maxOutputBytes), + collectOutput(commandInput, child.stderr, maxOutputBytes), + child.exitCode.pipe( + Effect.map((value) => Number(value)), + Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), + ), + ], + { concurrency: "unbounded" }, + ); + + if (!input.allowNonZeroExit && exitCode !== 0) { + const trimmedStderr = stderr.trim(); + return yield* new GitCommandError({ + operation: commandInput.operation, + command: quoteGitCommand(commandInput.args), + cwd: commandInput.cwd, + detail: + trimmedStderr.length > 0 + ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` + : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, + }); + } + + return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult; + }); + + return yield* commandEffect.pipe( + Effect.scoped, + Effect.timeoutOption(timeoutMs), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail( + new GitCommandError({ + operation: commandInput.operation, + command: quoteGitCommand(commandInput.args), + cwd: commandInput.cwd, + detail: `${quoteGitCommand(commandInput.args)} timed out.`, + }), + ), + onSome: Effect.succeed, + }), + ), + ); + }); + + return { + execute, + } satisfies GitServiceShape; +}); + +export const GitServiceLive = Layer.effect(GitService, makeGitService); diff --git a/apps/server/src/git/Service.ts b/apps/server/src/git/Service.ts deleted file mode 100644 index 2619e13cd4f7e9183ac1251d8da6d25d9fd1f4b8..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Service.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * GitService - Service for Git command execution. - * - * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed - * domain errors for Git command execution. - * - * @module GitService - */ -import { ServiceMap } from "effect"; -import type { Effect } from "effect"; - -import type { GitCommandError } from "./Errors.ts"; - -export interface ExecuteGitInput { - readonly operation: string; - readonly cwd: string; - readonly args: ReadonlyArray; - readonly env?: NodeJS.ProcessEnv; - readonly allowNonZeroExit?: boolean; - readonly timeoutMs?: number; - readonly maxOutputBytes?: number; -} - -export interface ExecuteGitResult { - readonly code: number; - readonly stdout: string; - readonly stderr: string; -} - -export interface GitServiceShape { - /** - * Execute a Git command. - */ - readonly execute: (input: ExecuteGitInput) => Effect.Effect; -} - -/** - * GitService - Service for Git command execution. - */ -export class GitService extends ServiceMap.Service()( - "git/GitService", -) {} 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/Services/GitService.ts b/apps/server/src/git/Services/GitService.ts new file mode 100644 index 0000000000000000000000000000000000000000..185ec1d14552289daa2c812f1ba12289d564392b --- /dev/null +++ b/apps/server/src/git/Services/GitService.ts @@ -0,0 +1,42 @@ +/** + * GitService - Service for Git command execution. + * + * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed + * domain errors for Git command execution. + * + * @module GitService + */ +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { GitCommandError } from "../Errors.ts"; + +export interface ExecuteGitInput { + readonly operation: string; + readonly cwd: string; + readonly args: ReadonlyArray; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; +} + +export interface ExecuteGitResult { + readonly code: number; + readonly stdout: string; + readonly stderr: string; +} + +export interface GitServiceShape { + /** + * Execute a Git command. + */ + readonly execute: (input: ExecuteGitInput) => Effect.Effect; +} + +/** + * GitService - Service for Git command execution. + */ +export class GitService extends ServiceMap.Service()( + "git/GitService", +) {} 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.test.ts b/apps/server/src/ptyAdapter.test.ts deleted file mode 100644 index 25f30d6fe3abb274ef131c2af03f4ec29801d4b1..0000000000000000000000000000000000000000 --- a/apps/server/src/ptyAdapter.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { assert, it, vi } from "@effect/vitest"; -import { Effect, Layer } from "effect"; -import { afterEach } from "vitest"; - -import { ensureNodePtySpawnHelperExecutable } from "./ptyAdapter"; - -function fileMode(filePath: string): number { - return fs.statSync(filePath).mode & 0o777; -} - -const tempDirs: string[] = []; - -afterEach(() => { - vi.unstubAllGlobals(); - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -const helperLayer = it.layer(Layer.empty); - -helperLayer("ensureNodePtySpawnHelperExecutable", (it) => { - it.effect("adds executable bits when helper exists but is not executable", () => - Effect.sync(() => { - if (process.platform === "win32") return; - - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); - tempDirs.push(dir); - const helperPath = path.join(dir, "spawn-helper"); - fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); - fs.chmodSync(helperPath, 0o644); - - ensureNodePtySpawnHelperExecutable(helperPath); - - assert.equal(fileMode(helperPath) & 0o111, 0o111); - }), - ); - - it.effect("keeps executable helper as executable", () => - Effect.sync(() => { - if (process.platform === "win32") return; - - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); - tempDirs.push(dir); - const helperPath = path.join(dir, "spawn-helper"); - fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); - fs.chmodSync(helperPath, 0o755); - - ensureNodePtySpawnHelperExecutable(helperPath); - - assert.equal(fileMode(helperPath) & 0o111, 0o111); - }), - ); - - it.effect("does not throw when helper path is missing", () => - Effect.sync(() => { - const missing = path.join(os.tmpdir(), `does-not-exist-${Date.now()}`); - ensureNodePtySpawnHelperExecutable(missing); - }), - ); -}); 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/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..16c262ffca0fbef07a19ad3492780ec391089e4b --- /dev/null +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -0,0 +1,567 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + DEFAULT_TERMINAL_ID, + type TerminalEvent, + type TerminalOpenInput, +} from "@t3tools/contracts"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY"; +import { TerminalManagerRuntime } from "./Manager"; +import { Encoding } from "effect"; + +class FakePtyProcess implements PtyProcess { + readonly writes: string[] = []; + readonly resizeCalls: Array<{ cols: number; rows: number }> = []; + private readonly dataListeners = new Set<(data: string) => void>(); + private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); + killed = false; + + constructor(readonly pid: number) {} + + write(data: string): void { + this.writes.push(data); + } + + resize(cols: number, rows: number): void { + this.resizeCalls.push({ cols, rows }); + } + + kill(): void { + this.killed = true; + } + + 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: string): void { + for (const listener of this.dataListeners) { + listener(data); + } + } + + emitExit(event: PtyExitEvent): void { + for (const listener of this.exitListeners) { + listener(event); + } + } +} + +class FakePtyAdapter implements PtyAdapterShape { + readonly spawnInputs: PtySpawnInput[] = []; + readonly processes: FakePtyProcess[] = []; + readonly spawnFailures: Error[] = []; + private nextPid = 9000; + + spawn(input: PtySpawnInput): PtyProcess { + this.spawnInputs.push(input); + const failure = this.spawnFailures.shift(); + if (failure) { + throw failure; + } + const process = new FakePtyProcess(this.nextPid++); + this.processes.push(process); + return process; + } +} + +function waitFor(predicate: () => boolean, timeoutMs = 800): Promise { + const started = Date.now(); + return new Promise((resolve, reject) => { + const poll = () => { + if (predicate()) { + resolve(); + return; + } + if (Date.now() - started > timeoutMs) { + reject(new Error("Timed out waiting for condition")); + return; + } + setTimeout(poll, 15); + }; + poll(); + }); +} + +function openInput(overrides: Partial = {}): TerminalOpenInput { + return { + threadId: "thread-1", + cwd: process.cwd(), + cols: 100, + rows: 24, + ...overrides, + }; +} + +function historyLogName(threadId: string): string { + return `terminal_${Encoding.encodeBase64Url(threadId)}.log`; +} + +function multiTerminalHistoryLogName(threadId: string, terminalId: string): string { + const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; + if (terminalId === DEFAULT_TERMINAL_ID) { + return `${threadPart}.log`; + } + return `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`; +} + +function historyLogPath(logsDir: string, threadId = "thread-1"): string { + return path.join(logsDir, historyLogName(threadId)); +} + +function multiTerminalHistoryLogPath( + logsDir: string, + threadId = "thread-1", + terminalId = "default", +): string { + return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId)); +} + +describe("TerminalManager", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + function makeManager( + historyLineLimit = 5, + options: { + shellResolver?: () => string; + subprocessChecker?: (terminalPid: number) => Promise; + subprocessPollIntervalMs?: number; + } = {}, + ) { + const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-")); + tempDirs.push(logsDir); + const ptyAdapter = new FakePtyAdapter(); + const manager = new TerminalManagerRuntime({ + logsDir, + ptyAdapter, + historyLineLimit, + shellResolver: options.shellResolver ?? (() => "/bin/bash"), + ...(options.subprocessChecker ? { subprocessChecker: options.subprocessChecker } : {}), + ...(options.subprocessPollIntervalMs + ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } + : {}), + }); + return { logsDir, ptyAdapter, manager }; + } + + it("spawns lazily and reuses running terminal per thread", async () => { + const { manager, ptyAdapter } = makeManager(); + const [first, second] = await Promise.all([ + manager.open(openInput()), + manager.open(openInput()), + ]); + const third = await manager.open(openInput()); + + expect(first.threadId).toBe("thread-1"); + expect(first.terminalId).toBe("default"); + expect(second.threadId).toBe("thread-1"); + expect(third.threadId).toBe("thread-1"); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + + manager.dispose(); + }); + + it("forwards write and resize to active pty process", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + await manager.write({ threadId: "thread-1", data: "ls\n" }); + await manager.resize({ threadId: "thread-1", cols: 120, rows: 30 }); + + expect(process.writes).toEqual(["ls\n"]); + expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); + + manager.dispose(); + }); + + it("resizes running terminal on open when a different size is requested", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput({ cols: 100, rows: 24 })); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + await manager.open(openInput({ cols: 140, rows: 40 })); + + expect(process.resizeCalls).toEqual([{ cols: 140, rows: 40 }]); + + manager.dispose(); + }); + + it("preserves existing terminal size on open when size is omitted", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput({ cols: 100, rows: 24 })); + const ptyProcess = ptyAdapter.processes[0]; + expect(ptyProcess).toBeDefined(); + if (!ptyProcess) return; + + await manager.open({ + threadId: "thread-1", + cwd: globalThis.process.cwd(), + }); + + expect(ptyProcess.resizeCalls).toEqual([]); + + ptyProcess.emitExit({ exitCode: 0, signal: 0 }); + await manager.open({ + threadId: "thread-1", + cwd: globalThis.process.cwd(), + }); + + const resumedSpawn = ptyAdapter.spawnInputs[1]; + expect(resumedSpawn).toBeDefined(); + if (!resumedSpawn) return; + expect(resumedSpawn.cols).toBe(100); + expect(resumedSpawn.rows).toBe(24); + + manager.dispose(); + }); + + it("uses default dimensions when opening a new terminal without size hints", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open({ + threadId: "thread-1", + cwd: process.cwd(), + }); + + const spawned = ptyAdapter.spawnInputs[0]; + expect(spawned).toBeDefined(); + if (!spawned) return; + expect(spawned.cols).toBe(120); + expect(spawned.rows).toBe(30); + + manager.dispose(); + }); + + it("supports multiple terminals per thread with isolated sessions", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput({ terminalId: "default" })); + await manager.open(openInput({ terminalId: "term-2" })); + + const first = ptyAdapter.processes[0]; + const second = ptyAdapter.processes[1]; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + if (!first || !second) return; + + await manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); + await manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); + + expect(first.writes).toEqual(["pwd\n"]); + expect(second.writes).toEqual(["ls\n"]); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + + manager.dispose(); + }); + + it("clears transcript and emits cleared event", async () => { + const { manager, ptyAdapter, logsDir } = makeManager(); + const events: TerminalEvent[] = []; + manager.on("event", (event) => { + events.push(event); + }); + await manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("hello\n"); + await waitFor(() => fs.existsSync(historyLogPath(logsDir))); + await manager.clear({ threadId: "thread-1" }); + await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); + + expect(events.some((event) => event.type === "cleared")).toBe(true); + expect( + events.some( + (event) => + event.type === "cleared" && + event.threadId === "thread-1" && + event.terminalId === "default", + ), + ).toBe(true); + + manager.dispose(); + }); + + it("restarts terminal with empty transcript and respawns pty", async () => { + const { manager, ptyAdapter, logsDir } = makeManager(); + await manager.open(openInput()); + const firstProcess = ptyAdapter.processes[0]; + expect(firstProcess).toBeDefined(); + if (!firstProcess) return; + firstProcess.emitData("before restart\n"); + await waitFor(() => fs.existsSync(historyLogPath(logsDir))); + + const snapshot = await manager.restart(openInput()); + expect(snapshot.history).toBe(""); + expect(snapshot.status).toBe("running"); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); + + manager.dispose(); + }); + + it("emits exited event and reopens with clean transcript after exit", async () => { + const { manager, ptyAdapter, logsDir } = makeManager(); + const events: TerminalEvent[] = []; + manager.on("event", (event) => { + events.push(event); + }); + await manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + process.emitData("old data\n"); + await waitFor(() => fs.existsSync(historyLogPath(logsDir))); + process.emitExit({ exitCode: 0, signal: 0 }); + + await waitFor(() => events.some((event) => event.type === "exited")); + const reopened = await manager.open(openInput()); + + expect(reopened.history).toBe(""); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(fs.readFileSync(historyLogPath(logsDir), "utf8")).toBe(""); + + manager.dispose(); + }); + + it("emits subprocess activity events when child-process state changes", async () => { + let hasRunningSubprocess = false; + const { manager } = makeManager(5, { + subprocessChecker: async () => hasRunningSubprocess, + subprocessPollIntervalMs: 20, + }); + const events: TerminalEvent[] = []; + manager.on("event", (event) => { + events.push(event); + }); + + await manager.open(openInput()); + await waitFor(() => events.some((event) => event.type === "started")); + expect(events.some((event) => event.type === "activity")).toBe(false); + + hasRunningSubprocess = true; + await waitFor( + () => + events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true), + 1_200, + ); + + hasRunningSubprocess = false; + await waitFor( + () => + events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false), + 1_200, + ); + + manager.dispose(); + }); + + it("caps persisted history to configured line limit", async () => { + const { manager, ptyAdapter } = makeManager(3); + await manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("line1\nline2\nline3\nline4\n"); + await manager.close({ threadId: "thread-1" }); + + const reopened = await manager.open(openInput()); + const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0); + expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]); + + manager.dispose(); + }); + + it("deletes history file when close(deleteHistory=true)", async () => { + const { manager, ptyAdapter, logsDir } = makeManager(); + await manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + process.emitData("bye\n"); + await waitFor(() => fs.existsSync(historyLogPath(logsDir))); + + await manager.close({ threadId: "thread-1", deleteHistory: true }); + expect(fs.existsSync(historyLogPath(logsDir))).toBe(false); + + manager.dispose(); + }); + + it("closes all terminals for a thread when close omits terminalId", async () => { + const { manager, ptyAdapter, logsDir } = makeManager(); + await manager.open(openInput({ terminalId: "default" })); + await manager.open(openInput({ terminalId: "sidecar" })); + const defaultProcess = ptyAdapter.processes[0]; + const sidecarProcess = ptyAdapter.processes[1]; + expect(defaultProcess).toBeDefined(); + expect(sidecarProcess).toBeDefined(); + if (!defaultProcess || !sidecarProcess) return; + + defaultProcess.emitData("default\n"); + sidecarProcess.emitData("sidecar\n"); + await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))); + await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))); + + await manager.close({ threadId: "thread-1", deleteHistory: true }); + + expect(defaultProcess.killed).toBe(true); + expect(sidecarProcess.killed).toBe(true); + expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))).toBe(false); + expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))).toBe(false); + + manager.dispose(); + }); + + it("migrates legacy transcript filenames to terminal-scoped history path on open", async () => { + const { manager, logsDir } = makeManager(); + const legacyPath = path.join(logsDir, "thread-1.log"); + const nextPath = historyLogPath(logsDir); + fs.writeFileSync(legacyPath, "legacy-line\n", "utf8"); + + const snapshot = await manager.open(openInput()); + + expect(snapshot.history).toBe("legacy-line\n"); + expect(fs.existsSync(nextPath)).toBe(true); + expect(fs.readFileSync(nextPath, "utf8")).toBe("legacy-line\n"); + expect(fs.existsSync(legacyPath)).toBe(false); + + manager.dispose(); + }); + + it("retries with fallback shells when preferred shell spawn fails", async () => { + const { manager, ptyAdapter } = makeManager(5, { + shellResolver: () => "/definitely/missing-shell -l", + }); + ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed.")); + + const snapshot = await manager.open(openInput()); + + expect(snapshot.status).toBe("running"); + expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); + expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell"); + + if (process.platform === "win32") { + expect( + ptyAdapter.spawnInputs.some( + (input) => input.shell === "cmd.exe" || input.shell === "powershell.exe", + ), + ).toBe(true); + } else { + expect( + ptyAdapter.spawnInputs.some((input) => + ["/bin/zsh", "/bin/bash", "/bin/sh", "zsh", "bash", "sh"].includes(input.shell), + ), + ).toBe(true); + } + + manager.dispose(); + }); + + it("filters app runtime env variables from terminal sessions", async () => { + const originalValues = new Map(); + const setEnv = (key: string, value: string | undefined) => { + if (!originalValues.has(key)) { + originalValues.set(key, process.env[key]); + } + if (value === undefined) { + delete process.env[key]; + return; + } + process.env[key] = value; + }; + const restoreEnv = () => { + for (const [key, value] of originalValues) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }; + + setEnv("PORT", "5173"); + setEnv("T3CODE_PORT", "3773"); + setEnv("VITE_DEV_SERVER_URL", "http://localhost:5173"); + setEnv("TEST_TERMINAL_KEEP", "keep-me"); + + try { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + expect(spawnInput.env.PORT).toBeUndefined(); + expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); + expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); + expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); + + manager.dispose(); + } finally { + restoreEnv(); + } + }); + + it("injects runtime env overrides into spawned terminals", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open( + openInput({ + env: { + T3CODE_PROJECT_ROOT: "/repo", + T3CODE_WORKTREE_PATH: "/repo/worktree-a", + CUSTOM_FLAG: "1", + }, + }), + ); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + expect(spawnInput.env.T3CODE_PROJECT_ROOT).toBe("/repo"); + expect(spawnInput.env.T3CODE_WORKTREE_PATH).toBe("/repo/worktree-a"); + expect(spawnInput.env.CUSTOM_FLAG).toBe("1"); + + manager.dispose(); + }); + + it("starts zsh with prompt spacer disabled to avoid `%` end markers", async () => { + if (process.platform === "win32") return; + const { manager, ptyAdapter } = makeManager(5, { + shellResolver: () => "/bin/zsh", + }); + await manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + expect(spawnInput.shell).toBe("/bin/zsh"); + expect(spawnInput.args).toEqual(["-o", "nopromptsp"]); + + manager.dispose(); + }); +}); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..da5910eba3d673c9813c09926b4317c3fde996ce --- /dev/null +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -0,0 +1,1047 @@ +import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import path from "node:path"; + +import { + DEFAULT_TERMINAL_ID, + TerminalClearInput, + TerminalCloseInput, + TerminalOpenInput, + TerminalResizeInput, + TerminalWriteInput, + type TerminalEvent, + type TerminalSessionSnapshot, +} from "@t3tools/contracts"; +import { Effect, Encoding, Layer, Path, Schema } from "effect"; + +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; +const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; +const DEFAULT_OPEN_COLS = 120; +const DEFAULT_OPEN_ROWS = 30; +const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); + +const decodeTerminalOpenInput = Schema.decodeUnknownSync(TerminalOpenInput); +const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput); +const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput); +const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput); +const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput); + +type TerminalSubprocessChecker = (terminalPid: number) => Promise; + +function defaultShellResolver(): string { + if (process.platform === "win32") { + return process.env.ComSpec ?? "cmd.exe"; + } + return process.env.SHELL ?? "bash"; +} + +function normalizeShellCommand(value: string | undefined): string | null { + if (!value) return null; + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + + if (process.platform === "win32") { + return trimmed; + } + + const firstToken = trimmed.split(/\s+/g)[0]?.trim(); + if (!firstToken) return null; + return firstToken.replace(/^['"]|['"]$/g, ""); +} + +function shellCandidateFromCommand(command: string | null): ShellCandidate | null { + if (!command || command.length === 0) return null; + const shellName = path.basename(command).toLowerCase(); + if (process.platform !== "win32" && shellName === "zsh") { + return { shell: command, args: ["-o", "nopromptsp"] }; + } + return { shell: command }; +} + +function formatShellCandidate(candidate: ShellCandidate): string { + if (!candidate.args || candidate.args.length === 0) return candidate.shell; + return `${candidate.shell} ${candidate.args.join(" ")}`; +} + +function uniqueShellCandidates(candidates: Array): ShellCandidate[] { + const seen = new Set(); + const ordered: ShellCandidate[] = []; + for (const candidate of candidates) { + if (!candidate) continue; + const key = formatShellCandidate(candidate); + if (seen.has(key)) continue; + seen.add(key); + ordered.push(candidate); + } + return ordered; +} + +function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] { + const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver())); + + if (process.platform === "win32") { + return uniqueShellCandidates([ + requested, + shellCandidateFromCommand(process.env.ComSpec ?? null), + shellCandidateFromCommand("powershell.exe"), + shellCandidateFromCommand("cmd.exe"), + ]); + } + + return uniqueShellCandidates([ + requested, + shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)), + shellCandidateFromCommand("/bin/zsh"), + shellCandidateFromCommand("/bin/bash"), + shellCandidateFromCommand("/bin/sh"), + shellCandidateFromCommand("zsh"), + shellCandidateFromCommand("bash"), + shellCandidateFromCommand("sh"), + ]); +} + +function isRetryableShellSpawnError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const message = error.message.toLowerCase(); + return ( + message.includes("posix_spawnp failed") || + message.includes("enoent") || + message.includes("not found") || + message.includes("file not found") || + message.includes("no such file") + ); +} + +async function checkWindowsSubprocessActivity(terminalPid: number): Promise { + const command = [ + `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, + "if ($children) { exit 0 }", + "exit 1", + ].join("; "); + try { + const result = await runProcess( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", command], + { + timeoutMs: 1_500, + allowNonZeroExit: true, + maxBufferBytes: 32_768, + outputMode: "truncate", + }, + ); + return result.code === 0; + } catch { + return false; + } +} + +async function checkPosixSubprocessActivity(terminalPid: number): Promise { + try { + const pgrepResult = await runProcess("pgrep", ["-P", String(terminalPid)], { + timeoutMs: 1_000, + allowNonZeroExit: true, + maxBufferBytes: 32_768, + outputMode: "truncate", + }); + if (pgrepResult.code === 0) { + return pgrepResult.stdout.trim().length > 0; + } + if (pgrepResult.code === 1) { + return false; + } + } catch { + // Fall back to ps when pgrep is unavailable. + } + + try { + const psResult = await runProcess("ps", ["-eo", "pid=,ppid="], { + timeoutMs: 1_000, + allowNonZeroExit: true, + maxBufferBytes: 262_144, + outputMode: "truncate", + }); + if (psResult.code !== 0) { + return false; + } + + for (const line of psResult.stdout.split(/\r?\n/g)) { + const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); + const pid = Number(pidRaw); + const ppid = Number(ppidRaw); + if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; + if (ppid === terminalPid) { + return true; + } + } + return false; + } catch { + return false; + } +} + +async function defaultSubprocessChecker(terminalPid: number): Promise { + if (!Number.isInteger(terminalPid) || terminalPid <= 0) { + return false; + } + if (process.platform === "win32") { + return checkWindowsSubprocessActivity(terminalPid); + } + return checkPosixSubprocessActivity(terminalPid); +} + +function capHistory(history: string, maxLines: number): string { + if (history.length === 0) return history; + const hasTrailingNewline = history.endsWith("\n"); + const lines = history.split("\n"); + if (hasTrailingNewline) { + lines.pop(); + } + if (lines.length <= maxLines) return history; + const capped = lines.slice(lines.length - maxLines).join("\n"); + return hasTrailingNewline ? `${capped}\n` : capped; +} + +function legacySafeThreadId(threadId: string): string { + return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); +} + +function toSafeThreadId(threadId: string): string { + return `terminal_${Encoding.encodeBase64Url(threadId)}`; +} + +function toSafeTerminalId(terminalId: string): string { + return Encoding.encodeBase64Url(terminalId); +} + +function toSessionKey(threadId: string, terminalId: string): string { + return `${threadId}\u0000${terminalId}`; +} + +function shouldExcludeTerminalEnvKey(key: string): boolean { + const normalizedKey = key.toUpperCase(); + if (normalizedKey.startsWith("T3CODE_")) { + return true; + } + if (normalizedKey.startsWith("VITE_")) { + return true; + } + return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); +} + +function createTerminalSpawnEnv( + baseEnv: NodeJS.ProcessEnv, + runtimeEnv?: Record | null, +): NodeJS.ProcessEnv { + const spawnEnv: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(baseEnv)) { + if (value === undefined) continue; + if (shouldExcludeTerminalEnvKey(key)) continue; + spawnEnv[key] = value; + } + if (runtimeEnv) { + for (const [key, value] of Object.entries(runtimeEnv)) { + spawnEnv[key] = value; + } + } + return spawnEnv; +} + +function normalizedRuntimeEnv( + env: Record | undefined, +): Record | null { + if (!env) return null; + const entries = Object.entries(env); + if (entries.length === 0) return null; + return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right))); +} + +export class TerminalManagerRuntime extends EventEmitter { + private readonly sessions = new Map(); + private readonly logsDir: string; + private readonly historyLineLimit: number; + private readonly ptyAdapter: PtyAdapterShape; + private readonly shellResolver: () => string; + private readonly persistQueues = new Map>(); + private readonly persistTimers = new Map>(); + private readonly pendingPersistHistory = new Map(); + private readonly threadLocks = new Map>(); + private readonly persistDebounceMs: number; + private readonly subprocessChecker: TerminalSubprocessChecker; + private readonly subprocessPollIntervalMs: number; + private subprocessPollTimer: ReturnType | null = null; + private subprocessPollInFlight = false; + private readonly logger = createLogger("terminal"); + + constructor(options: TerminalManagerOptions) { + super(); + this.logsDir = options.logsDir ?? path.resolve(process.cwd(), ".logs", "terminals"); + this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; + this.ptyAdapter = options.ptyAdapter; + this.shellResolver = options.shellResolver ?? defaultShellResolver; + this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; + this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; + this.subprocessPollIntervalMs = + options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; + fs.mkdirSync(this.logsDir, { recursive: true }); + } + + async open(raw: TerminalOpenInput): Promise { + const input = decodeTerminalOpenInput(raw); + return this.runWithThreadLock(input.threadId, async () => { + await this.assertValidCwd(input.cwd); + + const sessionKey = toSessionKey(input.threadId, input.terminalId); + const existing = this.sessions.get(sessionKey); + if (!existing) { + await this.flushPersistQueue(input.threadId, input.terminalId); + const history = await this.readHistory(input.threadId, input.terminalId); + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; + const session: TerminalSessionState = { + threadId: input.threadId, + terminalId: input.terminalId, + cwd: input.cwd, + status: "starting", + pid: null, + history, + exitCode: null, + exitSignal: null, + updatedAt: new Date().toISOString(), + cols, + rows, + process: null, + unsubscribeData: null, + unsubscribeExit: null, + hasRunningSubprocess: false, + runtimeEnv: normalizedRuntimeEnv(input.env), + }; + this.sessions.set(sessionKey, session); + this.startSession(session, { ...input, cols, rows }, "started"); + return this.snapshot(session); + } + + const nextRuntimeEnv = normalizedRuntimeEnv(input.env); + const currentRuntimeEnv = existing.runtimeEnv; + const targetCols = input.cols ?? existing.cols; + const targetRows = input.rows ?? existing.rows; + const runtimeEnvChanged = + JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv); + + if (existing.cwd !== input.cwd || runtimeEnvChanged) { + this.stopProcess(existing); + existing.cwd = input.cwd; + existing.runtimeEnv = nextRuntimeEnv; + existing.history = ""; + await this.persistHistory(existing.threadId, existing.terminalId, existing.history); + } else if (existing.status === "exited" || existing.status === "error") { + existing.runtimeEnv = nextRuntimeEnv; + existing.history = ""; + await this.persistHistory(existing.threadId, existing.terminalId, existing.history); + } else if (currentRuntimeEnv !== nextRuntimeEnv) { + existing.runtimeEnv = nextRuntimeEnv; + } + + if (!existing.process) { + this.startSession(existing, { ...input, cols: targetCols, rows: targetRows }, "started"); + return this.snapshot(existing); + } + + if (existing.cols !== targetCols || existing.rows !== targetRows) { + existing.cols = targetCols; + existing.rows = targetRows; + existing.process.resize(targetCols, targetRows); + existing.updatedAt = new Date().toISOString(); + } + + return this.snapshot(existing); + }); + } + + async write(raw: TerminalWriteInput): Promise { + const input = decodeTerminalWriteInput(raw); + const session = this.requireSession(input.threadId, input.terminalId); + if (!session.process || session.status !== "running") { + throw new Error( + `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, + ); + } + session.process.write(input.data); + } + + async resize(raw: TerminalResizeInput): Promise { + const input = decodeTerminalResizeInput(raw); + const session = this.requireSession(input.threadId, input.terminalId); + if (!session.process || session.status !== "running") { + throw new Error( + `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, + ); + } + session.cols = input.cols; + session.rows = input.rows; + session.updatedAt = new Date().toISOString(); + session.process.resize(input.cols, input.rows); + } + + async clear(raw: TerminalClearInput): Promise { + const input = decodeTerminalClearInput(raw); + await this.runWithThreadLock(input.threadId, async () => { + const session = this.requireSession(input.threadId, input.terminalId); + session.history = ""; + session.updatedAt = new Date().toISOString(); + await this.persistHistory(input.threadId, input.terminalId, session.history); + this.emitEvent({ + type: "cleared", + threadId: input.threadId, + terminalId: input.terminalId, + createdAt: new Date().toISOString(), + }); + }); + } + + async restart(raw: TerminalOpenInput): Promise { + const input = decodeTerminalOpenInput(raw); + return this.runWithThreadLock(input.threadId, async () => { + await this.assertValidCwd(input.cwd); + + const sessionKey = toSessionKey(input.threadId, input.terminalId); + let session = this.sessions.get(sessionKey); + if (!session) { + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; + session = { + threadId: input.threadId, + terminalId: input.terminalId, + cwd: input.cwd, + status: "starting", + pid: null, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: new Date().toISOString(), + cols, + rows, + process: null, + unsubscribeData: null, + unsubscribeExit: null, + hasRunningSubprocess: false, + runtimeEnv: normalizedRuntimeEnv(input.env), + }; + this.sessions.set(sessionKey, session); + } else { + this.stopProcess(session); + session.cwd = input.cwd; + session.runtimeEnv = normalizedRuntimeEnv(input.env); + } + + const cols = input.cols ?? session.cols; + const rows = input.rows ?? session.rows; + + session.history = ""; + await this.persistHistory(input.threadId, input.terminalId, session.history); + this.startSession(session, { ...input, cols, rows }, "restarted"); + return this.snapshot(session); + }); + } + + async close(raw: TerminalCloseInput): Promise { + const input = decodeTerminalCloseInput(raw); + await this.runWithThreadLock(input.threadId, async () => { + if (input.terminalId) { + await this.closeSession(input.threadId, input.terminalId, input.deleteHistory === true); + return; + } + + const threadSessions = this.sessionsForThread(input.threadId); + for (const session of threadSessions) { + this.stopProcess(session); + this.sessions.delete(toSessionKey(session.threadId, session.terminalId)); + } + await Promise.all( + threadSessions.map((session) => + this.flushPersistQueue(session.threadId, session.terminalId), + ), + ); + + if (input.deleteHistory) { + await this.deleteAllHistoryForThread(input.threadId); + } + this.updateSubprocessPollingState(); + }); + } + + dispose(): void { + this.stopSubprocessPolling(); + const sessions = [...this.sessions.values()]; + this.sessions.clear(); + for (const session of sessions) { + this.stopProcess(session); + } + for (const timer of this.persistTimers.values()) { + clearTimeout(timer); + } + this.persistTimers.clear(); + this.pendingPersistHistory.clear(); + this.threadLocks.clear(); + this.persistQueues.clear(); + } + + private startSession( + session: TerminalSessionState, + input: TerminalStartInput, + eventType: "started" | "restarted", + ): void { + this.stopProcess(session); + + session.status = "starting"; + session.cwd = input.cwd; + session.cols = input.cols; + session.rows = input.rows; + session.exitCode = null; + session.exitSignal = null; + session.hasRunningSubprocess = false; + session.updatedAt = new Date().toISOString(); + + let ptyProcess: PtyProcess | null = null; + let startedShell: string | null = null; + try { + const shellCandidates = resolveShellCandidates(this.shellResolver); + const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); + let lastSpawnError: unknown = null; + + for (const candidate of shellCandidates) { + try { + ptyProcess = this.ptyAdapter.spawn({ + shell: candidate.shell, + ...(candidate.args ? { args: candidate.args } : {}), + cwd: session.cwd, + cols: session.cols, + rows: session.rows, + env: terminalEnv, + }); + startedShell = formatShellCandidate(candidate); + break; + } catch (error) { + lastSpawnError = error; + if (!isRetryableShellSpawnError(error)) { + throw error; + } + } + } + + if (!ptyProcess) { + const detail = + lastSpawnError instanceof Error ? lastSpawnError.message : "Terminal start failed"; + const tried = + shellCandidates.length > 0 + ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.` + : ""; + throw new Error(`${detail}.${tried}`.trim()); + } + + session.process = ptyProcess; + session.pid = ptyProcess.pid; + session.status = "running"; + session.updatedAt = new Date().toISOString(); + session.unsubscribeData = ptyProcess.onData((data) => { + this.onProcessData(session, data); + }); + session.unsubscribeExit = ptyProcess.onExit((event) => { + this.onProcessExit(session, event); + }); + this.updateSubprocessPollingState(); + this.emitEvent({ + type: eventType, + threadId: session.threadId, + terminalId: session.terminalId, + createdAt: new Date().toISOString(), + snapshot: this.snapshot(session), + }); + } catch (error) { + if (ptyProcess) { + try { + ptyProcess.kill(); + } catch { + // Ignore kill errors during failed startup cleanup. + } + } + session.status = "error"; + session.pid = null; + session.process = null; + session.hasRunningSubprocess = false; + session.updatedAt = new Date().toISOString(); + this.updateSubprocessPollingState(); + const message = error instanceof Error ? error.message : "Terminal start failed"; + this.emitEvent({ + type: "error", + threadId: session.threadId, + terminalId: session.terminalId, + createdAt: new Date().toISOString(), + message, + }); + this.logger.error("failed to start terminal", { + threadId: session.threadId, + terminalId: session.terminalId, + error: message, + ...(startedShell ? { shell: startedShell } : {}), + }); + } + } + + private onProcessData(session: TerminalSessionState, data: string): void { + session.history = capHistory(`${session.history}${data}`, this.historyLineLimit); + session.updatedAt = new Date().toISOString(); + this.queuePersist(session.threadId, session.terminalId, session.history); + this.emitEvent({ + type: "output", + threadId: session.threadId, + terminalId: session.terminalId, + createdAt: new Date().toISOString(), + data, + }); + } + + private onProcessExit(session: TerminalSessionState, event: PtyExitEvent): void { + this.cleanupProcessHandles(session); + session.process = null; + session.pid = null; + session.hasRunningSubprocess = false; + session.status = "exited"; + session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null; + session.exitSignal = Number.isInteger(event.signal) ? event.signal : null; + session.updatedAt = new Date().toISOString(); + this.emitEvent({ + type: "exited", + threadId: session.threadId, + terminalId: session.terminalId, + createdAt: new Date().toISOString(), + exitCode: session.exitCode, + exitSignal: session.exitSignal, + }); + this.updateSubprocessPollingState(); + } + + private stopProcess(session: TerminalSessionState): void { + const process = session.process; + if (!process) return; + this.cleanupProcessHandles(session); + session.process = null; + session.pid = null; + session.hasRunningSubprocess = false; + session.status = "exited"; + session.updatedAt = new Date().toISOString(); + try { + process.kill(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn("failed to kill terminal process", { + threadId: session.threadId, + terminalId: session.terminalId, + error: message, + }); + } + this.updateSubprocessPollingState(); + } + + private cleanupProcessHandles(session: TerminalSessionState): void { + session.unsubscribeData?.(); + session.unsubscribeData = null; + session.unsubscribeExit?.(); + session.unsubscribeExit = null; + } + + private queuePersist(threadId: string, terminalId: string, history: string): void { + const persistenceKey = toSessionKey(threadId, terminalId); + this.pendingPersistHistory.set(persistenceKey, history); + this.schedulePersist(threadId, terminalId); + } + + private async persistHistory( + threadId: string, + terminalId: string, + history: string, + ): Promise { + const persistenceKey = toSessionKey(threadId, terminalId); + this.clearPersistTimer(threadId, terminalId); + this.pendingPersistHistory.delete(persistenceKey); + await this.enqueuePersistWrite(threadId, terminalId, history); + } + + private enqueuePersistWrite( + threadId: string, + terminalId: string, + history: string, + ): Promise { + const persistenceKey = toSessionKey(threadId, terminalId); + const task = async () => { + await fs.promises.writeFile(this.historyPath(threadId, terminalId), history, "utf8"); + }; + const previous = this.persistQueues.get(persistenceKey) ?? Promise.resolve(); + const next = previous + .catch(() => undefined) + .then(task) + .catch((error) => { + this.logger.warn("failed to persist terminal history", { + threadId, + terminalId, + error: error instanceof Error ? error.message : String(error), + }); + }); + this.persistQueues.set(persistenceKey, next); + const finalized = next.finally(() => { + if (this.persistQueues.get(persistenceKey) === next) { + this.persistQueues.delete(persistenceKey); + } + if ( + this.pendingPersistHistory.has(persistenceKey) && + !this.persistTimers.has(persistenceKey) + ) { + this.schedulePersist(threadId, terminalId); + } + }); + void finalized.catch(() => undefined); + return finalized; + } + + private schedulePersist(threadId: string, terminalId: string): void { + const persistenceKey = toSessionKey(threadId, terminalId); + if (this.persistTimers.has(persistenceKey)) return; + const timer = setTimeout(() => { + this.persistTimers.delete(persistenceKey); + const pendingHistory = this.pendingPersistHistory.get(persistenceKey); + if (pendingHistory === undefined) return; + this.pendingPersistHistory.delete(persistenceKey); + void this.enqueuePersistWrite(threadId, terminalId, pendingHistory); + }, this.persistDebounceMs); + this.persistTimers.set(persistenceKey, timer); + } + + private clearPersistTimer(threadId: string, terminalId: string): void { + const persistenceKey = toSessionKey(threadId, terminalId); + const timer = this.persistTimers.get(persistenceKey); + if (!timer) return; + clearTimeout(timer); + this.persistTimers.delete(persistenceKey); + } + + private async readHistory(threadId: string, terminalId: string): Promise { + const nextPath = this.historyPath(threadId, terminalId); + try { + const raw = await fs.promises.readFile(nextPath, "utf8"); + const capped = capHistory(raw, this.historyLineLimit); + if (capped !== raw) { + await fs.promises.writeFile(nextPath, capped, "utf8"); + } + return capped; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + + if (terminalId !== DEFAULT_TERMINAL_ID) { + return ""; + } + + const legacyPath = this.legacyHistoryPath(threadId); + try { + const raw = await fs.promises.readFile(legacyPath, "utf8"); + const capped = capHistory(raw, this.historyLineLimit); + + // Migrate legacy transcript filename to the terminal-scoped path. + await fs.promises.writeFile(nextPath, capped, "utf8"); + try { + await fs.promises.rm(legacyPath, { force: true }); + } catch (cleanupError) { + this.logger.warn("failed to remove legacy terminal history", { + threadId, + error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }); + } + + return capped; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return ""; + } + throw error; + } + } + + private async deleteHistory(threadId: string, terminalId: string): Promise { + const deletions = [fs.promises.rm(this.historyPath(threadId, terminalId), { force: true })]; + if (terminalId === DEFAULT_TERMINAL_ID) { + deletions.push(fs.promises.rm(this.legacyHistoryPath(threadId), { force: true })); + } + try { + await Promise.all(deletions); + } catch (error) { + this.logger.warn("failed to delete terminal history", { + threadId, + terminalId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + private async flushPersistQueue(threadId: string, terminalId: string): Promise { + const persistenceKey = toSessionKey(threadId, terminalId); + this.clearPersistTimer(threadId, terminalId); + + while (true) { + const pendingHistory = this.pendingPersistHistory.get(persistenceKey); + if (pendingHistory !== undefined) { + this.pendingPersistHistory.delete(persistenceKey); + await this.enqueuePersistWrite(threadId, terminalId, pendingHistory); + } + + const pending = this.persistQueues.get(persistenceKey); + if (!pending) { + return; + } + await pending.catch(() => undefined); + } + } + + private updateSubprocessPollingState(): void { + const hasRunningSessions = [...this.sessions.values()].some( + (session) => session.status === "running" && session.pid !== null, + ); + if (hasRunningSessions) { + this.ensureSubprocessPolling(); + return; + } + this.stopSubprocessPolling(); + } + + private ensureSubprocessPolling(): void { + if (this.subprocessPollTimer) return; + this.subprocessPollTimer = setInterval(() => { + void this.pollSubprocessActivity(); + }, this.subprocessPollIntervalMs); + this.subprocessPollTimer.unref?.(); + void this.pollSubprocessActivity(); + } + + private stopSubprocessPolling(): void { + if (!this.subprocessPollTimer) return; + clearInterval(this.subprocessPollTimer); + this.subprocessPollTimer = null; + } + + private async pollSubprocessActivity(): Promise { + if (this.subprocessPollInFlight) return; + + const runningSessions = [...this.sessions.values()].filter( + (session): session is TerminalSessionState & { pid: number } => + session.status === "running" && Number.isInteger(session.pid), + ); + if (runningSessions.length === 0) { + this.stopSubprocessPolling(); + return; + } + + this.subprocessPollInFlight = true; + try { + await Promise.all( + runningSessions.map(async (session) => { + const terminalPid = session.pid; + let hasRunningSubprocess = false; + try { + hasRunningSubprocess = await this.subprocessChecker(terminalPid); + } catch (error) { + this.logger.warn("failed to check terminal subprocess activity", { + threadId: session.threadId, + terminalId: session.terminalId, + terminalPid, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + const liveSession = this.sessions.get(toSessionKey(session.threadId, session.terminalId)); + if (!liveSession || liveSession.status !== "running" || liveSession.pid !== terminalPid) { + return; + } + if (liveSession.hasRunningSubprocess === hasRunningSubprocess) { + return; + } + + liveSession.hasRunningSubprocess = hasRunningSubprocess; + liveSession.updatedAt = new Date().toISOString(); + this.emitEvent({ + type: "activity", + threadId: liveSession.threadId, + terminalId: liveSession.terminalId, + createdAt: new Date().toISOString(), + hasRunningSubprocess, + }); + }), + ); + } finally { + this.subprocessPollInFlight = false; + } + } + + private async assertValidCwd(cwd: string): Promise { + let stats: fs.Stats; + try { + stats = await fs.promises.stat(cwd); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`Terminal cwd does not exist: ${cwd}`, { cause: error }); + } + throw error; + } + if (!stats.isDirectory()) { + throw new Error(`Terminal cwd is not a directory: ${cwd}`); + } + } + + private async closeSession( + threadId: string, + terminalId: string, + deleteHistory: boolean, + ): Promise { + const key = toSessionKey(threadId, terminalId); + const session = this.sessions.get(key); + if (session) { + this.stopProcess(session); + this.sessions.delete(key); + } + this.updateSubprocessPollingState(); + await this.flushPersistQueue(threadId, terminalId); + if (deleteHistory) { + await this.deleteHistory(threadId, terminalId); + } + } + + private sessionsForThread(threadId: string): TerminalSessionState[] { + return [...this.sessions.values()].filter((session) => session.threadId === threadId); + } + + private async deleteAllHistoryForThread(threadId: string): Promise { + const threadPrefix = `${toSafeThreadId(threadId)}_`; + try { + const entries = await fs.promises.readdir(this.logsDir, { withFileTypes: true }); + const removals = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .filter( + (name) => + name === `${toSafeThreadId(threadId)}.log` || + name === `${legacySafeThreadId(threadId)}.log` || + name.startsWith(threadPrefix), + ) + .map((name) => fs.promises.rm(path.join(this.logsDir, name), { force: true })); + await Promise.all(removals); + } catch (error) { + this.logger.warn("failed to delete terminal histories for thread", { + threadId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + private requireSession(threadId: string, terminalId: string): TerminalSessionState { + const session = this.sessions.get(toSessionKey(threadId, terminalId)); + if (!session) { + throw new Error(`Unknown terminal thread: ${threadId}, terminal: ${terminalId}`); + } + return session; + } + + private snapshot(session: TerminalSessionState): TerminalSessionSnapshot { + return { + threadId: session.threadId, + terminalId: session.terminalId, + cwd: session.cwd, + status: session.status, + pid: session.pid, + history: session.history, + exitCode: session.exitCode, + exitSignal: session.exitSignal, + updatedAt: session.updatedAt, + }; + } + + private emitEvent(event: TerminalEvent): void { + this.emit("event", event); + } + + private historyPath(threadId: string, terminalId: string): string { + const threadPart = toSafeThreadId(threadId); + if (terminalId === DEFAULT_TERMINAL_ID) { + return path.join(this.logsDir, `${threadPart}.log`); + } + return path.join(this.logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); + } + + private legacyHistoryPath(threadId: string): string { + return path.join(this.logsDir, `${legacySafeThreadId(threadId)}.log`); + } + + private async runWithThreadLock(threadId: string, task: () => Promise): Promise { + const previous = this.threadLocks.get(threadId) ?? Promise.resolve(); + let release: () => void = () => {}; + const current = new Promise((resolve) => { + release = resolve; + }); + this.threadLocks.set(threadId, current); + await previous.catch(() => undefined); + try { + return await task(); + } finally { + release(); + if (this.threadLocks.get(threadId) === current) { + this.threadLocks.delete(threadId); + } + } + } +} + +export const TerminalManagerLive = Layer.effect( + TerminalManager, + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig; + const { join } = yield* Path.Path; + const logsDir = join(stateDir, "logs", "terminals"); + + 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/terminal/Layers/NodePTY.test.ts b/apps/server/src/terminal/Layers/NodePTY.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..90e349cc5d80cede3ae504e8baa4153a49049eac --- /dev/null +++ b/apps/server/src/terminal/Layers/NodePTY.test.ts @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { assert, it, vi } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { afterEach } from "vitest"; + +import { ensureNodePtySpawnHelperExecutable } from "./NodePTY"; + +function fileMode(filePath: string): number { + return fs.statSync(filePath).mode & 0o777; +} + +const tempDirs: string[] = []; + +afterEach(() => { + vi.unstubAllGlobals(); + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +const helperLayer = it.layer(Layer.empty); + +helperLayer("ensureNodePtySpawnHelperExecutable", (it) => { + it.effect("adds executable bits when helper exists but is not executable", () => + Effect.sync(() => { + if (process.platform === "win32") return; + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); + tempDirs.push(dir); + const helperPath = path.join(dir, "spawn-helper"); + fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(helperPath, 0o644); + + ensureNodePtySpawnHelperExecutable(helperPath); + + assert.equal(fileMode(helperPath) & 0o111, 0o111); + }), + ); + + it.effect("keeps executable helper as executable", () => + Effect.sync(() => { + if (process.platform === "win32") return; + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); + tempDirs.push(dir); + const helperPath = path.join(dir, "spawn-helper"); + fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(helperPath, 0o755); + + ensureNodePtySpawnHelperExecutable(helperPath); + + assert.equal(fileMode(helperPath) & 0o111, 0o111); + }), + ); + + it.effect("does not throw when helper path is missing", () => + Effect.sync(() => { + const missing = path.join(os.tmpdir(), `does-not-exist-${Date.now()}`); + ensureNodePtySpawnHelperExecutable(missing); + }), + ); +}); 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/terminalManager.test.ts b/apps/server/src/terminalManager.test.ts deleted file mode 100644 index b0e4bd1c05818cc0d15b2869b1fbce02257cbe1e..0000000000000000000000000000000000000000 --- a/apps/server/src/terminalManager.test.ts +++ /dev/null @@ -1,567 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { - DEFAULT_TERMINAL_ID, - type TerminalEvent, - type TerminalOpenInput, -} from "@t3tools/contracts"; -import { afterEach, describe, expect, it } from "vitest"; - -import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter"; -import { TerminalManagerRuntime } from "./terminalManager"; -import { Encoding } from "effect"; - -class FakePtyProcess implements PtyProcess { - readonly writes: string[] = []; - readonly resizeCalls: Array<{ cols: number; rows: number }> = []; - private readonly dataListeners = new Set<(data: string) => void>(); - private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); - killed = false; - - constructor(readonly pid: number) {} - - write(data: string): void { - this.writes.push(data); - } - - resize(cols: number, rows: number): void { - this.resizeCalls.push({ cols, rows }); - } - - kill(): void { - this.killed = true; - } - - 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: string): void { - for (const listener of this.dataListeners) { - listener(data); - } - } - - emitExit(event: PtyExitEvent): void { - for (const listener of this.exitListeners) { - listener(event); - } - } -} - -class FakePtyAdapter implements PtyAdapterShape { - readonly spawnInputs: PtySpawnInput[] = []; - readonly processes: FakePtyProcess[] = []; - readonly spawnFailures: Error[] = []; - private nextPid = 9000; - - spawn(input: PtySpawnInput): PtyProcess { - this.spawnInputs.push(input); - const failure = this.spawnFailures.shift(); - if (failure) { - throw failure; - } - const process = new FakePtyProcess(this.nextPid++); - this.processes.push(process); - return process; - } -} - -function waitFor(predicate: () => boolean, timeoutMs = 800): Promise { - const started = Date.now(); - return new Promise((resolve, reject) => { - const poll = () => { - if (predicate()) { - resolve(); - return; - } - if (Date.now() - started > timeoutMs) { - reject(new Error("Timed out waiting for condition")); - return; - } - setTimeout(poll, 15); - }; - poll(); - }); -} - -function openInput(overrides: Partial = {}): TerminalOpenInput { - return { - threadId: "thread-1", - cwd: process.cwd(), - cols: 100, - rows: 24, - ...overrides, - }; -} - -function historyLogName(threadId: string): string { - return `terminal_${Encoding.encodeBase64Url(threadId)}.log`; -} - -function multiTerminalHistoryLogName(threadId: string, terminalId: string): string { - const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; - if (terminalId === DEFAULT_TERMINAL_ID) { - return `${threadPart}.log`; - } - return `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`; -} - -function historyLogPath(logsDir: string, threadId = "thread-1"): string { - return path.join(logsDir, historyLogName(threadId)); -} - -function multiTerminalHistoryLogPath( - logsDir: string, - threadId = "thread-1", - terminalId = "default", -): string { - return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId)); -} - -describe("TerminalManager", () => { - const tempDirs: string[] = []; - - afterEach(() => { - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - function makeManager( - historyLineLimit = 5, - options: { - shellResolver?: () => string; - subprocessChecker?: (terminalPid: number) => Promise; - subprocessPollIntervalMs?: number; - } = {}, - ) { - const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-")); - tempDirs.push(logsDir); - const ptyAdapter = new FakePtyAdapter(); - const manager = new TerminalManagerRuntime({ - logsDir, - ptyAdapter, - historyLineLimit, - shellResolver: options.shellResolver ?? (() => "/bin/bash"), - ...(options.subprocessChecker ? { subprocessChecker: options.subprocessChecker } : {}), - ...(options.subprocessPollIntervalMs - ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } - : {}), - }); - return { logsDir, ptyAdapter, manager }; - } - - it("spawns lazily and reuses running terminal per thread", async () => { - const { manager, ptyAdapter } = makeManager(); - const [first, second] = await Promise.all([ - manager.open(openInput()), - manager.open(openInput()), - ]); - const third = await manager.open(openInput()); - - expect(first.threadId).toBe("thread-1"); - expect(first.terminalId).toBe("default"); - expect(second.threadId).toBe("thread-1"); - expect(third.threadId).toBe("thread-1"); - expect(ptyAdapter.spawnInputs).toHaveLength(1); - - manager.dispose(); - }); - - it("forwards write and resize to active pty process", async () => { - const { manager, ptyAdapter } = makeManager(); - await manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - await manager.write({ threadId: "thread-1", data: "ls\n" }); - await manager.resize({ threadId: "thread-1", cols: 120, rows: 30 }); - - expect(process.writes).toEqual(["ls\n"]); - expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); - - manager.dispose(); - }); - - it("resizes running terminal on open when a different size is requested", async () => { - const { manager, ptyAdapter } = makeManager(); - await manager.open(openInput({ cols: 100, rows: 24 })); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - await manager.open(openInput({ cols: 140, rows: 40 })); - - expect(process.resizeCalls).toEqual([{ cols: 140, rows: 40 }]); - - manager.dispose(); - }); - - it("preserves existing terminal size on open when size is omitted", async () => { - const { manager, ptyAdapter } = makeManager(); - await manager.open(openInput({ cols: 100, rows: 24 })); - const ptyProcess = ptyAdapter.processes[0]; - expect(ptyProcess).toBeDefined(); - if (!ptyProcess) return; - - await manager.open({ - threadId: "thread-1", - cwd: globalThis.process.cwd(), - }); - - expect(ptyProcess.resizeCalls).toEqual([]); - - ptyProcess.emitExit({ exitCode: 0, signal: 0 }); - await manager.open({ - threadId: "thread-1", - cwd: globalThis.process.cwd(), - }); - - const resumedSpawn = ptyAdapter.spawnInputs[1]; - expect(resumedSpawn).toBeDefined(); - if (!resumedSpawn) return; - expect(resumedSpawn.cols).toBe(100); - expect(resumedSpawn.rows).toBe(24); - - manager.dispose(); - }); - - it("uses default dimensions when opening a new terminal without size hints", async () => { - const { manager, ptyAdapter } = makeManager(); - await manager.open({ - threadId: "thread-1", - cwd: process.cwd(), - }); - - const spawned = ptyAdapter.spawnInputs[0]; - expect(spawned).toBeDefined(); - if (!spawned) return; - expect(spawned.cols).toBe(120); - expect(spawned.rows).toBe(30); - - manager.dispose(); - }); - - it("supports multiple terminals per thread with isolated sessions", async () => { - const { manager, ptyAdapter } = makeManager(); - await manager.open(openInput({ terminalId: "default" })); - await manager.open(openInput({ terminalId: "term-2" })); - - const first = ptyAdapter.processes[0]; - const second = ptyAdapter.processes[1]; - expect(first).toBeDefined(); - expect(second).toBeDefined(); - if (!first || !second) return; - - await manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); - await manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); - - expect(first.writes).toEqual(["pwd\n"]); - expect(second.writes).toEqual(["ls\n"]); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - - manager.dispose(); - }); - - it("clears transcript and emits cleared event", async () => { - const { manager, ptyAdapter, logsDir } = makeManager(); - const events: TerminalEvent[] = []; - manager.on("event", (event) => { - events.push(event); - }); - await manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("hello\n"); - await waitFor(() => fs.existsSync(historyLogPath(logsDir))); - await manager.clear({ threadId: "thread-1" }); - await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); - - expect(events.some((event) => event.type === "cleared")).toBe(true); - expect( - events.some( - (event) => - event.type === "cleared" && - event.threadId === "thread-1" && - event.terminalId === "default", - ), - ).toBe(true); - - manager.dispose(); - }); - - it("restarts terminal with empty transcript and respawns pty", async () => { - const { manager, ptyAdapter, logsDir } = makeManager(); - await manager.open(openInput()); - const firstProcess = ptyAdapter.processes[0]; - expect(firstProcess).toBeDefined(); - if (!firstProcess) return; - firstProcess.emitData("before restart\n"); - await waitFor(() => fs.existsSync(historyLogPath(logsDir))); - - const snapshot = await manager.restart(openInput()); - expect(snapshot.history).toBe(""); - expect(snapshot.status).toBe("running"); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); - - manager.dispose(); - }); - - it("emits exited event and reopens with clean transcript after exit", async () => { - const { manager, ptyAdapter, logsDir } = makeManager(); - const events: TerminalEvent[] = []; - manager.on("event", (event) => { - events.push(event); - }); - await manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - process.emitData("old data\n"); - await waitFor(() => fs.existsSync(historyLogPath(logsDir))); - process.emitExit({ exitCode: 0, signal: 0 }); - - await waitFor(() => events.some((event) => event.type === "exited")); - const reopened = await manager.open(openInput()); - - expect(reopened.history).toBe(""); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - expect(fs.readFileSync(historyLogPath(logsDir), "utf8")).toBe(""); - - manager.dispose(); - }); - - it("emits subprocess activity events when child-process state changes", async () => { - let hasRunningSubprocess = false; - const { manager } = makeManager(5, { - subprocessChecker: async () => hasRunningSubprocess, - subprocessPollIntervalMs: 20, - }); - const events: TerminalEvent[] = []; - manager.on("event", (event) => { - events.push(event); - }); - - await manager.open(openInput()); - await waitFor(() => events.some((event) => event.type === "started")); - expect(events.some((event) => event.type === "activity")).toBe(false); - - hasRunningSubprocess = true; - await waitFor( - () => - events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true), - 1_200, - ); - - hasRunningSubprocess = false; - await waitFor( - () => - events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false), - 1_200, - ); - - manager.dispose(); - }); - - it("caps persisted history to configured line limit", async () => { - const { manager, ptyAdapter } = makeManager(3); - await manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("line1\nline2\nline3\nline4\n"); - await manager.close({ threadId: "thread-1" }); - - const reopened = await manager.open(openInput()); - const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0); - expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]); - - manager.dispose(); - }); - - it("deletes history file when close(deleteHistory=true)", async () => { - const { manager, ptyAdapter, logsDir } = makeManager(); - await manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - process.emitData("bye\n"); - await waitFor(() => fs.existsSync(historyLogPath(logsDir))); - - await manager.close({ threadId: "thread-1", deleteHistory: true }); - expect(fs.existsSync(historyLogPath(logsDir))).toBe(false); - - manager.dispose(); - }); - - it("closes all terminals for a thread when close omits terminalId", async () => { - const { manager, ptyAdapter, logsDir } = makeManager(); - await manager.open(openInput({ terminalId: "default" })); - await manager.open(openInput({ terminalId: "sidecar" })); - const defaultProcess = ptyAdapter.processes[0]; - const sidecarProcess = ptyAdapter.processes[1]; - expect(defaultProcess).toBeDefined(); - expect(sidecarProcess).toBeDefined(); - if (!defaultProcess || !sidecarProcess) return; - - defaultProcess.emitData("default\n"); - sidecarProcess.emitData("sidecar\n"); - await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))); - await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))); - - await manager.close({ threadId: "thread-1", deleteHistory: true }); - - expect(defaultProcess.killed).toBe(true); - expect(sidecarProcess.killed).toBe(true); - expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))).toBe(false); - expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))).toBe(false); - - manager.dispose(); - }); - - it("migrates legacy transcript filenames to terminal-scoped history path on open", async () => { - const { manager, logsDir } = makeManager(); - const legacyPath = path.join(logsDir, "thread-1.log"); - const nextPath = historyLogPath(logsDir); - fs.writeFileSync(legacyPath, "legacy-line\n", "utf8"); - - const snapshot = await manager.open(openInput()); - - expect(snapshot.history).toBe("legacy-line\n"); - expect(fs.existsSync(nextPath)).toBe(true); - expect(fs.readFileSync(nextPath, "utf8")).toBe("legacy-line\n"); - expect(fs.existsSync(legacyPath)).toBe(false); - - manager.dispose(); - }); - - it("retries with fallback shells when preferred shell spawn fails", async () => { - const { manager, ptyAdapter } = makeManager(5, { - shellResolver: () => "/definitely/missing-shell -l", - }); - ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed.")); - - const snapshot = await manager.open(openInput()); - - expect(snapshot.status).toBe("running"); - expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); - expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell"); - - if (process.platform === "win32") { - expect( - ptyAdapter.spawnInputs.some( - (input) => input.shell === "cmd.exe" || input.shell === "powershell.exe", - ), - ).toBe(true); - } else { - expect( - ptyAdapter.spawnInputs.some((input) => - ["/bin/zsh", "/bin/bash", "/bin/sh", "zsh", "bash", "sh"].includes(input.shell), - ), - ).toBe(true); - } - - manager.dispose(); - }); - - it("filters app runtime env variables from terminal sessions", async () => { - const originalValues = new Map(); - const setEnv = (key: string, value: string | undefined) => { - if (!originalValues.has(key)) { - originalValues.set(key, process.env[key]); - } - if (value === undefined) { - delete process.env[key]; - return; - } - process.env[key] = value; - }; - const restoreEnv = () => { - for (const [key, value] of originalValues) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - }; - - setEnv("PORT", "5173"); - setEnv("T3CODE_PORT", "3773"); - setEnv("VITE_DEV_SERVER_URL", "http://localhost:5173"); - setEnv("TEST_TERMINAL_KEEP", "keep-me"); - - try { - const { manager, ptyAdapter } = makeManager(); - await manager.open(openInput()); - const spawnInput = ptyAdapter.spawnInputs[0]; - expect(spawnInput).toBeDefined(); - if (!spawnInput) return; - - expect(spawnInput.env.PORT).toBeUndefined(); - expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); - expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); - expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); - - manager.dispose(); - } finally { - restoreEnv(); - } - }); - - it("injects runtime env overrides into spawned terminals", async () => { - const { manager, ptyAdapter } = makeManager(); - await manager.open( - openInput({ - env: { - T3CODE_PROJECT_ROOT: "/repo", - T3CODE_WORKTREE_PATH: "/repo/worktree-a", - CUSTOM_FLAG: "1", - }, - }), - ); - const spawnInput = ptyAdapter.spawnInputs[0]; - expect(spawnInput).toBeDefined(); - if (!spawnInput) return; - - expect(spawnInput.env.T3CODE_PROJECT_ROOT).toBe("/repo"); - expect(spawnInput.env.T3CODE_WORKTREE_PATH).toBe("/repo/worktree-a"); - expect(spawnInput.env.CUSTOM_FLAG).toBe("1"); - - manager.dispose(); - }); - - it("starts zsh with prompt spacer disabled to avoid `%` end markers", async () => { - if (process.platform === "win32") return; - const { manager, ptyAdapter } = makeManager(5, { - shellResolver: () => "/bin/zsh", - }); - await manager.open(openInput()); - const spawnInput = ptyAdapter.spawnInputs[0]; - expect(spawnInput).toBeDefined(); - if (!spawnInput) return; - - expect(spawnInput.shell).toBe("/bin/zsh"); - expect(spawnInput.args).toEqual(["-o", "nopromptsp"]); - - manager.dispose(); - }); -}); diff --git a/apps/server/src/terminalManager.ts b/apps/server/src/terminalManager.ts deleted file mode 100644 index 0a531435e061a888913d32a9fd1a931c0e3738f8..0000000000000000000000000000000000000000 --- a/apps/server/src/terminalManager.ts +++ /dev/null @@ -1,1095 +0,0 @@ -import { EventEmitter } from "node:events"; -import fs from "node:fs"; -import path from "node:path"; - -import { - DEFAULT_TERMINAL_ID, - TerminalClearInput, - TerminalCloseInput, - TerminalOpenInput, - TerminalResizeInput, - TerminalWriteInput, - type TerminalEvent, - type TerminalSessionSnapshot, - type TerminalSessionStatus, -} from "@t3tools/contracts"; -import { Effect, Encoding, Layer, Schema, ServiceMap } from "effect"; - -import { createLogger } from "./logger"; -import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; -import { runProcess } from "./processRunner"; - -const DEFAULT_HISTORY_LINE_LIMIT = 5_000; -const DEFAULT_PERSIST_DEBOUNCE_MS = 40; -const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; -const DEFAULT_OPEN_COLS = 120; -const DEFAULT_OPEN_ROWS = 30; -const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); - -const decodeTerminalOpenInput = Schema.decodeUnknownSync(TerminalOpenInput); -const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput); -const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput); -const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput); -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"; - } - return process.env.SHELL ?? "bash"; -} - -function normalizeShellCommand(value: string | undefined): string | null { - if (!value) return null; - const trimmed = value.trim(); - if (trimmed.length === 0) return null; - - if (process.platform === "win32") { - return trimmed; - } - - const firstToken = trimmed.split(/\s+/g)[0]?.trim(); - if (!firstToken) return null; - return firstToken.replace(/^['"]|['"]$/g, ""); -} - -function shellCandidateFromCommand(command: string | null): ShellCandidate | null { - if (!command || command.length === 0) return null; - const shellName = path.basename(command).toLowerCase(); - if (process.platform !== "win32" && shellName === "zsh") { - return { shell: command, args: ["-o", "nopromptsp"] }; - } - return { shell: command }; -} - -function formatShellCandidate(candidate: ShellCandidate): string { - if (!candidate.args || candidate.args.length === 0) return candidate.shell; - return `${candidate.shell} ${candidate.args.join(" ")}`; -} - -function uniqueShellCandidates(candidates: Array): ShellCandidate[] { - const seen = new Set(); - const ordered: ShellCandidate[] = []; - for (const candidate of candidates) { - if (!candidate) continue; - const key = formatShellCandidate(candidate); - if (seen.has(key)) continue; - seen.add(key); - ordered.push(candidate); - } - return ordered; -} - -function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] { - const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver())); - - if (process.platform === "win32") { - return uniqueShellCandidates([ - requested, - shellCandidateFromCommand(process.env.ComSpec ?? null), - shellCandidateFromCommand("powershell.exe"), - shellCandidateFromCommand("cmd.exe"), - ]); - } - - return uniqueShellCandidates([ - requested, - shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)), - shellCandidateFromCommand("/bin/zsh"), - shellCandidateFromCommand("/bin/bash"), - shellCandidateFromCommand("/bin/sh"), - shellCandidateFromCommand("zsh"), - shellCandidateFromCommand("bash"), - shellCandidateFromCommand("sh"), - ]); -} - -function isRetryableShellSpawnError(error: unknown): boolean { - if (!(error instanceof Error)) return false; - const message = error.message.toLowerCase(); - return ( - message.includes("posix_spawnp failed") || - message.includes("enoent") || - message.includes("not found") || - message.includes("file not found") || - message.includes("no such file") - ); -} - -async function checkWindowsSubprocessActivity(terminalPid: number): Promise { - const command = [ - `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, - "if ($children) { exit 0 }", - "exit 1", - ].join("; "); - try { - const result = await runProcess( - "powershell.exe", - ["-NoProfile", "-NonInteractive", "-Command", command], - { - timeoutMs: 1_500, - allowNonZeroExit: true, - maxBufferBytes: 32_768, - outputMode: "truncate", - }, - ); - return result.code === 0; - } catch { - return false; - } -} - -async function checkPosixSubprocessActivity(terminalPid: number): Promise { - try { - const pgrepResult = await runProcess("pgrep", ["-P", String(terminalPid)], { - timeoutMs: 1_000, - allowNonZeroExit: true, - maxBufferBytes: 32_768, - outputMode: "truncate", - }); - if (pgrepResult.code === 0) { - return pgrepResult.stdout.trim().length > 0; - } - if (pgrepResult.code === 1) { - return false; - } - } catch { - // Fall back to ps when pgrep is unavailable. - } - - try { - const psResult = await runProcess("ps", ["-eo", "pid=,ppid="], { - timeoutMs: 1_000, - allowNonZeroExit: true, - maxBufferBytes: 262_144, - outputMode: "truncate", - }); - if (psResult.code !== 0) { - return false; - } - - for (const line of psResult.stdout.split(/\r?\n/g)) { - const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); - const pid = Number(pidRaw); - const ppid = Number(ppidRaw); - if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; - if (ppid === terminalPid) { - return true; - } - } - return false; - } catch { - return false; - } -} - -async function defaultSubprocessChecker(terminalPid: number): Promise { - if (!Number.isInteger(terminalPid) || terminalPid <= 0) { - return false; - } - if (process.platform === "win32") { - return checkWindowsSubprocessActivity(terminalPid); - } - return checkPosixSubprocessActivity(terminalPid); -} - -function capHistory(history: string, maxLines: number): string { - if (history.length === 0) return history; - const hasTrailingNewline = history.endsWith("\n"); - const lines = history.split("\n"); - if (hasTrailingNewline) { - lines.pop(); - } - if (lines.length <= maxLines) return history; - const capped = lines.slice(lines.length - maxLines).join("\n"); - return hasTrailingNewline ? `${capped}\n` : capped; -} - -function legacySafeThreadId(threadId: string): string { - return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); -} - -function toSafeThreadId(threadId: string): string { - return `terminal_${Encoding.encodeBase64Url(threadId)}`; -} - -function toSafeTerminalId(terminalId: string): string { - return Encoding.encodeBase64Url(terminalId); -} - -function toSessionKey(threadId: string, terminalId: string): string { - return `${threadId}\u0000${terminalId}`; -} - -function shouldExcludeTerminalEnvKey(key: string): boolean { - const normalizedKey = key.toUpperCase(); - if (normalizedKey.startsWith("T3CODE_")) { - return true; - } - if (normalizedKey.startsWith("VITE_")) { - return true; - } - return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); -} - -function createTerminalSpawnEnv( - baseEnv: NodeJS.ProcessEnv, - runtimeEnv?: Record | null, -): NodeJS.ProcessEnv { - const spawnEnv: NodeJS.ProcessEnv = {}; - for (const [key, value] of Object.entries(baseEnv)) { - if (value === undefined) continue; - if (shouldExcludeTerminalEnvKey(key)) continue; - spawnEnv[key] = value; - } - if (runtimeEnv) { - for (const [key, value] of Object.entries(runtimeEnv)) { - spawnEnv[key] = value; - } - } - return spawnEnv; -} - -function normalizedRuntimeEnv( - env: Record | undefined, -): Record | null { - if (!env) return null; - const entries = Object.entries(env); - if (entries.length === 0) return null; - return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right))); -} - -export class TerminalManagerRuntime extends EventEmitter { - private readonly sessions = new Map(); - private readonly logsDir: string; - private readonly historyLineLimit: number; - private readonly ptyAdapter: PtyAdapterShape; - private readonly shellResolver: () => string; - private readonly persistQueues = new Map>(); - private readonly persistTimers = new Map>(); - private readonly pendingPersistHistory = new Map(); - private readonly threadLocks = new Map>(); - private readonly persistDebounceMs: number; - private readonly subprocessChecker: TerminalSubprocessChecker; - private readonly subprocessPollIntervalMs: number; - private subprocessPollTimer: ReturnType | null = null; - private subprocessPollInFlight = false; - private readonly logger = createLogger("terminal"); - - constructor(options: TerminalManagerOptions) { - super(); - this.logsDir = options.logsDir ?? path.resolve(process.cwd(), ".logs", "terminals"); - this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; - this.ptyAdapter = options.ptyAdapter; - this.shellResolver = options.shellResolver ?? defaultShellResolver; - this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; - this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; - this.subprocessPollIntervalMs = - options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; - fs.mkdirSync(this.logsDir, { recursive: true }); - } - - async open(raw: TerminalOpenInput): Promise { - const input = decodeTerminalOpenInput(raw); - return this.runWithThreadLock(input.threadId, async () => { - await this.assertValidCwd(input.cwd); - - const sessionKey = toSessionKey(input.threadId, input.terminalId); - const existing = this.sessions.get(sessionKey); - if (!existing) { - await this.flushPersistQueue(input.threadId, input.terminalId); - const history = await this.readHistory(input.threadId, input.terminalId); - const cols = input.cols ?? DEFAULT_OPEN_COLS; - const rows = input.rows ?? DEFAULT_OPEN_ROWS; - const session: TerminalSessionState = { - threadId: input.threadId, - terminalId: input.terminalId, - cwd: input.cwd, - status: "starting", - pid: null, - history, - exitCode: null, - exitSignal: null, - updatedAt: new Date().toISOString(), - cols, - rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - hasRunningSubprocess: false, - runtimeEnv: normalizedRuntimeEnv(input.env), - }; - this.sessions.set(sessionKey, session); - this.startSession(session, { ...input, cols, rows }, "started"); - return this.snapshot(session); - } - - const nextRuntimeEnv = normalizedRuntimeEnv(input.env); - const currentRuntimeEnv = existing.runtimeEnv; - const targetCols = input.cols ?? existing.cols; - const targetRows = input.rows ?? existing.rows; - const runtimeEnvChanged = - JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv); - - if (existing.cwd !== input.cwd || runtimeEnvChanged) { - this.stopProcess(existing); - existing.cwd = input.cwd; - existing.runtimeEnv = nextRuntimeEnv; - existing.history = ""; - await this.persistHistory(existing.threadId, existing.terminalId, existing.history); - } else if (existing.status === "exited" || existing.status === "error") { - existing.runtimeEnv = nextRuntimeEnv; - existing.history = ""; - await this.persistHistory(existing.threadId, existing.terminalId, existing.history); - } else if (currentRuntimeEnv !== nextRuntimeEnv) { - existing.runtimeEnv = nextRuntimeEnv; - } - - if (!existing.process) { - this.startSession(existing, { ...input, cols: targetCols, rows: targetRows }, "started"); - return this.snapshot(existing); - } - - if (existing.cols !== targetCols || existing.rows !== targetRows) { - existing.cols = targetCols; - existing.rows = targetRows; - existing.process.resize(targetCols, targetRows); - existing.updatedAt = new Date().toISOString(); - } - - return this.snapshot(existing); - }); - } - - async write(raw: TerminalWriteInput): Promise { - const input = decodeTerminalWriteInput(raw); - const session = this.requireSession(input.threadId, input.terminalId); - if (!session.process || session.status !== "running") { - throw new Error( - `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, - ); - } - session.process.write(input.data); - } - - async resize(raw: TerminalResizeInput): Promise { - const input = decodeTerminalResizeInput(raw); - const session = this.requireSession(input.threadId, input.terminalId); - if (!session.process || session.status !== "running") { - throw new Error( - `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, - ); - } - session.cols = input.cols; - session.rows = input.rows; - session.updatedAt = new Date().toISOString(); - session.process.resize(input.cols, input.rows); - } - - async clear(raw: TerminalClearInput): Promise { - const input = decodeTerminalClearInput(raw); - await this.runWithThreadLock(input.threadId, async () => { - const session = this.requireSession(input.threadId, input.terminalId); - session.history = ""; - session.updatedAt = new Date().toISOString(); - await this.persistHistory(input.threadId, input.terminalId, session.history); - this.emitEvent({ - type: "cleared", - threadId: input.threadId, - terminalId: input.terminalId, - createdAt: new Date().toISOString(), - }); - }); - } - - async restart(raw: TerminalOpenInput): Promise { - const input = decodeTerminalOpenInput(raw); - return this.runWithThreadLock(input.threadId, async () => { - await this.assertValidCwd(input.cwd); - - const sessionKey = toSessionKey(input.threadId, input.terminalId); - let session = this.sessions.get(sessionKey); - if (!session) { - const cols = input.cols ?? DEFAULT_OPEN_COLS; - const rows = input.rows ?? DEFAULT_OPEN_ROWS; - session = { - threadId: input.threadId, - terminalId: input.terminalId, - cwd: input.cwd, - status: "starting", - pid: null, - history: "", - exitCode: null, - exitSignal: null, - updatedAt: new Date().toISOString(), - cols, - rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - hasRunningSubprocess: false, - runtimeEnv: normalizedRuntimeEnv(input.env), - }; - this.sessions.set(sessionKey, session); - } else { - this.stopProcess(session); - session.cwd = input.cwd; - session.runtimeEnv = normalizedRuntimeEnv(input.env); - } - - const cols = input.cols ?? session.cols; - const rows = input.rows ?? session.rows; - - session.history = ""; - await this.persistHistory(input.threadId, input.terminalId, session.history); - this.startSession(session, { ...input, cols, rows }, "restarted"); - return this.snapshot(session); - }); - } - - async close(raw: TerminalCloseInput): Promise { - const input = decodeTerminalCloseInput(raw); - await this.runWithThreadLock(input.threadId, async () => { - if (input.terminalId) { - await this.closeSession(input.threadId, input.terminalId, input.deleteHistory === true); - return; - } - - const threadSessions = this.sessionsForThread(input.threadId); - for (const session of threadSessions) { - this.stopProcess(session); - this.sessions.delete(toSessionKey(session.threadId, session.terminalId)); - } - await Promise.all( - threadSessions.map((session) => - this.flushPersistQueue(session.threadId, session.terminalId), - ), - ); - - if (input.deleteHistory) { - await this.deleteAllHistoryForThread(input.threadId); - } - this.updateSubprocessPollingState(); - }); - } - - dispose(): void { - this.stopSubprocessPolling(); - const sessions = [...this.sessions.values()]; - this.sessions.clear(); - for (const session of sessions) { - this.stopProcess(session); - } - for (const timer of this.persistTimers.values()) { - clearTimeout(timer); - } - this.persistTimers.clear(); - this.pendingPersistHistory.clear(); - this.threadLocks.clear(); - this.persistQueues.clear(); - } - - private startSession( - session: TerminalSessionState, - input: TerminalStartInput, - eventType: "started" | "restarted", - ): void { - this.stopProcess(session); - - session.status = "starting"; - session.cwd = input.cwd; - session.cols = input.cols; - session.rows = input.rows; - session.exitCode = null; - session.exitSignal = null; - session.hasRunningSubprocess = false; - session.updatedAt = new Date().toISOString(); - - let ptyProcess: PtyProcess | null = null; - let startedShell: string | null = null; - try { - const shellCandidates = resolveShellCandidates(this.shellResolver); - const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); - let lastSpawnError: unknown = null; - - for (const candidate of shellCandidates) { - try { - ptyProcess = this.ptyAdapter.spawn({ - shell: candidate.shell, - ...(candidate.args ? { args: candidate.args } : {}), - cwd: session.cwd, - cols: session.cols, - rows: session.rows, - env: terminalEnv, - }); - startedShell = formatShellCandidate(candidate); - break; - } catch (error) { - lastSpawnError = error; - if (!isRetryableShellSpawnError(error)) { - throw error; - } - } - } - - if (!ptyProcess) { - const detail = - lastSpawnError instanceof Error ? lastSpawnError.message : "Terminal start failed"; - const tried = - shellCandidates.length > 0 - ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.` - : ""; - throw new Error(`${detail}.${tried}`.trim()); - } - - session.process = ptyProcess; - session.pid = ptyProcess.pid; - session.status = "running"; - session.updatedAt = new Date().toISOString(); - session.unsubscribeData = ptyProcess.onData((data) => { - this.onProcessData(session, data); - }); - session.unsubscribeExit = ptyProcess.onExit((event) => { - this.onProcessExit(session, event); - }); - this.updateSubprocessPollingState(); - this.emitEvent({ - type: eventType, - threadId: session.threadId, - terminalId: session.terminalId, - createdAt: new Date().toISOString(), - snapshot: this.snapshot(session), - }); - } catch (error) { - if (ptyProcess) { - try { - ptyProcess.kill(); - } catch { - // Ignore kill errors during failed startup cleanup. - } - } - session.status = "error"; - session.pid = null; - session.process = null; - session.hasRunningSubprocess = false; - session.updatedAt = new Date().toISOString(); - this.updateSubprocessPollingState(); - const message = error instanceof Error ? error.message : "Terminal start failed"; - this.emitEvent({ - type: "error", - threadId: session.threadId, - terminalId: session.terminalId, - createdAt: new Date().toISOString(), - message, - }); - this.logger.error("failed to start terminal", { - threadId: session.threadId, - terminalId: session.terminalId, - error: message, - ...(startedShell ? { shell: startedShell } : {}), - }); - } - } - - private onProcessData(session: TerminalSessionState, data: string): void { - session.history = capHistory(`${session.history}${data}`, this.historyLineLimit); - session.updatedAt = new Date().toISOString(); - this.queuePersist(session.threadId, session.terminalId, session.history); - this.emitEvent({ - type: "output", - threadId: session.threadId, - terminalId: session.terminalId, - createdAt: new Date().toISOString(), - data, - }); - } - - private onProcessExit(session: TerminalSessionState, event: PtyExitEvent): void { - this.cleanupProcessHandles(session); - session.process = null; - session.pid = null; - session.hasRunningSubprocess = false; - session.status = "exited"; - session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null; - session.exitSignal = Number.isInteger(event.signal) ? event.signal : null; - session.updatedAt = new Date().toISOString(); - this.emitEvent({ - type: "exited", - threadId: session.threadId, - terminalId: session.terminalId, - createdAt: new Date().toISOString(), - exitCode: session.exitCode, - exitSignal: session.exitSignal, - }); - this.updateSubprocessPollingState(); - } - - private stopProcess(session: TerminalSessionState): void { - const process = session.process; - if (!process) return; - this.cleanupProcessHandles(session); - session.process = null; - session.pid = null; - session.hasRunningSubprocess = false; - session.status = "exited"; - session.updatedAt = new Date().toISOString(); - try { - process.kill(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.logger.warn("failed to kill terminal process", { - threadId: session.threadId, - terminalId: session.terminalId, - error: message, - }); - } - this.updateSubprocessPollingState(); - } - - private cleanupProcessHandles(session: TerminalSessionState): void { - session.unsubscribeData?.(); - session.unsubscribeData = null; - session.unsubscribeExit?.(); - session.unsubscribeExit = null; - } - - private queuePersist(threadId: string, terminalId: string, history: string): void { - const persistenceKey = toSessionKey(threadId, terminalId); - this.pendingPersistHistory.set(persistenceKey, history); - this.schedulePersist(threadId, terminalId); - } - - private async persistHistory( - threadId: string, - terminalId: string, - history: string, - ): Promise { - const persistenceKey = toSessionKey(threadId, terminalId); - this.clearPersistTimer(threadId, terminalId); - this.pendingPersistHistory.delete(persistenceKey); - await this.enqueuePersistWrite(threadId, terminalId, history); - } - - private enqueuePersistWrite( - threadId: string, - terminalId: string, - history: string, - ): Promise { - const persistenceKey = toSessionKey(threadId, terminalId); - const task = async () => { - await fs.promises.writeFile(this.historyPath(threadId, terminalId), history, "utf8"); - }; - const previous = this.persistQueues.get(persistenceKey) ?? Promise.resolve(); - const next = previous - .catch(() => undefined) - .then(task) - .catch((error) => { - this.logger.warn("failed to persist terminal history", { - threadId, - terminalId, - error: error instanceof Error ? error.message : String(error), - }); - }); - this.persistQueues.set(persistenceKey, next); - const finalized = next.finally(() => { - if (this.persistQueues.get(persistenceKey) === next) { - this.persistQueues.delete(persistenceKey); - } - if ( - this.pendingPersistHistory.has(persistenceKey) && - !this.persistTimers.has(persistenceKey) - ) { - this.schedulePersist(threadId, terminalId); - } - }); - void finalized.catch(() => undefined); - return finalized; - } - - private schedulePersist(threadId: string, terminalId: string): void { - const persistenceKey = toSessionKey(threadId, terminalId); - if (this.persistTimers.has(persistenceKey)) return; - const timer = setTimeout(() => { - this.persistTimers.delete(persistenceKey); - const pendingHistory = this.pendingPersistHistory.get(persistenceKey); - if (pendingHistory === undefined) return; - this.pendingPersistHistory.delete(persistenceKey); - void this.enqueuePersistWrite(threadId, terminalId, pendingHistory); - }, this.persistDebounceMs); - this.persistTimers.set(persistenceKey, timer); - } - - private clearPersistTimer(threadId: string, terminalId: string): void { - const persistenceKey = toSessionKey(threadId, terminalId); - const timer = this.persistTimers.get(persistenceKey); - if (!timer) return; - clearTimeout(timer); - this.persistTimers.delete(persistenceKey); - } - - private async readHistory(threadId: string, terminalId: string): Promise { - const nextPath = this.historyPath(threadId, terminalId); - try { - const raw = await fs.promises.readFile(nextPath, "utf8"); - const capped = capHistory(raw, this.historyLineLimit); - if (capped !== raw) { - await fs.promises.writeFile(nextPath, capped, "utf8"); - } - return capped; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - } - - if (terminalId !== DEFAULT_TERMINAL_ID) { - return ""; - } - - const legacyPath = this.legacyHistoryPath(threadId); - try { - const raw = await fs.promises.readFile(legacyPath, "utf8"); - const capped = capHistory(raw, this.historyLineLimit); - - // Migrate legacy transcript filename to the terminal-scoped path. - await fs.promises.writeFile(nextPath, capped, "utf8"); - try { - await fs.promises.rm(legacyPath, { force: true }); - } catch (cleanupError) { - this.logger.warn("failed to remove legacy terminal history", { - threadId, - error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), - }); - } - - return capped; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return ""; - } - throw error; - } - } - - private async deleteHistory(threadId: string, terminalId: string): Promise { - const deletions = [fs.promises.rm(this.historyPath(threadId, terminalId), { force: true })]; - if (terminalId === DEFAULT_TERMINAL_ID) { - deletions.push(fs.promises.rm(this.legacyHistoryPath(threadId), { force: true })); - } - try { - await Promise.all(deletions); - } catch (error) { - this.logger.warn("failed to delete terminal history", { - threadId, - terminalId, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - private async flushPersistQueue(threadId: string, terminalId: string): Promise { - const persistenceKey = toSessionKey(threadId, terminalId); - this.clearPersistTimer(threadId, terminalId); - - while (true) { - const pendingHistory = this.pendingPersistHistory.get(persistenceKey); - if (pendingHistory !== undefined) { - this.pendingPersistHistory.delete(persistenceKey); - await this.enqueuePersistWrite(threadId, terminalId, pendingHistory); - } - - const pending = this.persistQueues.get(persistenceKey); - if (!pending) { - return; - } - await pending.catch(() => undefined); - } - } - - private updateSubprocessPollingState(): void { - const hasRunningSessions = [...this.sessions.values()].some( - (session) => session.status === "running" && session.pid !== null, - ); - if (hasRunningSessions) { - this.ensureSubprocessPolling(); - return; - } - this.stopSubprocessPolling(); - } - - private ensureSubprocessPolling(): void { - if (this.subprocessPollTimer) return; - this.subprocessPollTimer = setInterval(() => { - void this.pollSubprocessActivity(); - }, this.subprocessPollIntervalMs); - this.subprocessPollTimer.unref?.(); - void this.pollSubprocessActivity(); - } - - private stopSubprocessPolling(): void { - if (!this.subprocessPollTimer) return; - clearInterval(this.subprocessPollTimer); - this.subprocessPollTimer = null; - } - - private async pollSubprocessActivity(): Promise { - if (this.subprocessPollInFlight) return; - - const runningSessions = [...this.sessions.values()].filter( - (session): session is TerminalSessionState & { pid: number } => - session.status === "running" && Number.isInteger(session.pid), - ); - if (runningSessions.length === 0) { - this.stopSubprocessPolling(); - return; - } - - this.subprocessPollInFlight = true; - try { - await Promise.all( - runningSessions.map(async (session) => { - const terminalPid = session.pid; - let hasRunningSubprocess = false; - try { - hasRunningSubprocess = await this.subprocessChecker(terminalPid); - } catch (error) { - this.logger.warn("failed to check terminal subprocess activity", { - threadId: session.threadId, - terminalId: session.terminalId, - terminalPid, - error: error instanceof Error ? error.message : String(error), - }); - return; - } - - const liveSession = this.sessions.get(toSessionKey(session.threadId, session.terminalId)); - if (!liveSession || liveSession.status !== "running" || liveSession.pid !== terminalPid) { - return; - } - if (liveSession.hasRunningSubprocess === hasRunningSubprocess) { - return; - } - - liveSession.hasRunningSubprocess = hasRunningSubprocess; - liveSession.updatedAt = new Date().toISOString(); - this.emitEvent({ - type: "activity", - threadId: liveSession.threadId, - terminalId: liveSession.terminalId, - createdAt: new Date().toISOString(), - hasRunningSubprocess, - }); - }), - ); - } finally { - this.subprocessPollInFlight = false; - } - } - - private async assertValidCwd(cwd: string): Promise { - let stats: fs.Stats; - try { - stats = await fs.promises.stat(cwd); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - throw new Error(`Terminal cwd does not exist: ${cwd}`, { cause: error }); - } - throw error; - } - if (!stats.isDirectory()) { - throw new Error(`Terminal cwd is not a directory: ${cwd}`); - } - } - - private async closeSession( - threadId: string, - terminalId: string, - deleteHistory: boolean, - ): Promise { - const key = toSessionKey(threadId, terminalId); - const session = this.sessions.get(key); - if (session) { - this.stopProcess(session); - this.sessions.delete(key); - } - this.updateSubprocessPollingState(); - await this.flushPersistQueue(threadId, terminalId); - if (deleteHistory) { - await this.deleteHistory(threadId, terminalId); - } - } - - private sessionsForThread(threadId: string): TerminalSessionState[] { - return [...this.sessions.values()].filter((session) => session.threadId === threadId); - } - - private async deleteAllHistoryForThread(threadId: string): Promise { - const threadPrefix = `${toSafeThreadId(threadId)}_`; - try { - const entries = await fs.promises.readdir(this.logsDir, { withFileTypes: true }); - const removals = entries - .filter((entry) => entry.isFile()) - .map((entry) => entry.name) - .filter( - (name) => - name === `${toSafeThreadId(threadId)}.log` || - name === `${legacySafeThreadId(threadId)}.log` || - name.startsWith(threadPrefix), - ) - .map((name) => fs.promises.rm(path.join(this.logsDir, name), { force: true })); - await Promise.all(removals); - } catch (error) { - this.logger.warn("failed to delete terminal histories for thread", { - threadId, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - private requireSession(threadId: string, terminalId: string): TerminalSessionState { - const session = this.sessions.get(toSessionKey(threadId, terminalId)); - if (!session) { - throw new Error(`Unknown terminal thread: ${threadId}, terminal: ${terminalId}`); - } - return session; - } - - private snapshot(session: TerminalSessionState): TerminalSessionSnapshot { - return { - threadId: session.threadId, - terminalId: session.terminalId, - cwd: session.cwd, - status: session.status, - pid: session.pid, - history: session.history, - exitCode: session.exitCode, - exitSignal: session.exitSignal, - updatedAt: session.updatedAt, - }; - } - - private emitEvent(event: TerminalEvent): void { - this.emit("event", event); - } - - private historyPath(threadId: string, terminalId: string): string { - const threadPart = toSafeThreadId(threadId); - if (terminalId === DEFAULT_TERMINAL_ID) { - return path.join(this.logsDir, `${threadPart}.log`); - } - return path.join(this.logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); - } - - private legacyHistoryPath(threadId: string): string { - return path.join(this.logsDir, `${legacySafeThreadId(threadId)}.log`); - } - - private async runWithThreadLock(threadId: string, task: () => Promise): Promise { - const previous = this.threadLocks.get(threadId) ?? Promise.resolve(); - let release: () => void = () => {}; - const current = new Promise((resolve) => { - release = resolve; - }); - this.threadLocks.set(threadId, current); - await previous.catch(() => undefined); - try { - return await task(); - } finally { - release(); - if (this.threadLocks.get(threadId) === current) { - this.threadLocks.delete(threadId); - } - } - } -} - -export function makeTerminalManagerLive( - options: Omit = {}, -): Layer.Layer { - return Layer.effect( - TerminalManager, - Effect.gen(function* () { - const ptyAdapter = yield* PtyAdapter; - const runtime = new TerminalManagerRuntime({ ...options, 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/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);