| author | |
| committer | |
| log | 52681634cdf71e05a8df069664953b42352a388c |
| tree | 0a4e87fa1fe68ae55cc0f0f7afc0eef5bc6d902e |
| parent | 35f39435f3feb5bc6b44d25d069cf29c98e99ed5 |
39 files changed, 6425 insertions(+), 5211 deletions(-)
apps/server/src/checkpointing/Layers/CheckpointStore.ts+2-2| ... | ... | @@ -15,8 +15,8 @@ import { Effect, Layer, FileSystem, Path } from "effect"; |
| 15 | 15 | |
| 16 | 16 | import { CheckpointInvariantError } from "../Errors.ts"; |
| 17 | 17 | import { GitCommandError } from "../../git/Errors.ts"; |
| 18 | import { GitServiceLive } from "../../git/Layer.ts"; | |
| 19 | import { GitService } from "../../git/Service.ts"; | |
| 18 | import { GitServiceLive } from "../../git/Layers/GitService.ts"; | |
| 19 | import { GitService } from "../../git/Services/GitService.ts"; | |
| 20 | 20 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; |
| 21 | 21 | import { CheckpointRef } from "@t3tools/contracts"; |
| 22 | 22 |
apps/server/src/codexTextGenerator.ts deleted-254| ... | ... | @@ -1,254 +0,0 @@ |
| 1 | import { randomUUID } from "node:crypto"; | |
| 2 | import fs from "node:fs/promises"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | ||
| 6 | import type { | |
| 7 | CommitMessageGenerationInput, | |
| 8 | CommitMessageGenerationResult, | |
| 9 | PrContentGenerationInput, | |
| 10 | PrContentGenerationResult, | |
| 11 | TextGenerationService, | |
| 12 | } from "./coreServices"; | |
| 13 | import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner"; | |
| 14 | ||
| 15 | interface ProcessRunner { | |
| 16 | ( | |
| 17 | command: string, | |
| 18 | args: readonly string[], | |
| 19 | options?: ProcessRunOptions, | |
| 20 | ): Promise<ProcessRunResult>; | |
| 21 | } | |
| 22 | ||
| 23 | const CODEX_MODEL = "gpt-5.3-codex"; | |
| 24 | const CODEX_REASONING_EFFORT = "low"; | |
| 25 | ||
| 26 | const COMMIT_OUTPUT_SCHEMA_JSON = { | |
| 27 | type: "object", | |
| 28 | properties: { | |
| 29 | subject: { type: "string" }, | |
| 30 | body: { type: "string" }, | |
| 31 | }, | |
| 32 | required: ["subject", "body"], | |
| 33 | additionalProperties: false, | |
| 34 | } as const; | |
| 35 | ||
| 36 | const PR_OUTPUT_SCHEMA_JSON = { | |
| 37 | type: "object", | |
| 38 | properties: { | |
| 39 | title: { type: "string" }, | |
| 40 | body: { type: "string" }, | |
| 41 | }, | |
| 42 | required: ["title", "body"], | |
| 43 | additionalProperties: false, | |
| 44 | } as const; | |
| 45 | ||
| 46 | function parseCommitOutput(raw: unknown): { subject: string; body: string } { | |
| 47 | if (!raw || typeof raw !== "object") { | |
| 48 | throw new Error("Codex returned a non-object commit message payload."); | |
| 49 | } | |
| 50 | const record = raw as Record<string, unknown>; | |
| 51 | const subject = typeof record.subject === "string" ? record.subject.trim() : ""; | |
| 52 | const body = typeof record.body === "string" ? record.body : ""; | |
| 53 | if (subject.length === 0) { | |
| 54 | throw new Error("Codex returned an empty commit subject."); | |
| 55 | } | |
| 56 | return { subject, body }; | |
| 57 | } | |
| 58 | ||
| 59 | function parsePrOutput(raw: unknown): { title: string; body: string } { | |
| 60 | if (!raw || typeof raw !== "object") { | |
| 61 | throw new Error("Codex returned a non-object PR payload."); | |
| 62 | } | |
| 63 | const record = raw as Record<string, unknown>; | |
| 64 | const title = typeof record.title === "string" ? record.title.trim() : ""; | |
| 65 | const body = typeof record.body === "string" ? record.body.trim() : ""; | |
| 66 | if (title.length === 0 || body.length === 0) { | |
| 67 | throw new Error("Codex returned an invalid PR title/body payload."); | |
| 68 | } | |
| 69 | return { title, body }; | |
| 70 | } | |
| 71 | ||
| 72 | function limitSection(value: string, maxChars: number): string { | |
| 73 | if (value.length <= maxChars) return value; | |
| 74 | const truncated = value.slice(0, maxChars); | |
| 75 | return `${truncated}\n\n[truncated]`; | |
| 76 | } | |
| 77 | ||
| 78 | async function writeTempFile(prefix: string, content: string): Promise<string> { | |
| 79 | const filePath = path.join(os.tmpdir(), `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`); | |
| 80 | await fs.writeFile(filePath, content, "utf8"); | |
| 81 | return filePath; | |
| 82 | } | |
| 83 | ||
| 84 | async function safeUnlink(filePath: string): Promise<void> { | |
| 85 | try { | |
| 86 | await fs.unlink(filePath); | |
| 87 | } catch { | |
| 88 | // Best-effort cleanup. | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | function sanitizeCommitSubject(raw: string): string { | |
| 93 | const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | |
| 94 | const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); | |
| 95 | if (withoutTrailingPeriod.length === 0) { | |
| 96 | return "Update project files"; | |
| 97 | } | |
| 98 | ||
| 99 | if (withoutTrailingPeriod.length <= 72) { | |
| 100 | return withoutTrailingPeriod; | |
| 101 | } | |
| 102 | return withoutTrailingPeriod.slice(0, 72).trimEnd(); | |
| 103 | } | |
| 104 | ||
| 105 | function sanitizePrTitle(raw: string): string { | |
| 106 | const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | |
| 107 | if (singleLine.length > 0) { | |
| 108 | return singleLine; | |
| 109 | } | |
| 110 | return "Update project changes"; | |
| 111 | } | |
| 112 | ||
| 113 | async function runCodexJson<T>({ | |
| 114 | cwd, | |
| 115 | prompt, | |
| 116 | outputSchemaJson, | |
| 117 | parse, | |
| 118 | run, | |
| 119 | }: { | |
| 120 | cwd: string; | |
| 121 | prompt: string; | |
| 122 | outputSchemaJson: object; | |
| 123 | parse: (raw: unknown) => T; | |
| 124 | run: ProcessRunner; | |
| 125 | }): Promise<T> { | |
| 126 | const schemaPath = await writeTempFile("codex-schema", JSON.stringify(outputSchemaJson)); | |
| 127 | let outputPath: string | null = null; | |
| 128 | ||
| 129 | try { | |
| 130 | outputPath = await writeTempFile("codex-output", ""); | |
| 131 | await run( | |
| 132 | "codex", | |
| 133 | [ | |
| 134 | "exec", | |
| 135 | "--ephemeral", | |
| 136 | "-s", | |
| 137 | "read-only", | |
| 138 | "--model", | |
| 139 | CODEX_MODEL, | |
| 140 | "--config", | |
| 141 | `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`, | |
| 142 | "--output-schema", | |
| 143 | schemaPath, | |
| 144 | "--output-last-message", | |
| 145 | outputPath, | |
| 146 | "-", | |
| 147 | ], | |
| 148 | { | |
| 149 | cwd, | |
| 150 | timeoutMs: 180_000, | |
| 151 | stdin: prompt, | |
| 152 | }, | |
| 153 | ); | |
| 154 | ||
| 155 | const rawOutput = (await fs.readFile(outputPath, "utf8")).trim(); | |
| 156 | if (rawOutput.length === 0) { | |
| 157 | throw new Error("Codex returned an empty response."); | |
| 158 | } | |
| 159 | ||
| 160 | let parsedJson: unknown; | |
| 161 | try { | |
| 162 | parsedJson = JSON.parse(rawOutput); | |
| 163 | } catch { | |
| 164 | throw new Error("Codex returned invalid JSON output."); | |
| 165 | } | |
| 166 | ||
| 167 | return parse(parsedJson); | |
| 168 | } finally { | |
| 169 | await Promise.all([safeUnlink(schemaPath), ...(outputPath ? [safeUnlink(outputPath)] : [])]); | |
| 170 | } | |
| 171 | } | |
| 172 | ||
| 173 | interface CodexTextGeneratorDeps { | |
| 174 | runProcess?: ProcessRunner; | |
| 175 | } | |
| 176 | ||
| 177 | export class CodexTextGenerator implements TextGenerationService { | |
| 178 | private readonly run: ProcessRunner; | |
| 179 | ||
| 180 | constructor(deps: CodexTextGeneratorDeps = {}) { | |
| 181 | this.run = deps.runProcess ?? runProcess; | |
| 182 | } | |
| 183 | ||
| 184 | async generateCommitMessage( | |
| 185 | input: CommitMessageGenerationInput, | |
| 186 | ): Promise<CommitMessageGenerationResult> { | |
| 187 | const prompt = [ | |
| 188 | "You write concise git commit messages.", | |
| 189 | "Return a JSON object with keys: subject, body.", | |
| 190 | "Rules:", | |
| 191 | "- subject must be imperative, <= 72 chars, and no trailing period", | |
| 192 | "- body can be empty string or short bullet points", | |
| 193 | "- capture the primary user-visible or developer-visible change", | |
| 194 | "", | |
| 195 | `Branch: ${input.branch ?? "(detached)"}`, | |
| 196 | "", | |
| 197 | "Staged files:", | |
| 198 | limitSection(input.stagedSummary, 6_000), | |
| 199 | "", | |
| 200 | "Staged patch:", | |
| 201 | limitSection(input.stagedPatch, 40_000), | |
| 202 | ].join("\n"); | |
| 203 | ||
| 204 | const generated = await runCodexJson({ | |
| 205 | cwd: input.cwd, | |
| 206 | prompt, | |
| 207 | outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON, | |
| 208 | parse: (raw) => parseCommitOutput(raw), | |
| 209 | run: this.run, | |
| 210 | }); | |
| 211 | ||
| 212 | return { | |
| 213 | subject: sanitizeCommitSubject(generated.subject), | |
| 214 | body: generated.body.trim(), | |
| 215 | }; | |
| 216 | } | |
| 217 | ||
| 218 | async generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult> { | |
| 219 | const prompt = [ | |
| 220 | "You write GitHub pull request content.", | |
| 221 | "Return a JSON object with keys: title, body.", | |
| 222 | "Rules:", | |
| 223 | "- title should be concise and specific", | |
| 224 | "- body must be markdown and include headings '## Summary' and '## Testing'", | |
| 225 | "- under Summary, provide short bullet points", | |
| 226 | "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", | |
| 227 | "", | |
| 228 | `Base branch: ${input.baseBranch}`, | |
| 229 | `Head branch: ${input.headBranch}`, | |
| 230 | "", | |
| 231 | "Commits:", | |
| 232 | limitSection(input.commitSummary, 12_000), | |
| 233 | "", | |
| 234 | "Diff stat:", | |
| 235 | limitSection(input.diffSummary, 12_000), | |
| 236 | "", | |
| 237 | "Diff patch:", | |
| 238 | limitSection(input.diffPatch, 40_000), | |
| 239 | ].join("\n"); | |
| 240 | ||
| 241 | const generated = await runCodexJson({ | |
| 242 | cwd: input.cwd, | |
| 243 | prompt, | |
| 244 | outputSchemaJson: PR_OUTPUT_SCHEMA_JSON, | |
| 245 | parse: (raw) => parsePrOutput(raw), | |
| 246 | run: this.run, | |
| 247 | }); | |
| 248 | ||
| 249 | return { | |
| 250 | title: sanitizePrTitle(generated.title), | |
| 251 | body: generated.body.trim(), | |
| 252 | }; | |
| 253 | } | |
| 254 | } |
apps/server/src/coreServices.ts deleted-32| ... | ... | @@ -1,32 +0,0 @@ |
| 1 | export interface CommitMessageGenerationInput { | |
| 2 | cwd: string; | |
| 3 | branch: string | null; | |
| 4 | stagedSummary: string; | |
| 5 | stagedPatch: string; | |
| 6 | } | |
| 7 | ||
| 8 | export interface CommitMessageGenerationResult { | |
| 9 | subject: string; | |
| 10 | body: string; | |
| 11 | } | |
| 12 | ||
| 13 | export interface PrContentGenerationInput { | |
| 14 | cwd: string; | |
| 15 | baseBranch: string; | |
| 16 | headBranch: string; | |
| 17 | commitSummary: string; | |
| 18 | diffSummary: string; | |
| 19 | diffPatch: string; | |
| 20 | } | |
| 21 | ||
| 22 | export interface PrContentGenerationResult { | |
| 23 | title: string; | |
| 24 | body: string; | |
| 25 | } | |
| 26 | ||
| 27 | export interface TextGenerationService { | |
| 28 | generateCommitMessage( | |
| 29 | input: CommitMessageGenerationInput, | |
| 30 | ): Promise<CommitMessageGenerationResult>; | |
| 31 | generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult>; | |
| 32 | } |
apps/server/src/git.test.ts deleted-930| ... | ... | @@ -1,930 +0,0 @@ |
| 1 | import { existsSync } from "node:fs"; | |
| 2 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | import { describe, expect, it, vi } from "vitest"; | |
| 6 | ||
| 7 | import { | |
| 8 | GitCoreService, | |
| 9 | checkoutGitBranch, | |
| 10 | createGitBranch, | |
| 11 | createGitWorktree, | |
| 12 | initGitRepo, | |
| 13 | listGitBranches, | |
| 14 | pullGitBranch, | |
| 15 | removeGitWorktree, | |
| 16 | runTerminalCommand, | |
| 17 | } from "./git"; | |
| 18 | ||
| 19 | // ── Helpers ── | |
| 20 | ||
| 21 | /** Run a raw git command for test setup (not under test). */ | |
| 22 | async function git(cwd: string, command: string): Promise<string> { | |
| 23 | const result = await runTerminalCommand({ | |
| 24 | command: `git ${command}`, | |
| 25 | cwd, | |
| 26 | timeoutMs: 10_000, | |
| 27 | }); | |
| 28 | if (result.code !== 0) { | |
| 29 | throw new Error(`git ${command} failed: ${result.stderr}`); | |
| 30 | } | |
| 31 | return result.stdout.trim(); | |
| 32 | } | |
| 33 | ||
| 34 | /** Create a disposable temp directory that cleans up automatically. */ | |
| 35 | async function makeTmpDir() { | |
| 36 | const dir = await mkdtemp(path.join(tmpdir(), "git-test-")); | |
| 37 | return { | |
| 38 | path: dir, | |
| 39 | [Symbol.asyncDispose]: async () => { | |
| 40 | await rm(dir, { recursive: true, force: true }); | |
| 41 | }, | |
| 42 | }; | |
| 43 | } | |
| 44 | ||
| 45 | /** Create a repo with an initial commit so branches work. */ | |
| 46 | async function initRepoWithCommit(cwd: string): Promise<void> { | |
| 47 | await initGitRepo({ cwd }); | |
| 48 | await git(cwd, "config user.email 'test@test.com'"); | |
| 49 | await git(cwd, "config user.name 'Test'"); | |
| 50 | await writeFile(path.join(cwd, "README.md"), "# test\n"); | |
| 51 | await git(cwd, "add ."); | |
| 52 | await git(cwd, "commit -m 'initial commit'"); | |
| 53 | } | |
| 54 | ||
| 55 | async function commitWithDate( | |
| 56 | cwd: string, | |
| 57 | fileName: string, | |
| 58 | fileContents: string, | |
| 59 | dateIsoString: string, | |
| 60 | message: string, | |
| 61 | ): Promise<void> { | |
| 62 | await writeFile(path.join(cwd, fileName), fileContents); | |
| 63 | await git(cwd, `add ${fileName}`); | |
| 64 | const commitResult = await runTerminalCommand({ | |
| 65 | command: `GIT_AUTHOR_DATE="${dateIsoString}" GIT_COMMITTER_DATE="${dateIsoString}" git commit -m "${message}"`, | |
| 66 | cwd, | |
| 67 | timeoutMs: 10_000, | |
| 68 | }); | |
| 69 | if (commitResult.code !== 0) { | |
| 70 | throw new Error(`git dated commit failed: ${commitResult.stderr}`); | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | // ── Tests ── | |
| 75 | ||
| 76 | describe("git integration", () => { | |
| 77 | describe("runTerminalCommand", () => { | |
| 78 | it("caps captured output when maxOutputBytes is exceeded", async () => { | |
| 79 | const result = await runTerminalCommand({ | |
| 80 | command: `node -e "process.stdout.write('x'.repeat(2000))"`, | |
| 81 | cwd: process.cwd(), | |
| 82 | timeoutMs: 10_000, | |
| 83 | maxOutputBytes: 128, | |
| 84 | }); | |
| 85 | ||
| 86 | expect(result.code).toBe(0); | |
| 87 | expect(result.stdout.length).toBeLessThanOrEqual(128); | |
| 88 | expect(result.stderr).toContain("output truncated"); | |
| 89 | }); | |
| 90 | }); | |
| 91 | ||
| 92 | // ── initGitRepo ── | |
| 93 | ||
| 94 | describe("initGitRepo", () => { | |
| 95 | it("creates a valid git repo", async () => { | |
| 96 | await using tmp = await makeTmpDir(); | |
| 97 | await initGitRepo({ cwd: tmp.path }); | |
| 98 | expect(existsSync(path.join(tmp.path, ".git"))).toBe(true); | |
| 99 | }); | |
| 100 | ||
| 101 | it("listGitBranches reports isRepo: true after init + commit", async () => { | |
| 102 | await using tmp = await makeTmpDir(); | |
| 103 | await initRepoWithCommit(tmp.path); | |
| 104 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 105 | expect(result.isRepo).toBe(true); | |
| 106 | expect(result.branches.length).toBeGreaterThanOrEqual(1); | |
| 107 | }); | |
| 108 | }); | |
| 109 | ||
| 110 | // ── listGitBranches ── | |
| 111 | ||
| 112 | describe("listGitBranches", () => { | |
| 113 | it("returns isRepo: false for non-git directory", async () => { | |
| 114 | await using tmp = await makeTmpDir(); | |
| 115 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 116 | expect(result.isRepo).toBe(false); | |
| 117 | expect(result.branches).toEqual([]); | |
| 118 | }); | |
| 119 | ||
| 120 | it("returns the current branch with current: true", async () => { | |
| 121 | await using tmp = await makeTmpDir(); | |
| 122 | await initRepoWithCommit(tmp.path); | |
| 123 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 124 | const current = result.branches.find((b) => b.current); | |
| 125 | expect(current).toBeDefined(); | |
| 126 | expect(current!.current).toBe(true); | |
| 127 | }); | |
| 128 | ||
| 129 | it("keeps current branch first and sorts the remaining branches by recency", async () => { | |
| 130 | await using tmp = await makeTmpDir(); | |
| 131 | await initRepoWithCommit(tmp.path); | |
| 132 | const initialBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 133 | (branch) => branch.current, | |
| 134 | )!.name; | |
| 135 | ||
| 136 | await createGitBranch({ cwd: tmp.path, branch: "older-branch" }); | |
| 137 | await checkoutGitBranch({ cwd: tmp.path, branch: "older-branch" }); | |
| 138 | await commitWithDate( | |
| 139 | tmp.path, | |
| 140 | "older.txt", | |
| 141 | "older branch change\n", | |
| 142 | "Thu, 1 Jan 2037 00:00:00 +0000", | |
| 143 | "older branch change", | |
| 144 | ); | |
| 145 | ||
| 146 | await checkoutGitBranch({ cwd: tmp.path, branch: initialBranch }); | |
| 147 | await createGitBranch({ cwd: tmp.path, branch: "newer-branch" }); | |
| 148 | await checkoutGitBranch({ cwd: tmp.path, branch: "newer-branch" }); | |
| 149 | await commitWithDate( | |
| 150 | tmp.path, | |
| 151 | "newer.txt", | |
| 152 | "newer branch change\n", | |
| 153 | "Fri, 1 Jan 2038 00:00:00 +0000", | |
| 154 | "newer branch change", | |
| 155 | ); | |
| 156 | ||
| 157 | // Switch away to show current branch is pinned, then remaining branches are recency-sorted. | |
| 158 | await checkoutGitBranch({ cwd: tmp.path, branch: "older-branch" }); | |
| 159 | ||
| 160 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 161 | expect(result.branches[0]!.name).toBe("older-branch"); | |
| 162 | expect(result.branches[1]!.name).toBe("newer-branch"); | |
| 163 | }); | |
| 164 | ||
| 165 | it("keeps default branch right after current branch", async () => { | |
| 166 | await using tmp = await makeTmpDir(); | |
| 167 | await using remote = await makeTmpDir(); | |
| 168 | await initRepoWithCommit(tmp.path); | |
| 169 | const defaultBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 170 | (branch) => branch.current, | |
| 171 | )!.name; | |
| 172 | ||
| 173 | await git(remote.path, "init --bare"); | |
| 174 | await git(tmp.path, `remote add origin ${JSON.stringify(remote.path)}`); | |
| 175 | await git(tmp.path, `push -u origin ${defaultBranch}`); | |
| 176 | await git(tmp.path, `remote set-head origin ${defaultBranch}`); | |
| 177 | ||
| 178 | await createGitBranch({ cwd: tmp.path, branch: "current-branch" }); | |
| 179 | await checkoutGitBranch({ cwd: tmp.path, branch: "current-branch" }); | |
| 180 | await commitWithDate( | |
| 181 | tmp.path, | |
| 182 | "current.txt", | |
| 183 | "current change\n", | |
| 184 | "Thu, 1 Jan 2037 00:00:00 +0000", | |
| 185 | "current change", | |
| 186 | ); | |
| 187 | ||
| 188 | await checkoutGitBranch({ cwd: tmp.path, branch: defaultBranch }); | |
| 189 | await createGitBranch({ cwd: tmp.path, branch: "newer-branch" }); | |
| 190 | await checkoutGitBranch({ cwd: tmp.path, branch: "newer-branch" }); | |
| 191 | await commitWithDate( | |
| 192 | tmp.path, | |
| 193 | "newer.txt", | |
| 194 | "newer change\n", | |
| 195 | "Fri, 1 Jan 2038 00:00:00 +0000", | |
| 196 | "newer change", | |
| 197 | ); | |
| 198 | ||
| 199 | await checkoutGitBranch({ cwd: tmp.path, branch: "current-branch" }); | |
| 200 | ||
| 201 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 202 | expect(result.branches[0]!.name).toBe("current-branch"); | |
| 203 | expect(result.branches[1]!.name).toBe(defaultBranch); | |
| 204 | expect(result.branches[2]!.name).toBe("newer-branch"); | |
| 205 | }); | |
| 206 | ||
| 207 | it("lists multiple branches after creating them", async () => { | |
| 208 | await using tmp = await makeTmpDir(); | |
| 209 | await initRepoWithCommit(tmp.path); | |
| 210 | await createGitBranch({ cwd: tmp.path, branch: "feature-a" }); | |
| 211 | await createGitBranch({ cwd: tmp.path, branch: "feature-b" }); | |
| 212 | ||
| 213 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 214 | const names = result.branches.map((b) => b.name); | |
| 215 | expect(names).toContain("feature-a"); | |
| 216 | expect(names).toContain("feature-b"); | |
| 217 | }); | |
| 218 | ||
| 219 | it("isDefault is false when no remote exists", async () => { | |
| 220 | await using tmp = await makeTmpDir(); | |
| 221 | await initRepoWithCommit(tmp.path); | |
| 222 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 223 | expect(result.branches.every((b) => b.isDefault === false)).toBe(true); | |
| 224 | }); | |
| 225 | }); | |
| 226 | ||
| 227 | // ── checkoutGitBranch ── | |
| 228 | ||
| 229 | describe("checkoutGitBranch", () => { | |
| 230 | it("checks out an existing branch", async () => { | |
| 231 | await using tmp = await makeTmpDir(); | |
| 232 | await initRepoWithCommit(tmp.path); | |
| 233 | await createGitBranch({ cwd: tmp.path, branch: "feature" }); | |
| 234 | ||
| 235 | await checkoutGitBranch({ cwd: tmp.path, branch: "feature" }); | |
| 236 | ||
| 237 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 238 | const current = result.branches.find((b) => b.current); | |
| 239 | expect(current!.name).toBe("feature"); | |
| 240 | }); | |
| 241 | ||
| 242 | it("refreshes upstream behind count after checkout when remote branch advanced", async () => { | |
| 243 | await using remote = await makeTmpDir(); | |
| 244 | await using source = await makeTmpDir(); | |
| 245 | await using clone = await makeTmpDir(); | |
| 246 | await git(remote.path, "init --bare"); | |
| 247 | ||
| 248 | await initRepoWithCommit(source.path); | |
| 249 | const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( | |
| 250 | (branch) => branch.current, | |
| 251 | )!.name; | |
| 252 | await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); | |
| 253 | await git(source.path, `push -u origin ${defaultBranch}`); | |
| 254 | ||
| 255 | const featureBranch = "feature-behind"; | |
| 256 | await createGitBranch({ cwd: source.path, branch: featureBranch }); | |
| 257 | await checkoutGitBranch({ cwd: source.path, branch: featureBranch }); | |
| 258 | await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); | |
| 259 | await git(source.path, "add feature.txt"); | |
| 260 | await git(source.path, "commit -m 'feature base'"); | |
| 261 | await git(source.path, `push -u origin ${featureBranch}`); | |
| 262 | await checkoutGitBranch({ cwd: source.path, branch: defaultBranch }); | |
| 263 | ||
| 264 | await git(clone.path, `clone ${JSON.stringify(remote.path)} .`); | |
| 265 | await git(clone.path, "config user.email 'test@test.com'"); | |
| 266 | await git(clone.path, "config user.name 'Test'"); | |
| 267 | await git(clone.path, `checkout -b ${featureBranch} --track origin/${featureBranch}`); | |
| 268 | await writeFile(path.join(clone.path, "feature.txt"), "feature from remote\n"); | |
| 269 | await git(clone.path, "add feature.txt"); | |
| 270 | await git(clone.path, "commit -m 'remote feature update'"); | |
| 271 | await git(clone.path, `push origin ${featureBranch}`); | |
| 272 | ||
| 273 | await checkoutGitBranch({ cwd: source.path, branch: featureBranch }); | |
| 274 | const core = new GitCoreService(); | |
| 275 | await vi.waitFor(async () => { | |
| 276 | const details = await core.statusDetails(source.path); | |
| 277 | expect(details.branch).toBe(featureBranch); | |
| 278 | expect(details.aheadCount).toBe(0); | |
| 279 | expect(details.behindCount).toBe(1); | |
| 280 | }); | |
| 281 | }); | |
| 282 | ||
| 283 | it("keeps checkout successful when upstream refresh fails", async () => { | |
| 284 | await using remote = await makeTmpDir(); | |
| 285 | await using source = await makeTmpDir(); | |
| 286 | await git(remote.path, "init --bare"); | |
| 287 | ||
| 288 | await initRepoWithCommit(source.path); | |
| 289 | const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( | |
| 290 | (branch) => branch.current, | |
| 291 | )!.name; | |
| 292 | await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); | |
| 293 | await git(source.path, `push -u origin ${defaultBranch}`); | |
| 294 | ||
| 295 | const featureBranch = "feature-refresh-failure"; | |
| 296 | await git(source.path, `branch ${featureBranch}`); | |
| 297 | await git(source.path, `checkout ${featureBranch}`); | |
| 298 | await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); | |
| 299 | await git(source.path, "add feature.txt"); | |
| 300 | await git(source.path, "commit -m 'feature base'"); | |
| 301 | await git(source.path, `push -u origin ${featureBranch}`); | |
| 302 | await git(source.path, `checkout ${defaultBranch}`); | |
| 303 | ||
| 304 | class RefreshFailingGitCoreService extends GitCoreService { | |
| 305 | refreshFetchAttempts = 0; | |
| 306 | ||
| 307 | override async git( | |
| 308 | cwd: string, | |
| 309 | args: readonly string[], | |
| 310 | allowNonZeroExit = false, | |
| 311 | ): Promise<void> { | |
| 312 | if (args[0] === "fetch") { | |
| 313 | this.refreshFetchAttempts += 1; | |
| 314 | throw new Error("simulated fetch timeout"); | |
| 315 | } | |
| 316 | await super.git(cwd, args, allowNonZeroExit); | |
| 317 | } | |
| 318 | } | |
| 319 | ||
| 320 | const core = new RefreshFailingGitCoreService(); | |
| 321 | await expect(core.checkoutBranch({ cwd: source.path, branch: featureBranch })).resolves.toBe( | |
| 322 | undefined, | |
| 323 | ); | |
| 324 | await vi.waitFor(() => { | |
| 325 | expect(core.refreshFetchAttempts).toBe(1); | |
| 326 | }); | |
| 327 | expect(await git(source.path, "branch --show-current")).toBe(featureBranch); | |
| 328 | }); | |
| 329 | ||
| 330 | it("refresh fetch is scoped to the checked out branch upstream refspec", async () => { | |
| 331 | await using remote = await makeTmpDir(); | |
| 332 | await using source = await makeTmpDir(); | |
| 333 | await git(remote.path, "init --bare"); | |
| 334 | ||
| 335 | await initRepoWithCommit(source.path); | |
| 336 | const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( | |
| 337 | (branch) => branch.current, | |
| 338 | )!.name; | |
| 339 | await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); | |
| 340 | await git(source.path, `push -u origin ${defaultBranch}`); | |
| 341 | ||
| 342 | const featureBranch = "feature/scoped-fetch"; | |
| 343 | await git(source.path, `checkout -b ${featureBranch}`); | |
| 344 | await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); | |
| 345 | await git(source.path, "add feature.txt"); | |
| 346 | await git(source.path, "commit -m 'feature base'"); | |
| 347 | await git(source.path, `push -u origin ${featureBranch}`); | |
| 348 | await git(source.path, `checkout ${defaultBranch}`); | |
| 349 | ||
| 350 | class FetchRecordingGitCoreService extends GitCoreService { | |
| 351 | fetchArgs: readonly string[] | null = null; | |
| 352 | ||
| 353 | override async git( | |
| 354 | cwd: string, | |
| 355 | args: readonly string[], | |
| 356 | allowNonZeroExit = false, | |
| 357 | ): Promise<void> { | |
| 358 | if (args[0] === "fetch") { | |
| 359 | this.fetchArgs = args; | |
| 360 | return; | |
| 361 | } | |
| 362 | await super.git(cwd, args, allowNonZeroExit); | |
| 363 | } | |
| 364 | } | |
| 365 | ||
| 366 | const core = new FetchRecordingGitCoreService(); | |
| 367 | await core.checkoutBranch({ cwd: source.path, branch: featureBranch }); | |
| 368 | await vi.waitFor(() => { | |
| 369 | expect(core.fetchArgs).not.toBeNull(); | |
| 370 | }); | |
| 371 | ||
| 372 | expect(await git(source.path, "branch --show-current")).toBe(featureBranch); | |
| 373 | expect(core.fetchArgs).toEqual([ | |
| 374 | "fetch", | |
| 375 | "--quiet", | |
| 376 | "--no-tags", | |
| 377 | "origin", | |
| 378 | `+refs/heads/${featureBranch}:refs/remotes/origin/${featureBranch}`, | |
| 379 | ]); | |
| 380 | }); | |
| 381 | ||
| 382 | it("returns checkout result before background upstream refresh completes", async () => { | |
| 383 | await using remote = await makeTmpDir(); | |
| 384 | await using source = await makeTmpDir(); | |
| 385 | await git(remote.path, "init --bare"); | |
| 386 | ||
| 387 | await initRepoWithCommit(source.path); | |
| 388 | const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( | |
| 389 | (branch) => branch.current, | |
| 390 | )!.name; | |
| 391 | await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); | |
| 392 | await git(source.path, `push -u origin ${defaultBranch}`); | |
| 393 | ||
| 394 | const featureBranch = "feature/background-refresh"; | |
| 395 | await git(source.path, `checkout -b ${featureBranch}`); | |
| 396 | await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); | |
| 397 | await git(source.path, "add feature.txt"); | |
| 398 | await git(source.path, "commit -m 'feature base'"); | |
| 399 | await git(source.path, `push -u origin ${featureBranch}`); | |
| 400 | await git(source.path, `checkout ${defaultBranch}`); | |
| 401 | ||
| 402 | class SlowRefreshGitCoreService extends GitCoreService { | |
| 403 | fetchStarted = false; | |
| 404 | private readonly waitForReleasePromise: Promise<void>; | |
| 405 | private releaseWait: () => void = () => undefined; | |
| 406 | ||
| 407 | constructor() { | |
| 408 | super(); | |
| 409 | this.waitForReleasePromise = new Promise<void>((resolve) => { | |
| 410 | this.releaseWait = resolve; | |
| 411 | }); | |
| 412 | } | |
| 413 | ||
| 414 | releaseFetch(): void { | |
| 415 | this.releaseWait(); | |
| 416 | } | |
| 417 | ||
| 418 | override async git( | |
| 419 | cwd: string, | |
| 420 | args: readonly string[], | |
| 421 | allowNonZeroExit = false, | |
| 422 | ): Promise<void> { | |
| 423 | if (args[0] === "fetch") { | |
| 424 | this.fetchStarted = true; | |
| 425 | await this.waitForReleasePromise; | |
| 426 | return; | |
| 427 | } | |
| 428 | await super.git(cwd, args, allowNonZeroExit); | |
| 429 | } | |
| 430 | } | |
| 431 | ||
| 432 | const core = new SlowRefreshGitCoreService(); | |
| 433 | await expect(core.checkoutBranch({ cwd: source.path, branch: featureBranch })).resolves.toBe( | |
| 434 | undefined, | |
| 435 | ); | |
| 436 | await vi.waitFor(() => { | |
| 437 | expect(core.fetchStarted).toBe(true); | |
| 438 | }); | |
| 439 | expect(await git(source.path, "branch --show-current")).toBe(featureBranch); | |
| 440 | core.releaseFetch(); | |
| 441 | }); | |
| 442 | ||
| 443 | it("throws when branch does not exist", async () => { | |
| 444 | await using tmp = await makeTmpDir(); | |
| 445 | await initRepoWithCommit(tmp.path); | |
| 446 | await expect(checkoutGitBranch({ cwd: tmp.path, branch: "nonexistent" })).rejects.toThrow(); | |
| 447 | }); | |
| 448 | ||
| 449 | it("throws when checkout would overwrite uncommitted changes", async () => { | |
| 450 | await using tmp = await makeTmpDir(); | |
| 451 | await initRepoWithCommit(tmp.path); | |
| 452 | await createGitBranch({ cwd: tmp.path, branch: "other" }); | |
| 453 | ||
| 454 | // Create a conflicting change: modify README on current branch | |
| 455 | await writeFile(path.join(tmp.path, "README.md"), "modified\n"); | |
| 456 | await git(tmp.path, "add README.md"); | |
| 457 | ||
| 458 | // First, checkout other branch cleanly | |
| 459 | await git(tmp.path, "stash"); | |
| 460 | await checkoutGitBranch({ cwd: tmp.path, branch: "other" }); | |
| 461 | await writeFile(path.join(tmp.path, "README.md"), "other content\n"); | |
| 462 | await git(tmp.path, "add ."); | |
| 463 | await git(tmp.path, "commit -m 'other change'"); | |
| 464 | ||
| 465 | // Go back to default branch | |
| 466 | const defaultBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 467 | (b) => !b.current, | |
| 468 | )!.name; | |
| 469 | await checkoutGitBranch({ cwd: tmp.path, branch: defaultBranch }); | |
| 470 | ||
| 471 | // Make uncommitted changes to the same file | |
| 472 | await writeFile(path.join(tmp.path, "README.md"), "conflicting local\n"); | |
| 473 | ||
| 474 | // Checkout should fail due to uncommitted changes | |
| 475 | await expect(checkoutGitBranch({ cwd: tmp.path, branch: "other" })).rejects.toThrow(); | |
| 476 | }); | |
| 477 | }); | |
| 478 | ||
| 479 | // ── createGitBranch ── | |
| 480 | ||
| 481 | describe("createGitBranch", () => { | |
| 482 | it("creates a new branch visible in listGitBranches", async () => { | |
| 483 | await using tmp = await makeTmpDir(); | |
| 484 | await initRepoWithCommit(tmp.path); | |
| 485 | await createGitBranch({ cwd: tmp.path, branch: "new-feature" }); | |
| 486 | ||
| 487 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 488 | expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); | |
| 489 | }); | |
| 490 | ||
| 491 | it("throws when branch already exists", async () => { | |
| 492 | await using tmp = await makeTmpDir(); | |
| 493 | await initRepoWithCommit(tmp.path); | |
| 494 | await createGitBranch({ cwd: tmp.path, branch: "dupe" }); | |
| 495 | await expect(createGitBranch({ cwd: tmp.path, branch: "dupe" })).rejects.toThrow(); | |
| 496 | }); | |
| 497 | }); | |
| 498 | ||
| 499 | // ── createGitWorktree + removeGitWorktree ── | |
| 500 | ||
| 501 | describe("createGitWorktree", () => { | |
| 502 | it("creates a worktree with a new branch from the base branch", async () => { | |
| 503 | await using tmp = await makeTmpDir(); | |
| 504 | await initRepoWithCommit(tmp.path); | |
| 505 | ||
| 506 | const wtPath = path.join(tmp.path, "worktree-out"); | |
| 507 | const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 508 | (b) => b.current, | |
| 509 | )!.name; | |
| 510 | ||
| 511 | const result = await createGitWorktree({ | |
| 512 | cwd: tmp.path, | |
| 513 | branch: currentBranch, | |
| 514 | newBranch: "wt-branch", | |
| 515 | path: wtPath, | |
| 516 | }); | |
| 517 | ||
| 518 | expect(result.worktree.path).toBe(wtPath); | |
| 519 | expect(result.worktree.branch).toBe("wt-branch"); | |
| 520 | expect(existsSync(wtPath)).toBe(true); | |
| 521 | expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); | |
| 522 | ||
| 523 | // Clean up worktree before tmp dir disposal | |
| 524 | await removeGitWorktree({ cwd: tmp.path, path: wtPath }); | |
| 525 | }); | |
| 526 | ||
| 527 | it("worktree has the new branch checked out", async () => { | |
| 528 | await using tmp = await makeTmpDir(); | |
| 529 | await initRepoWithCommit(tmp.path); | |
| 530 | ||
| 531 | const wtPath = path.join(tmp.path, "wt-check-dir"); | |
| 532 | const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 533 | (b) => b.current, | |
| 534 | )!.name; | |
| 535 | ||
| 536 | await createGitWorktree({ | |
| 537 | cwd: tmp.path, | |
| 538 | branch: currentBranch, | |
| 539 | newBranch: "wt-check", | |
| 540 | path: wtPath, | |
| 541 | }); | |
| 542 | ||
| 543 | // Verify the worktree is on the new branch | |
| 544 | const branchOutput = await git(wtPath, "branch --show-current"); | |
| 545 | expect(branchOutput).toBe("wt-check"); | |
| 546 | ||
| 547 | await removeGitWorktree({ cwd: tmp.path, path: wtPath }); | |
| 548 | }); | |
| 549 | ||
| 550 | it("throws when new branch name already exists", async () => { | |
| 551 | await using tmp = await makeTmpDir(); | |
| 552 | await initRepoWithCommit(tmp.path); | |
| 553 | await createGitBranch({ cwd: tmp.path, branch: "existing" }); | |
| 554 | ||
| 555 | const wtPath = path.join(tmp.path, "wt-conflict"); | |
| 556 | const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 557 | (b) => b.current, | |
| 558 | )!.name; | |
| 559 | ||
| 560 | await expect( | |
| 561 | createGitWorktree({ | |
| 562 | cwd: tmp.path, | |
| 563 | branch: currentBranch, | |
| 564 | newBranch: "existing", | |
| 565 | path: wtPath, | |
| 566 | }), | |
| 567 | ).rejects.toThrow(); | |
| 568 | }); | |
| 569 | ||
| 570 | it("listGitBranches from worktree cwd reports worktree branch as current", async () => { | |
| 571 | await using tmp = await makeTmpDir(); | |
| 572 | await initRepoWithCommit(tmp.path); | |
| 573 | ||
| 574 | const wtPath = path.join(tmp.path, "wt-list-dir"); | |
| 575 | const mainBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 576 | (b) => b.current, | |
| 577 | )!.name; | |
| 578 | ||
| 579 | await createGitWorktree({ | |
| 580 | cwd: tmp.path, | |
| 581 | branch: mainBranch, | |
| 582 | newBranch: "wt-list", | |
| 583 | path: wtPath, | |
| 584 | }); | |
| 585 | ||
| 586 | // listGitBranches from the worktree should show wt-list as current | |
| 587 | const wtBranches = await listGitBranches({ cwd: wtPath }); | |
| 588 | expect(wtBranches.isRepo).toBe(true); | |
| 589 | const wtCurrent = wtBranches.branches.find((b) => b.current); | |
| 590 | expect(wtCurrent!.name).toBe("wt-list"); | |
| 591 | ||
| 592 | // Main repo should still show the original branch as current | |
| 593 | const mainBranches = await listGitBranches({ cwd: tmp.path }); | |
| 594 | const mainCurrent = mainBranches.branches.find((b) => b.current); | |
| 595 | expect(mainCurrent!.name).toBe(mainBranch); | |
| 596 | ||
| 597 | await removeGitWorktree({ cwd: tmp.path, path: wtPath }); | |
| 598 | }); | |
| 599 | ||
| 600 | it("removeGitWorktree cleans up the worktree", async () => { | |
| 601 | await using tmp = await makeTmpDir(); | |
| 602 | await initRepoWithCommit(tmp.path); | |
| 603 | ||
| 604 | const wtPath = path.join(tmp.path, "wt-remove-dir"); | |
| 605 | const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 606 | (b) => b.current, | |
| 607 | )!.name; | |
| 608 | ||
| 609 | await createGitWorktree({ | |
| 610 | cwd: tmp.path, | |
| 611 | branch: currentBranch, | |
| 612 | newBranch: "wt-remove", | |
| 613 | path: wtPath, | |
| 614 | }); | |
| 615 | expect(existsSync(wtPath)).toBe(true); | |
| 616 | ||
| 617 | await removeGitWorktree({ cwd: tmp.path, path: wtPath }); | |
| 618 | expect(existsSync(wtPath)).toBe(false); | |
| 619 | }); | |
| 620 | ||
| 621 | it("removeGitWorktree force removes a dirty worktree", async () => { | |
| 622 | await using tmp = await makeTmpDir(); | |
| 623 | await initRepoWithCommit(tmp.path); | |
| 624 | ||
| 625 | const wtPath = path.join(tmp.path, "wt-dirty-dir"); | |
| 626 | const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 627 | (b) => b.current, | |
| 628 | )!.name; | |
| 629 | ||
| 630 | await createGitWorktree({ | |
| 631 | cwd: tmp.path, | |
| 632 | branch: currentBranch, | |
| 633 | newBranch: "wt-dirty", | |
| 634 | path: wtPath, | |
| 635 | }); | |
| 636 | expect(existsSync(wtPath)).toBe(true); | |
| 637 | ||
| 638 | await writeFile(path.join(wtPath, "README.md"), "dirty change\n"); | |
| 639 | ||
| 640 | await expect(removeGitWorktree({ cwd: tmp.path, path: wtPath })).rejects.toThrow(); | |
| 641 | expect(existsSync(wtPath)).toBe(true); | |
| 642 | ||
| 643 | await removeGitWorktree({ cwd: tmp.path, path: wtPath, force: true }); | |
| 644 | expect(existsSync(wtPath)).toBe(false); | |
| 645 | }); | |
| 646 | }); | |
| 647 | ||
| 648 | // ── Full flow: local branch checkout ── | |
| 649 | ||
| 650 | describe("full flow: local branch checkout", () => { | |
| 651 | it("init → commit → create branch → checkout → verify current", async () => { | |
| 652 | await using tmp = await makeTmpDir(); | |
| 653 | await initRepoWithCommit(tmp.path); | |
| 654 | await createGitBranch({ cwd: tmp.path, branch: "feature-login" }); | |
| 655 | await checkoutGitBranch({ cwd: tmp.path, branch: "feature-login" }); | |
| 656 | ||
| 657 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 658 | const current = result.branches.find((b) => b.current); | |
| 659 | expect(current!.name).toBe("feature-login"); | |
| 660 | }); | |
| 661 | }); | |
| 662 | ||
| 663 | // ── Full flow: worktree creation from base branch ── | |
| 664 | ||
| 665 | describe("full flow: worktree creation", () => { | |
| 666 | it("creates worktree with new branch from current branch", async () => { | |
| 667 | await using tmp = await makeTmpDir(); | |
| 668 | await initRepoWithCommit(tmp.path); | |
| 669 | ||
| 670 | const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 671 | (b) => b.current, | |
| 672 | )!.name; | |
| 673 | ||
| 674 | const wtPath = path.join(tmp.path, "my-worktree"); | |
| 675 | const result = await createGitWorktree({ | |
| 676 | cwd: tmp.path, | |
| 677 | branch: currentBranch, | |
| 678 | newBranch: "feature-wt", | |
| 679 | path: wtPath, | |
| 680 | }); | |
| 681 | ||
| 682 | // Worktree exists | |
| 683 | expect(existsSync(result.worktree.path)).toBe(true); | |
| 684 | ||
| 685 | // Main repo still on original branch | |
| 686 | const mainBranches = await listGitBranches({ cwd: tmp.path }); | |
| 687 | const mainCurrent = mainBranches.branches.find((b) => b.current); | |
| 688 | expect(mainCurrent!.name).toBe(currentBranch); | |
| 689 | ||
| 690 | // Worktree is on the new branch | |
| 691 | const wtBranch = await git(wtPath, "branch --show-current"); | |
| 692 | expect(wtBranch).toBe("feature-wt"); | |
| 693 | ||
| 694 | await removeGitWorktree({ cwd: tmp.path, path: wtPath }); | |
| 695 | }); | |
| 696 | }); | |
| 697 | ||
| 698 | // ── Full flow: thread switching simulation ── | |
| 699 | ||
| 700 | describe("full flow: thread switching (checkout toggling)", () => { | |
| 701 | it("checkout a → checkout b → checkout a → current matches", async () => { | |
| 702 | await using tmp = await makeTmpDir(); | |
| 703 | await initRepoWithCommit(tmp.path); | |
| 704 | await createGitBranch({ cwd: tmp.path, branch: "branch-a" }); | |
| 705 | await createGitBranch({ cwd: tmp.path, branch: "branch-b" }); | |
| 706 | ||
| 707 | // Simulate switching to thread A's branch | |
| 708 | await checkoutGitBranch({ cwd: tmp.path, branch: "branch-a" }); | |
| 709 | let branches = await listGitBranches({ cwd: tmp.path }); | |
| 710 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); | |
| 711 | ||
| 712 | // Simulate switching to thread B's branch | |
| 713 | await checkoutGitBranch({ cwd: tmp.path, branch: "branch-b" }); | |
| 714 | branches = await listGitBranches({ cwd: tmp.path }); | |
| 715 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b"); | |
| 716 | ||
| 717 | // Switch back to thread A | |
| 718 | await checkoutGitBranch({ cwd: tmp.path, branch: "branch-a" }); | |
| 719 | branches = await listGitBranches({ cwd: tmp.path }); | |
| 720 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); | |
| 721 | }); | |
| 722 | }); | |
| 723 | ||
| 724 | // ── Full flow: checkout conflict ── | |
| 725 | ||
| 726 | describe("full flow: checkout conflict", () => { | |
| 727 | it("uncommitted changes prevent checkout to a diverged branch", async () => { | |
| 728 | await using tmp = await makeTmpDir(); | |
| 729 | await initRepoWithCommit(tmp.path); | |
| 730 | await createGitBranch({ cwd: tmp.path, branch: "diverged" }); | |
| 731 | ||
| 732 | // Make diverged branch have different file content | |
| 733 | await checkoutGitBranch({ cwd: tmp.path, branch: "diverged" }); | |
| 734 | await writeFile(path.join(tmp.path, "README.md"), "diverged content\n"); | |
| 735 | await git(tmp.path, "add ."); | |
| 736 | await git(tmp.path, "commit -m 'diverge'"); | |
| 737 | ||
| 738 | // Actually, let's just get back to the initial branch explicitly | |
| 739 | const allBranches = await listGitBranches({ cwd: tmp.path }); | |
| 740 | const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name; | |
| 741 | await checkoutGitBranch({ cwd: tmp.path, branch: initialBranch }); | |
| 742 | ||
| 743 | // Make local uncommitted changes to the same file | |
| 744 | await writeFile(path.join(tmp.path, "README.md"), "local uncommitted\n"); | |
| 745 | ||
| 746 | // Attempt checkout should fail | |
| 747 | await expect(checkoutGitBranch({ cwd: tmp.path, branch: "diverged" })).rejects.toThrow(); | |
| 748 | ||
| 749 | // Current branch should still be the initial one | |
| 750 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 751 | expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); | |
| 752 | }); | |
| 753 | }); | |
| 754 | ||
| 755 | describe("GitCoreService", () => { | |
| 756 | it("supports branch lifecycle operations through the service API", async () => { | |
| 757 | await using tmp = await makeTmpDir(); | |
| 758 | const core = new GitCoreService(); | |
| 759 | ||
| 760 | await core.initRepo({ cwd: tmp.path }); | |
| 761 | await git(tmp.path, "config user.email 'test@test.com'"); | |
| 762 | await git(tmp.path, "config user.name 'Test'"); | |
| 763 | await writeFile(path.join(tmp.path, "README.md"), "# test\n"); | |
| 764 | await git(tmp.path, "add ."); | |
| 765 | await git(tmp.path, "commit -m 'initial commit'"); | |
| 766 | ||
| 767 | await core.createBranch({ cwd: tmp.path, branch: "feature/service-api" }); | |
| 768 | await core.checkoutBranch({ cwd: tmp.path, branch: "feature/service-api" }); | |
| 769 | const branches = await core.listBranches({ cwd: tmp.path }); | |
| 770 | ||
| 771 | expect(branches.isRepo).toBe(true); | |
| 772 | expect(branches.branches.find((branch) => branch.current)?.name).toBe("feature/service-api"); | |
| 773 | }); | |
| 774 | ||
| 775 | it("reports status details and dirty state", async () => { | |
| 776 | await using tmp = await makeTmpDir(); | |
| 777 | await initRepoWithCommit(tmp.path); | |
| 778 | const core = new GitCoreService(); | |
| 779 | ||
| 780 | const clean = await core.status({ cwd: tmp.path }); | |
| 781 | expect(clean.hasWorkingTreeChanges).toBe(false); | |
| 782 | expect(clean.branch).toBeTruthy(); | |
| 783 | ||
| 784 | await writeFile(path.join(tmp.path, "README.md"), "updated\n"); | |
| 785 | const dirty = await core.statusDetails(tmp.path); | |
| 786 | expect(dirty.hasWorkingTreeChanges).toBe(true); | |
| 787 | }); | |
| 788 | ||
| 789 | it("includes command context when worktree removal fails", async () => { | |
| 790 | await using tmp = await makeTmpDir(); | |
| 791 | await initRepoWithCommit(tmp.path); | |
| 792 | const core = new GitCoreService(); | |
| 793 | const missingWorktreePath = path.join(tmp.path, "missing-worktree"); | |
| 794 | ||
| 795 | const error = await core | |
| 796 | .removeWorktree({ cwd: tmp.path, path: missingWorktreePath }) | |
| 797 | .then(() => null) | |
| 798 | .catch((err: unknown) => err); | |
| 799 | ||
| 800 | expect(error).toBeInstanceOf(Error); | |
| 801 | const message = (error as Error).message; | |
| 802 | expect(message).toContain("git worktree remove"); | |
| 803 | expect(message).toContain(`cwd: ${tmp.path}`); | |
| 804 | expect(message).toContain(missingWorktreePath); | |
| 805 | }); | |
| 806 | ||
| 807 | it("refreshes upstream before statusDetails so behind count reflects remote updates", async () => { | |
| 808 | await using remote = await makeTmpDir(); | |
| 809 | await using source = await makeTmpDir(); | |
| 810 | await using clone = await makeTmpDir(); | |
| 811 | await git(remote.path, "init --bare"); | |
| 812 | ||
| 813 | await initRepoWithCommit(source.path); | |
| 814 | const initialBranch = (await listGitBranches({ cwd: source.path })).branches.find( | |
| 815 | (branch) => branch.current, | |
| 816 | )!.name; | |
| 817 | await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); | |
| 818 | await git(source.path, `push -u origin ${initialBranch}`); | |
| 819 | ||
| 820 | await git(clone.path, `clone ${JSON.stringify(remote.path)} .`); | |
| 821 | await git(clone.path, "config user.email 'test@test.com'"); | |
| 822 | await git(clone.path, "config user.name 'Test'"); | |
| 823 | await git(clone.path, `checkout -B ${initialBranch} --track origin/${initialBranch}`); | |
| 824 | await writeFile(path.join(clone.path, "CHANGELOG.md"), "remote change\n"); | |
| 825 | await git(clone.path, "add CHANGELOG.md"); | |
| 826 | await git(clone.path, "commit -m 'remote update'"); | |
| 827 | await git(clone.path, `push origin ${initialBranch}`); | |
| 828 | ||
| 829 | const core = new GitCoreService(); | |
| 830 | const details = await core.statusDetails(source.path); | |
| 831 | expect(details.branch).toBe(initialBranch); | |
| 832 | expect(details.aheadCount).toBe(0); | |
| 833 | expect(details.behindCount).toBe(1); | |
| 834 | }); | |
| 835 | ||
| 836 | it("prepares commit context by auto-staging and creates commit", async () => { | |
| 837 | await using tmp = await makeTmpDir(); | |
| 838 | await initRepoWithCommit(tmp.path); | |
| 839 | const core = new GitCoreService(); | |
| 840 | ||
| 841 | await writeFile(path.join(tmp.path, "README.md"), "new content\n"); | |
| 842 | const context = await core.prepareCommitContext(tmp.path); | |
| 843 | expect(context).not.toBeNull(); | |
| 844 | expect(context!.stagedSummary.length).toBeGreaterThan(0); | |
| 845 | expect(context!.stagedPatch.length).toBeGreaterThan(0); | |
| 846 | ||
| 847 | const created = await core.commit(tmp.path, "Add README update", "- include updated content"); | |
| 848 | expect(created.commitSha.length).toBeGreaterThan(0); | |
| 849 | expect(await git(tmp.path, "log -1 --pretty=%s")).toBe("Add README update"); | |
| 850 | }); | |
| 851 | ||
| 852 | it("pushes with upstream setup and then skips when up to date", async () => { | |
| 853 | await using tmp = await makeTmpDir(); | |
| 854 | await using remote = await makeTmpDir(); | |
| 855 | await initRepoWithCommit(tmp.path); | |
| 856 | await git(remote.path, "init --bare"); | |
| 857 | await git(tmp.path, `remote add origin ${JSON.stringify(remote.path)}`); | |
| 858 | await createGitBranch({ cwd: tmp.path, branch: "feature/core-push" }); | |
| 859 | await checkoutGitBranch({ cwd: tmp.path, branch: "feature/core-push" }); | |
| 860 | ||
| 861 | await writeFile(path.join(tmp.path, "feature.txt"), "push me\n"); | |
| 862 | const core = new GitCoreService(); | |
| 863 | const context = await core.prepareCommitContext(tmp.path); | |
| 864 | expect(context).not.toBeNull(); | |
| 865 | await core.commit(tmp.path, "Add feature file", ""); | |
| 866 | ||
| 867 | const pushed = await core.pushCurrentBranch(tmp.path, null); | |
| 868 | expect(pushed.status).toBe("pushed"); | |
| 869 | expect(pushed.setUpstream).toBe(true); | |
| 870 | expect(await git(tmp.path, "rev-parse --abbrev-ref @{upstream}")).toBe( | |
| 871 | "origin/feature/core-push", | |
| 872 | ); | |
| 873 | ||
| 874 | const skipped = await core.pushCurrentBranch(tmp.path, null); | |
| 875 | expect(skipped.status).toBe("skipped_up_to_date"); | |
| 876 | }); | |
| 877 | ||
| 878 | it("pulls behind branch and then reports up-to-date", async () => { | |
| 879 | await using remote = await makeTmpDir(); | |
| 880 | await using source = await makeTmpDir(); | |
| 881 | await using clone = await makeTmpDir(); | |
| 882 | await git(remote.path, "init --bare"); | |
| 883 | ||
| 884 | await initRepoWithCommit(source.path); | |
| 885 | const initialBranch = (await listGitBranches({ cwd: source.path })).branches.find( | |
| 886 | (branch) => branch.current, | |
| 887 | )!.name; | |
| 888 | await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); | |
| 889 | await git(source.path, `push -u origin ${initialBranch}`); | |
| 890 | ||
| 891 | await git(clone.path, `clone ${JSON.stringify(remote.path)} .`); | |
| 892 | await git(clone.path, "config user.email 'test@test.com'"); | |
| 893 | await git(clone.path, "config user.name 'Test'"); | |
| 894 | await writeFile(path.join(clone.path, "CHANGELOG.md"), "remote change\n"); | |
| 895 | await git(clone.path, "add CHANGELOG.md"); | |
| 896 | await git(clone.path, "commit -m 'remote update'"); | |
| 897 | await git(clone.path, `push origin ${initialBranch}`); | |
| 898 | ||
| 899 | const core = new GitCoreService(); | |
| 900 | const pulled = await core.pullCurrentBranch(source.path); | |
| 901 | expect(pulled.status).toBe("pulled"); | |
| 902 | expect((await core.statusDetails(source.path)).behindCount).toBe(0); | |
| 903 | ||
| 904 | const skipped = await core.pullCurrentBranch(source.path); | |
| 905 | expect(skipped.status).toBe("skipped_up_to_date"); | |
| 906 | }); | |
| 907 | ||
| 908 | it("top-level pullGitBranch rejects when no upstream exists", async () => { | |
| 909 | await using tmp = await makeTmpDir(); | |
| 910 | await initRepoWithCommit(tmp.path); | |
| 911 | await expect(pullGitBranch({ cwd: tmp.path })).rejects.toThrow("no upstream"); | |
| 912 | }); | |
| 913 | ||
| 914 | it("lists branches when recency lookup fails", async () => { | |
| 915 | await using tmp = await makeTmpDir(); | |
| 916 | await initRepoWithCommit(tmp.path); | |
| 917 | const core = new GitCoreService(); | |
| 918 | const recencySpy = vi | |
| 919 | .spyOn(core as any, "readBranchRecency") | |
| 920 | .mockRejectedValueOnce(new Error("timeout")); | |
| 921 | ||
| 922 | const result = await core.listBranches({ cwd: tmp.path }); | |
| 923 | ||
| 924 | expect(result.isRepo).toBe(true); | |
| 925 | expect(result.branches.length).toBeGreaterThan(0); | |
| 926 | expect(result.branches[0]?.current).toBe(true); | |
| 927 | recencySpy.mockRestore(); | |
| 928 | }); | |
| 929 | }); | |
| 930 | }); |
apps/server/src/git.ts deleted-730| ... | ... | @@ -1,730 +0,0 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { | |
| 6 | GitPullInput, | |
| 7 | GitPullResult, | |
| 8 | GitStatusInput, | |
| 9 | GitStatusResult, | |
| 10 | type GitCheckoutInput, | |
| 11 | type GitCreateBranchInput, | |
| 12 | type GitCreateWorktreeInput, | |
| 13 | type GitCreateWorktreeResult, | |
| 14 | type GitInitInput, | |
| 15 | type GitListBranchesInput, | |
| 16 | type GitListBranchesResult, | |
| 17 | type GitRemoveWorktreeInput, | |
| 18 | } from "@t3tools/contracts"; | |
| 19 | import { type ProcessRunResult, runProcess } from "./processRunner"; | |
| 20 | ||
| 21 | export interface TerminalCommandInput { | |
| 22 | command: string; | |
| 23 | cwd: string; | |
| 24 | timeoutMs?: number; | |
| 25 | maxOutputBytes?: number; | |
| 26 | } | |
| 27 | ||
| 28 | export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> { | |
| 29 | upstreamRef: string | null; | |
| 30 | } | |
| 31 | ||
| 32 | export interface GitPreparedCommitContext { | |
| 33 | stagedSummary: string; | |
| 34 | stagedPatch: string; | |
| 35 | } | |
| 36 | ||
| 37 | export interface GitPushResult { | |
| 38 | status: "pushed" | "skipped_up_to_date"; | |
| 39 | branch: string; | |
| 40 | upstreamBranch?: string | undefined; | |
| 41 | setUpstream?: boolean | undefined; | |
| 42 | } | |
| 43 | ||
| 44 | export interface GitRangeContext { | |
| 45 | commitSummary: string; | |
| 46 | diffSummary: string; | |
| 47 | diffPatch: string; | |
| 48 | } | |
| 49 | ||
| 50 | interface RunGitOptions { | |
| 51 | timeoutMs?: number | undefined; | |
| 52 | allowNonZeroExit?: boolean | undefined; | |
| 53 | } | |
| 54 | ||
| 55 | interface ExecuteGitOptions { | |
| 56 | timeoutMs?: number | undefined; | |
| 57 | allowNonZeroExit?: boolean | undefined; | |
| 58 | fallbackErrorMessage?: string | undefined; | |
| 59 | } | |
| 60 | ||
| 61 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | |
| 62 | const STATUS_UPSTREAM_REFRESH_INTERVAL_MS = 15_000; | |
| 63 | const STATUS_UPSTREAM_REFRESH_TIMEOUT_MS = 5_000; | |
| 64 | ||
| 65 | /** Spawn git directly with an argv array — no shell, no quoting needed. */ | |
| 66 | function runGit( | |
| 67 | args: readonly string[], | |
| 68 | cwd: string, | |
| 69 | timeoutMs = 30_000, | |
| 70 | ): Promise<ProcessRunResult> { | |
| 71 | return runProcess("git", args, { | |
| 72 | cwd, | |
| 73 | timeoutMs, | |
| 74 | allowNonZeroExit: true, | |
| 75 | maxBufferBytes: DEFAULT_MAX_OUTPUT_BYTES, | |
| 76 | outputMode: "truncate", | |
| 77 | }); | |
| 78 | } | |
| 79 | ||
| 80 | function trimStdout(value: string): string { | |
| 81 | return value.trim(); | |
| 82 | } | |
| 83 | ||
| 84 | function parseBranchAb(value: string): { ahead: number; behind: number } { | |
| 85 | const match = value.match(/^\+(\d+)\s+-(\d+)$/); | |
| 86 | if (!match) return { ahead: 0, behind: 0 }; | |
| 87 | return { | |
| 88 | ahead: Number(match[1] ?? "0"), | |
| 89 | behind: Number(match[2] ?? "0"), | |
| 90 | }; | |
| 91 | } | |
| 92 | ||
| 93 | function parseNumstatEntries( | |
| 94 | stdout: string, | |
| 95 | ): Array<{ path: string; insertions: number; deletions: number }> { | |
| 96 | const entries: Array<{ path: string; insertions: number; deletions: number }> = []; | |
| 97 | for (const line of stdout.split(/\r?\n/g)) { | |
| 98 | if (line.trim().length === 0) continue; | |
| 99 | const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); | |
| 100 | const rawPath = | |
| 101 | pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); | |
| 102 | if (rawPath.length === 0) continue; | |
| 103 | const added = Number.parseInt(addedRaw ?? "0", 10); | |
| 104 | const deleted = Number.parseInt(deletedRaw ?? "0", 10); | |
| 105 | const renameArrowIndex = rawPath.indexOf(" => "); | |
| 106 | const normalizedPath = | |
| 107 | renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; | |
| 108 | entries.push({ | |
| 109 | path: normalizedPath.length > 0 ? normalizedPath : rawPath, | |
| 110 | insertions: Number.isFinite(added) ? added : 0, | |
| 111 | deletions: Number.isFinite(deleted) ? deleted : 0, | |
| 112 | }); | |
| 113 | } | |
| 114 | return entries; | |
| 115 | } | |
| 116 | ||
| 117 | function parsePorcelainPath(line: string): string | null { | |
| 118 | if (line.startsWith("? ") || line.startsWith("! ")) { | |
| 119 | const simple = line.slice(2).trim(); | |
| 120 | return simple.length > 0 ? simple : null; | |
| 121 | } | |
| 122 | ||
| 123 | if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { | |
| 124 | return null; | |
| 125 | } | |
| 126 | ||
| 127 | const tabIndex = line.indexOf("\t"); | |
| 128 | if (tabIndex >= 0) { | |
| 129 | const fromTab = line.slice(tabIndex + 1); | |
| 130 | const [path] = fromTab.split("\t"); | |
| 131 | return path?.trim().length ? path.trim() : null; | |
| 132 | } | |
| 133 | ||
| 134 | const parts = line.trim().split(/\s+/g); | |
| 135 | const path = parts.at(-1) ?? ""; | |
| 136 | return path.length > 0 ? path : null; | |
| 137 | } | |
| 138 | ||
| 139 | function commandLabel(args: readonly string[]): string { | |
| 140 | return `git ${args.join(" ")}`; | |
| 141 | } | |
| 142 | ||
| 143 | function normalizeGitSpawnError(cwd: string, error: unknown): Error { | |
| 144 | if (!(error instanceof Error)) { | |
| 145 | return new Error("Failed to run git command."); | |
| 146 | } | |
| 147 | if (!fs.existsSync(cwd)) { | |
| 148 | return new Error(`Working directory does not exist: ${cwd}`); | |
| 149 | } | |
| 150 | if (error.message.includes("Command not found: git")) { | |
| 151 | return new Error("Git is required but not available on PATH."); | |
| 152 | } | |
| 153 | return new Error(`Failed to run git command: ${error.message}`); | |
| 154 | } | |
| 155 | ||
| 156 | function normalizeGitExecutionError(args: readonly string[], result: ProcessRunResult): Error { | |
| 157 | const stderr = result.stderr.trim(); | |
| 158 | if (stderr.toLowerCase().includes("not a git repository")) { | |
| 159 | return new Error("Current folder is not a git repository."); | |
| 160 | } | |
| 161 | if (result.timedOut) { | |
| 162 | return new Error(`${commandLabel(args)} timed out.`); | |
| 163 | } | |
| 164 | const detail = | |
| 165 | stderr.length > 0 ? stderr : `code=${result.code ?? "null"}, signal=${result.signal ?? "null"}`; | |
| 166 | return new Error(`${commandLabel(args)} failed: ${detail}`); | |
| 167 | } | |
| 168 | ||
| 169 | async function runGitOrThrow( | |
| 170 | cwd: string, | |
| 171 | args: readonly string[], | |
| 172 | options: RunGitOptions = {}, | |
| 173 | ): Promise<ProcessRunResult> { | |
| 174 | let result: ProcessRunResult; | |
| 175 | try { | |
| 176 | result = await runGit(args, cwd, options.timeoutMs); | |
| 177 | } catch (error) { | |
| 178 | throw normalizeGitSpawnError(cwd, error); | |
| 179 | } | |
| 180 | ||
| 181 | if (result.timedOut) { | |
| 182 | throw normalizeGitExecutionError(args, result); | |
| 183 | } | |
| 184 | if (!options.allowNonZeroExit && result.code !== 0) { | |
| 185 | throw normalizeGitExecutionError(args, result); | |
| 186 | } | |
| 187 | return result; | |
| 188 | } | |
| 189 | ||
| 190 | async function executeGit( | |
| 191 | cwd: string, | |
| 192 | args: readonly string[], | |
| 193 | options: ExecuteGitOptions = {}, | |
| 194 | ): Promise<ProcessRunResult> { | |
| 195 | const result = await runGitOrThrow(cwd, args, { | |
| 196 | timeoutMs: options.timeoutMs, | |
| 197 | allowNonZeroExit: true, | |
| 198 | }); | |
| 199 | ||
| 200 | if (options.allowNonZeroExit || result.code === 0) { | |
| 201 | return result; | |
| 202 | } | |
| 203 | ||
| 204 | const stderr = result.stderr.trim(); | |
| 205 | if (stderr.length > 0) { | |
| 206 | throw new Error(stderr); | |
| 207 | } | |
| 208 | if (options.fallbackErrorMessage) { | |
| 209 | throw new Error(options.fallbackErrorMessage); | |
| 210 | } | |
| 211 | throw normalizeGitExecutionError(args, result); | |
| 212 | } | |
| 213 | ||
| 214 | export class GitCoreService { | |
| 215 | private readonly lastUpstreamRefreshAt = new Map<string, number>(); | |
| 216 | ||
| 217 | async status(input: GitStatusInput): Promise<GitStatusResult> { | |
| 218 | const details = await this.statusDetails(input.cwd); | |
| 219 | return { | |
| 220 | branch: details.branch, | |
| 221 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | |
| 222 | workingTree: details.workingTree, | |
| 223 | hasUpstream: details.hasUpstream, | |
| 224 | aheadCount: details.aheadCount, | |
| 225 | behindCount: details.behindCount, | |
| 226 | openPr: null, | |
| 227 | }; | |
| 228 | } | |
| 229 | ||
| 230 | async statusDetails(cwd: string): Promise<GitStatusDetails> { | |
| 231 | await this.refreshStatusUpstreamIfStale(cwd).catch(() => undefined); | |
| 232 | ||
| 233 | const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = await Promise.all([ | |
| 234 | this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]), | |
| 235 | this.gitStdout(cwd, ["diff", "--numstat"]), | |
| 236 | this.gitStdout(cwd, ["diff", "--cached", "--numstat"]), | |
| 237 | ]); | |
| 238 | ||
| 239 | let branch: string | null = null; | |
| 240 | let upstreamRef: string | null = null; | |
| 241 | let aheadCount = 0; | |
| 242 | let behindCount = 0; | |
| 243 | let hasWorkingTreeChanges = false; | |
| 244 | const changedFilesWithoutNumstat = new Set<string>(); | |
| 245 | ||
| 246 | for (const line of statusStdout.split(/\r?\n/g)) { | |
| 247 | if (line.startsWith("# branch.head ")) { | |
| 248 | const value = line.slice("# branch.head ".length).trim(); | |
| 249 | branch = value.startsWith("(") ? null : value; | |
| 250 | continue; | |
| 251 | } | |
| 252 | if (line.startsWith("# branch.upstream ")) { | |
| 253 | const value = line.slice("# branch.upstream ".length).trim(); | |
| 254 | upstreamRef = value.length > 0 ? value : null; | |
| 255 | continue; | |
| 256 | } | |
| 257 | if (line.startsWith("# branch.ab ")) { | |
| 258 | const value = line.slice("# branch.ab ".length).trim(); | |
| 259 | const parsed = parseBranchAb(value); | |
| 260 | aheadCount = parsed.ahead; | |
| 261 | behindCount = parsed.behind; | |
| 262 | continue; | |
| 263 | } | |
| 264 | if (line.trim().length > 0 && !line.startsWith("#")) { | |
| 265 | hasWorkingTreeChanges = true; | |
| 266 | const pathValue = parsePorcelainPath(line); | |
| 267 | if (pathValue) changedFilesWithoutNumstat.add(pathValue); | |
| 268 | } | |
| 269 | } | |
| 270 | const stagedEntries = parseNumstatEntries(stagedNumstatStdout); | |
| 271 | const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); | |
| 272 | const fileStatMap = new Map<string, { insertions: number; deletions: number }>(); | |
| 273 | for (const entry of [...stagedEntries, ...unstagedEntries]) { | |
| 274 | const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; | |
| 275 | existing.insertions += entry.insertions; | |
| 276 | existing.deletions += entry.deletions; | |
| 277 | fileStatMap.set(entry.path, existing); | |
| 278 | } | |
| 279 | ||
| 280 | let insertions = 0; | |
| 281 | let deletions = 0; | |
| 282 | const files = Array.from(fileStatMap.entries()) | |
| 283 | .map(([path, stat]) => { | |
| 284 | insertions += stat.insertions; | |
| 285 | deletions += stat.deletions; | |
| 286 | return { path, insertions: stat.insertions, deletions: stat.deletions }; | |
| 287 | }) | |
| 288 | .toSorted((a, b) => a.path.localeCompare(b.path)); | |
| 289 | ||
| 290 | for (const filePath of changedFilesWithoutNumstat) { | |
| 291 | if (fileStatMap.has(filePath)) continue; | |
| 292 | files.push({ path: filePath, insertions: 0, deletions: 0 }); | |
| 293 | } | |
| 294 | files.sort((a, b) => a.path.localeCompare(b.path)); | |
| 295 | ||
| 296 | return { | |
| 297 | branch, | |
| 298 | upstreamRef, | |
| 299 | hasWorkingTreeChanges, | |
| 300 | workingTree: { | |
| 301 | files, | |
| 302 | insertions, | |
| 303 | deletions, | |
| 304 | }, | |
| 305 | hasUpstream: upstreamRef !== null, | |
| 306 | aheadCount, | |
| 307 | behindCount, | |
| 308 | }; | |
| 309 | } | |
| 310 | ||
| 311 | async prepareCommitContext(cwd: string): Promise<GitPreparedCommitContext | null> { | |
| 312 | await this.git(cwd, ["add", "-A"]); | |
| 313 | ||
| 314 | const stagedSummary = await this.gitStdout(cwd, ["diff", "--cached", "--name-status"]); | |
| 315 | if (trimStdout(stagedSummary).length === 0) { | |
| 316 | return null; | |
| 317 | } | |
| 318 | ||
| 319 | const stagedPatch = await this.gitStdout(cwd, ["diff", "--cached", "--patch", "--minimal"]); | |
| 320 | ||
| 321 | return { | |
| 322 | stagedSummary, | |
| 323 | stagedPatch, | |
| 324 | }; | |
| 325 | } | |
| 326 | ||
| 327 | async commit(cwd: string, subject: string, body: string): Promise<{ commitSha: string }> { | |
| 328 | const args = ["commit", "-m", subject]; | |
| 329 | const trimmedBody = body.trim(); | |
| 330 | if (trimmedBody.length > 0) { | |
| 331 | args.push("-m", trimmedBody); | |
| 332 | } | |
| 333 | await this.git(cwd, args); | |
| 334 | const commitSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"])); | |
| 335 | return { commitSha }; | |
| 336 | } | |
| 337 | ||
| 338 | async pushCurrentBranch(cwd: string, fallbackBranch: string | null): Promise<GitPushResult> { | |
| 339 | const details = await this.statusDetails(cwd); | |
| 340 | const branch = details.branch ?? fallbackBranch; | |
| 341 | if (!branch) { | |
| 342 | throw new Error("Cannot push from detached HEAD."); | |
| 343 | } | |
| 344 | ||
| 345 | if (details.hasUpstream && details.aheadCount === 0 && details.behindCount === 0) { | |
| 346 | return { | |
| 347 | status: "skipped_up_to_date", | |
| 348 | branch, | |
| 349 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | |
| 350 | }; | |
| 351 | } | |
| 352 | ||
| 353 | if (!details.hasUpstream) { | |
| 354 | await this.git(cwd, ["push", "-u", "origin", branch]); | |
| 355 | return { | |
| 356 | status: "pushed", | |
| 357 | branch, | |
| 358 | upstreamBranch: `origin/${branch}`, | |
| 359 | setUpstream: true, | |
| 360 | }; | |
| 361 | } | |
| 362 | ||
| 363 | await this.git(cwd, ["push"]); | |
| 364 | return { | |
| 365 | status: "pushed", | |
| 366 | branch, | |
| 367 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | |
| 368 | setUpstream: false, | |
| 369 | }; | |
| 370 | } | |
| 371 | ||
| 372 | async pullCurrentBranch(cwd: string): Promise<GitPullResult> { | |
| 373 | const details = await this.statusDetails(cwd); | |
| 374 | const branch = details.branch; | |
| 375 | if (!branch) { | |
| 376 | throw new Error("Cannot pull from detached HEAD."); | |
| 377 | } | |
| 378 | if (!details.hasUpstream) { | |
| 379 | throw new Error("Current branch has no upstream configured. Push with upstream first."); | |
| 380 | } | |
| 381 | const beforeSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); | |
| 382 | await executeGit(cwd, ["pull", "--ff-only"], { | |
| 383 | timeoutMs: 30_000, | |
| 384 | fallbackErrorMessage: "git pull failed", | |
| 385 | }); | |
| 386 | const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); | |
| 387 | const refreshed = await this.statusDetails(cwd); | |
| 388 | return { | |
| 389 | status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", | |
| 390 | branch, | |
| 391 | upstreamBranch: refreshed.upstreamRef, | |
| 392 | }; | |
| 393 | } | |
| 394 | ||
| 395 | async readRangeContext(cwd: string, baseBranch: string): Promise<GitRangeContext> { | |
| 396 | const range = `${baseBranch}..HEAD`; | |
| 397 | const [commitSummary, diffSummary, diffPatch] = await Promise.all([ | |
| 398 | this.gitStdout(cwd, ["log", "--oneline", range]), | |
| 399 | this.gitStdout(cwd, ["diff", "--stat", range]), | |
| 400 | this.gitStdout(cwd, ["diff", "--patch", "--minimal", range]), | |
| 401 | ]); | |
| 402 | ||
| 403 | return { | |
| 404 | commitSummary, | |
| 405 | diffSummary, | |
| 406 | diffPatch, | |
| 407 | }; | |
| 408 | } | |
| 409 | ||
| 410 | async readConfigValue(cwd: string, key: string): Promise<string | null> { | |
| 411 | const stdout = await this.gitStdout(cwd, ["config", "--get", key], true); | |
| 412 | const value = trimStdout(stdout); | |
| 413 | return value.length > 0 ? value : null; | |
| 414 | } | |
| 415 | ||
| 416 | private async readBranchRecency(cwd: string): Promise<Map<string, number>> { | |
| 417 | const branchRecency = await executeGit( | |
| 418 | cwd, | |
| 419 | ["for-each-ref", "--format=%(refname:short)%09%(committerdate:unix)", "refs/heads"], | |
| 420 | { | |
| 421 | timeoutMs: 15_000, | |
| 422 | allowNonZeroExit: true, | |
| 423 | }, | |
| 424 | ); | |
| 425 | ||
| 426 | const branchLastCommit = new Map<string, number>(); | |
| 427 | if (branchRecency.code !== 0) { | |
| 428 | return branchLastCommit; | |
| 429 | } | |
| 430 | ||
| 431 | for (const line of branchRecency.stdout.split("\n")) { | |
| 432 | if (line.length === 0) { | |
| 433 | continue; | |
| 434 | } | |
| 435 | const [name, lastCommitRaw] = line.split("\t"); | |
| 436 | if (!name) { | |
| 437 | continue; | |
| 438 | } | |
| 439 | const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); | |
| 440 | branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); | |
| 441 | } | |
| 442 | ||
| 443 | return branchLastCommit; | |
| 444 | } | |
| 445 | ||
| 446 | async listBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> { | |
| 447 | const branchRecencyPromise = this.readBranchRecency(input.cwd).catch( | |
| 448 | () => new Map<string, number>(), | |
| 449 | ); | |
| 450 | const result = await executeGit(input.cwd, ["branch", "--no-color"], { | |
| 451 | timeoutMs: 10_000, | |
| 452 | allowNonZeroExit: true, | |
| 453 | }); | |
| 454 | ||
| 455 | if (result.code !== 0) { | |
| 456 | const stderr = result.stderr.trim(); | |
| 457 | if (stderr.toLowerCase().includes("not a git repository")) { | |
| 458 | return { branches: [], isRepo: false }; | |
| 459 | } | |
| 460 | throw new Error(stderr || "git branch failed"); | |
| 461 | } | |
| 462 | ||
| 463 | const [defaultRef, worktreeList, branchLastCommit] = await Promise.all([ | |
| 464 | executeGit(input.cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], { | |
| 465 | timeoutMs: 5_000, | |
| 466 | allowNonZeroExit: true, | |
| 467 | }), | |
| 468 | executeGit(input.cwd, ["worktree", "list", "--porcelain"], { | |
| 469 | timeoutMs: 5_000, | |
| 470 | allowNonZeroExit: true, | |
| 471 | }), | |
| 472 | branchRecencyPromise, | |
| 473 | ]); | |
| 474 | const defaultBranch = | |
| 475 | defaultRef.code === 0 | |
| 476 | ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") | |
| 477 | : null; | |
| 478 | ||
| 479 | const worktreeMap = new Map<string, string>(); | |
| 480 | if (worktreeList.code === 0) { | |
| 481 | let currentPath: string | null = null; | |
| 482 | for (const line of worktreeList.stdout.split("\n")) { | |
| 483 | if (line.startsWith("worktree ")) { | |
| 484 | const candidatePath = line.slice("worktree ".length); | |
| 485 | currentPath = fs.existsSync(candidatePath) ? candidatePath : null; | |
| 486 | } else if (line.startsWith("branch refs/heads/") && currentPath) { | |
| 487 | worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); | |
| 488 | } else if (line === "") { | |
| 489 | currentPath = null; | |
| 490 | } | |
| 491 | } | |
| 492 | } | |
| 493 | ||
| 494 | const branches = result.stdout | |
| 495 | .split("\n") | |
| 496 | .map((line) => line.trim()) | |
| 497 | .filter((line) => line.length > 0) | |
| 498 | .map((line) => { | |
| 499 | const name = line.replace(/^[*+]\s+/, ""); | |
| 500 | return { | |
| 501 | name, | |
| 502 | current: line.startsWith("* "), | |
| 503 | isDefault: name === defaultBranch, | |
| 504 | worktreePath: worktreeMap.get(name) ?? null, | |
| 505 | }; | |
| 506 | }) | |
| 507 | .toSorted((a, b) => { | |
| 508 | const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; | |
| 509 | const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; | |
| 510 | if (aPriority !== bPriority) return aPriority - bPriority; | |
| 511 | ||
| 512 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; | |
| 513 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; | |
| 514 | if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; | |
| 515 | return a.name.localeCompare(b.name); | |
| 516 | }); | |
| 517 | ||
| 518 | return { branches, isRepo: true }; | |
| 519 | } | |
| 520 | ||
| 521 | async createWorktree(input: GitCreateWorktreeInput): Promise<GitCreateWorktreeResult> { | |
| 522 | const sanitizedBranch = input.newBranch.replace(/\//g, "-"); | |
| 523 | const repoName = path.basename(input.cwd); | |
| 524 | const worktreePath = | |
| 525 | input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch); | |
| 526 | ||
| 527 | await executeGit( | |
| 528 | input.cwd, | |
| 529 | ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch], | |
| 530 | { fallbackErrorMessage: "git worktree add failed" }, | |
| 531 | ); | |
| 532 | ||
| 533 | return { | |
| 534 | worktree: { | |
| 535 | path: worktreePath, | |
| 536 | branch: input.newBranch, | |
| 537 | }, | |
| 538 | }; | |
| 539 | } | |
| 540 | ||
| 541 | async removeWorktree(input: GitRemoveWorktreeInput): Promise<void> { | |
| 542 | const args = ["worktree", "remove"]; | |
| 543 | if (input.force) { | |
| 544 | args.push("--force"); | |
| 545 | } | |
| 546 | args.push(input.path); | |
| 547 | try { | |
| 548 | await executeGit(input.cwd, args, { | |
| 549 | timeoutMs: 15_000, | |
| 550 | fallbackErrorMessage: "git worktree remove failed", | |
| 551 | }); | |
| 552 | } catch (error) { | |
| 553 | const detail = error instanceof Error ? error.message : String(error); | |
| 554 | throw new Error(`${commandLabel(args)} failed (cwd: ${input.cwd}): ${detail}`, { | |
| 555 | cause: error, | |
| 556 | }); | |
| 557 | } | |
| 558 | } | |
| 559 | ||
| 560 | async createBranch(input: GitCreateBranchInput): Promise<void> { | |
| 561 | await executeGit(input.cwd, ["branch", input.branch], { | |
| 562 | timeoutMs: 10_000, | |
| 563 | fallbackErrorMessage: "git branch create failed", | |
| 564 | }); | |
| 565 | } | |
| 566 | ||
| 567 | private async resolveCurrentUpstream( | |
| 568 | cwd: string, | |
| 569 | ): Promise<{ upstreamRef: string; remoteName: string; upstreamBranch: string } | null> { | |
| 570 | const upstreamRef = trimStdout( | |
| 571 | await this.gitStdout( | |
| 572 | cwd, | |
| 573 | ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], | |
| 574 | true, | |
| 575 | ), | |
| 576 | ); | |
| 577 | if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { | |
| 578 | return null; | |
| 579 | } | |
| 580 | ||
| 581 | const separatorIndex = upstreamRef.indexOf("/"); | |
| 582 | if (separatorIndex <= 0) { | |
| 583 | return null; | |
| 584 | } | |
| 585 | const remoteName = upstreamRef.slice(0, separatorIndex); | |
| 586 | const upstreamBranch = upstreamRef.slice(separatorIndex + 1); | |
| 587 | if (remoteName.length === 0) { | |
| 588 | return null; | |
| 589 | } | |
| 590 | if (upstreamBranch.length === 0) { | |
| 591 | return null; | |
| 592 | } | |
| 593 | ||
| 594 | return { | |
| 595 | upstreamRef, | |
| 596 | remoteName, | |
| 597 | upstreamBranch, | |
| 598 | }; | |
| 599 | } | |
| 600 | ||
| 601 | private async fetchUpstreamRef( | |
| 602 | cwd: string, | |
| 603 | upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, | |
| 604 | ): Promise<void> { | |
| 605 | const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; | |
| 606 | await this.git(cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], true); | |
| 607 | } | |
| 608 | ||
| 609 | private async fetchUpstreamRefForStatus( | |
| 610 | cwd: string, | |
| 611 | upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, | |
| 612 | ): Promise<void> { | |
| 613 | const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; | |
| 614 | await executeGit(cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], { | |
| 615 | allowNonZeroExit: true, | |
| 616 | timeoutMs: STATUS_UPSTREAM_REFRESH_TIMEOUT_MS, | |
| 617 | }); | |
| 618 | } | |
| 619 | ||
| 620 | private async refreshStatusUpstreamIfStale(cwd: string): Promise<void> { | |
| 621 | const upstream = await this.resolveCurrentUpstream(cwd); | |
| 622 | if (!upstream) { | |
| 623 | return; | |
| 624 | } | |
| 625 | ||
| 626 | const cacheKey = `${cwd}\u0000${upstream.upstreamRef}`; | |
| 627 | const now = Date.now(); | |
| 628 | const lastRefreshAt = this.lastUpstreamRefreshAt.get(cacheKey) ?? 0; | |
| 629 | if (now - lastRefreshAt < STATUS_UPSTREAM_REFRESH_INTERVAL_MS) { | |
| 630 | return; | |
| 631 | } | |
| 632 | ||
| 633 | // Record attempt time first to avoid repeated fetch spikes when status is polled often. | |
| 634 | this.lastUpstreamRefreshAt.set(cacheKey, now); | |
| 635 | await this.fetchUpstreamRefForStatus(cwd, upstream); | |
| 636 | } | |
| 637 | ||
| 638 | private async refreshCheckedOutBranchUpstream(cwd: string): Promise<void> { | |
| 639 | const upstream = await this.resolveCurrentUpstream(cwd); | |
| 640 | if (!upstream) { | |
| 641 | return; | |
| 642 | } | |
| 643 | await this.fetchUpstreamRef(cwd, upstream); | |
| 644 | } | |
| 645 | ||
| 646 | async checkoutBranch(input: GitCheckoutInput): Promise<void> { | |
| 647 | await executeGit(input.cwd, ["checkout", input.branch], { | |
| 648 | timeoutMs: 10_000, | |
| 649 | fallbackErrorMessage: "git checkout failed", | |
| 650 | }); | |
| 651 | // Refresh upstream refs in the background so checkout remains responsive. | |
| 652 | void this.refreshCheckedOutBranchUpstream(input.cwd).catch(() => undefined); | |
| 653 | } | |
| 654 | ||
| 655 | async initRepo(input: GitInitInput): Promise<void> { | |
| 656 | await executeGit(input.cwd, ["init"], { | |
| 657 | timeoutMs: 10_000, | |
| 658 | fallbackErrorMessage: "git init failed", | |
| 659 | }); | |
| 660 | } | |
| 661 | ||
| 662 | async git(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise<void> { | |
| 663 | await executeGit(cwd, args, { allowNonZeroExit }); | |
| 664 | } | |
| 665 | ||
| 666 | async gitStdout(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise<string> { | |
| 667 | const result = await executeGit(cwd, args, { allowNonZeroExit }); | |
| 668 | return result.stdout; | |
| 669 | } | |
| 670 | } | |
| 671 | ||
| 672 | const defaultGitCoreService = new GitCoreService(); | |
| 673 | ||
| 674 | export async function runTerminalCommand(input: TerminalCommandInput): Promise<ProcessRunResult> { | |
| 675 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | |
| 676 | const shellPath = | |
| 677 | process.platform === "win32" | |
| 678 | ? (process.env.ComSpec ?? "cmd.exe") | |
| 679 | : (process.env.SHELL ?? "/bin/sh"); | |
| 680 | ||
| 681 | const args = | |
| 682 | process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command]; | |
| 683 | ||
| 684 | const result = await runProcess(shellPath, args, { | |
| 685 | cwd: input.cwd, | |
| 686 | timeoutMs: input.timeoutMs ?? 30_000, | |
| 687 | allowNonZeroExit: true, | |
| 688 | maxBufferBytes: maxOutputBytes, | |
| 689 | outputMode: "truncate", | |
| 690 | }); | |
| 691 | ||
| 692 | if (!result.stdoutTruncated && !result.stderrTruncated) { | |
| 693 | return result; | |
| 694 | } | |
| 695 | ||
| 696 | return { | |
| 697 | ...result, | |
| 698 | stderr: `${result.stderr}\n[output truncated at ${maxOutputBytes} bytes]`, | |
| 699 | }; | |
| 700 | } | |
| 701 | ||
| 702 | export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> { | |
| 703 | return defaultGitCoreService.listBranches(input); | |
| 704 | } | |
| 705 | ||
| 706 | export async function pullGitBranch(input: GitPullInput): Promise<GitPullResult> { | |
| 707 | return defaultGitCoreService.pullCurrentBranch(input.cwd); | |
| 708 | } | |
| 709 | ||
| 710 | export async function createGitWorktree( | |
| 711 | input: GitCreateWorktreeInput, | |
| 712 | ): Promise<GitCreateWorktreeResult> { | |
| 713 | return defaultGitCoreService.createWorktree(input); | |
| 714 | } | |
| 715 | ||
| 716 | export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> { | |
| 717 | await defaultGitCoreService.removeWorktree(input); | |
| 718 | } | |
| 719 | ||
| 720 | export async function createGitBranch(input: GitCreateBranchInput): Promise<void> { | |
| 721 | await defaultGitCoreService.createBranch(input); | |
| 722 | } | |
| 723 | ||
| 724 | export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> { | |
| 725 | await defaultGitCoreService.checkoutBranch(input); | |
| 726 | } | |
| 727 | ||
| 728 | export async function initGitRepo(input: GitInitInput): Promise<void> { | |
| 729 | await defaultGitCoreService.initRepo(input); | |
| 730 | } |
apps/server/src/git/Errors.ts+66| ... | ... | @@ -14,3 +14,69 @@ export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()( |
| 14 | 14 | return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; |
| 15 | 15 | } |
| 16 | 16 | } |
| 17 | ||
| 18 | /** | |
| 19 | * GitHubCliError - GitHub CLI execution or authentication failed. | |
| 20 | */ | |
| 21 | export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", { | |
| 22 | operation: Schema.String, | |
| 23 | detail: Schema.String, | |
| 24 | cause: Schema.optional(Schema.Defect), | |
| 25 | }) { | |
| 26 | override get message(): string { | |
| 27 | return `GitHub CLI failed in ${this.operation}: ${this.detail}`; | |
| 28 | } | |
| 29 | } | |
| 30 | ||
| 31 | /** | |
| 32 | * TextGenerationError - Commit or PR text generation failed. | |
| 33 | */ | |
| 34 | export class TextGenerationError extends Schema.TaggedErrorClass<TextGenerationError>()( | |
| 35 | "TextGenerationError", | |
| 36 | { | |
| 37 | operation: Schema.String, | |
| 38 | detail: Schema.String, | |
| 39 | cause: Schema.optional(Schema.Defect), | |
| 40 | }, | |
| 41 | ) { | |
| 42 | override get message(): string { | |
| 43 | return `Text generation failed in ${this.operation}: ${this.detail}`; | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * GitManagerError - Stacked Git workflow orchestration failed. | |
| 49 | */ | |
| 50 | export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()("GitManagerError", { | |
| 51 | operation: Schema.String, | |
| 52 | detail: Schema.String, | |
| 53 | cause: Schema.optional(Schema.Defect), | |
| 54 | }) { | |
| 55 | override get message(): string { | |
| 56 | return `Git manager failed in ${this.operation}: ${this.detail}`; | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * GitCoreError - Errors emitted by low-level Git repository operations. | |
| 62 | */ | |
| 63 | export type GitCoreError = GitCommandError; | |
| 64 | ||
| 65 | /** | |
| 66 | * GitHubCliServiceError - Errors emitted by GitHub CLI service helpers. | |
| 67 | */ | |
| 68 | export type GitHubCliServiceError = GitHubCliError; | |
| 69 | ||
| 70 | /** | |
| 71 | * TextGenerationServiceError - Errors emitted by commit/PR text generation. | |
| 72 | */ | |
| 73 | export type TextGenerationServiceError = TextGenerationError; | |
| 74 | ||
| 75 | /** | |
| 76 | * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration. | |
| 77 | */ | |
| 78 | export type GitManagerServiceError = | |
| 79 | | GitManagerError | |
| 80 | | GitCoreError | |
| 81 | | GitHubCliServiceError | |
| 82 | | TextGenerationServiceError; |
apps/server/src/git/Layer.test.ts deleted-59| ... | ... | @@ -1,59 +0,0 @@ |
| 1 | import { NodeServices } from "@effect/platform-node"; | |
| 2 | import { it, assert } from "@effect/vitest"; | |
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import { GitCommandError } from "./Errors.ts"; | |
| 6 | import { GitServiceLive } from "./Layer.ts"; | |
| 7 | import { GitService } from "./Service.ts"; | |
| 8 | ||
| 9 | const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer)); | |
| 10 | ||
| 11 | layer("GitServiceLive", (it) => { | |
| 12 | it.effect("runGit executes successful git commands", () => | |
| 13 | Effect.gen(function* () { | |
| 14 | const gitService = yield* GitService; | |
| 15 | const result = yield* gitService.execute({ | |
| 16 | operation: "GitProcess.test.version", | |
| 17 | cwd: process.cwd(), | |
| 18 | args: ["--version"], | |
| 19 | }); | |
| 20 | ||
| 21 | assert.equal(result.code, 0); | |
| 22 | assert.ok(result.stdout.toLowerCase().includes("git version")); | |
| 23 | }), | |
| 24 | ); | |
| 25 | ||
| 26 | it.effect("runGit can return non-zero exit codes when allowed", () => | |
| 27 | Effect.gen(function* () { | |
| 28 | const gitService = yield* GitService; | |
| 29 | const result = yield* gitService.execute({ | |
| 30 | operation: "GitProcess.test.allowNonZero", | |
| 31 | cwd: process.cwd(), | |
| 32 | args: ["rev-parse", "--verify", "__definitely_missing_ref__"], | |
| 33 | allowNonZeroExit: true, | |
| 34 | }); | |
| 35 | ||
| 36 | assert.notEqual(result.code, 0); | |
| 37 | }), | |
| 38 | ); | |
| 39 | ||
| 40 | it.effect("runGit fails with GitCommandError when non-zero exits are not allowed", () => | |
| 41 | Effect.gen(function* () { | |
| 42 | const gitService = yield* GitService; | |
| 43 | const result = yield* Effect.result( | |
| 44 | gitService.execute({ | |
| 45 | operation: "GitProcess.test.failOnNonZero", | |
| 46 | cwd: process.cwd(), | |
| 47 | args: ["rev-parse", "--verify", "__definitely_missing_ref__"], | |
| 48 | }), | |
| 49 | ); | |
| 50 | ||
| 51 | assert.equal(result._tag, "Failure"); | |
| 52 | if (result._tag === "Failure") { | |
| 53 | assert.ok(result.failure instanceof GitCommandError); | |
| 54 | assert.equal(result.failure.operation, "GitProcess.test.failOnNonZero"); | |
| 55 | assert.equal(result.failure.command, "git rev-parse --verify __definitely_missing_ref__"); | |
| 56 | } | |
| 57 | }), | |
| 58 | ); | |
| 59 | }); |
apps/server/src/git/Layer.ts deleted-141| ... | ... | @@ -1,141 +0,0 @@ |
| 1 | /** | |
| 2 | * Git process helpers - Effect-native git execution with typed errors. | |
| 3 | * | |
| 4 | * Centralizes child-process git invocation for server modules. This module | |
| 5 | * only executes git commands and reports structured failures. | |
| 6 | * | |
| 7 | * @module GitServiceLive | |
| 8 | */ | |
| 9 | import { Effect, Layer, Option, Stream } from "effect"; | |
| 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 11 | import { GitCommandError } from "./Errors.ts"; | |
| 12 | import { ExecuteGitInput, ExecuteGitResult, GitService, GitServiceShape } from "./Service.ts"; | |
| 13 | ||
| 14 | const DEFAULT_TIMEOUT_MS = 30_000; | |
| 15 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | |
| 16 | ||
| 17 | function quoteGitCommand(args: ReadonlyArray<string>): string { | |
| 18 | return `git ${args.join(" ")}`; | |
| 19 | } | |
| 20 | ||
| 21 | function toGitCommandError( | |
| 22 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | |
| 23 | detail: string, | |
| 24 | ) { | |
| 25 | return (cause: unknown) => | |
| 26 | cause instanceof GitCommandError | |
| 27 | ? cause | |
| 28 | : new GitCommandError({ | |
| 29 | operation: input.operation, | |
| 30 | command: quoteGitCommand(input.args), | |
| 31 | cwd: input.cwd, | |
| 32 | detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, | |
| 33 | ...(cause !== undefined ? { cause } : {}), | |
| 34 | }); | |
| 35 | } | |
| 36 | ||
| 37 | const collectOutput = Effect.fn(function* ( | |
| 38 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | |
| 39 | stream: Stream.Stream<Uint8Array, unknown>, | |
| 40 | maxOutputBytes: number, | |
| 41 | ): Effect.fn.Return<string, GitCommandError> { | |
| 42 | const decoder = new TextDecoder(); | |
| 43 | let bytes = 0; | |
| 44 | let text = ""; | |
| 45 | ||
| 46 | yield* Stream.runForEach(stream, (chunk) => | |
| 47 | Effect.gen(function* () { | |
| 48 | bytes += chunk.byteLength; | |
| 49 | if (bytes > maxOutputBytes) { | |
| 50 | return yield* new GitCommandError({ | |
| 51 | operation: input.operation, | |
| 52 | command: quoteGitCommand(input.args), | |
| 53 | cwd: input.cwd, | |
| 54 | detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, | |
| 55 | }); | |
| 56 | } | |
| 57 | text += decoder.decode(chunk, { stream: true }); | |
| 58 | }), | |
| 59 | ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); | |
| 60 | ||
| 61 | text += decoder.decode(); | |
| 62 | return text; | |
| 63 | }); | |
| 64 | ||
| 65 | const makeGitService = Effect.gen(function* () { | |
| 66 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 67 | ||
| 68 | const execute: GitServiceShape["execute"] = Effect.fnUntraced(function* (input) { | |
| 69 | const normalizedCwd = input.cwd.trim(); | |
| 70 | const commandInput = { | |
| 71 | ...input, | |
| 72 | cwd: normalizedCwd, | |
| 73 | args: [...input.args], | |
| 74 | } as const; | |
| 75 | const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; | |
| 76 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | |
| 77 | ||
| 78 | const commandEffect = Effect.gen(function* () { | |
| 79 | const child = yield* commandSpawner | |
| 80 | .spawn( | |
| 81 | ChildProcess.make("git", commandInput.args, { | |
| 82 | cwd: commandInput.cwd, | |
| 83 | ...(input.env ? { env: input.env } : {}), | |
| 84 | }), | |
| 85 | ) | |
| 86 | .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); | |
| 87 | ||
| 88 | const [stdout, stderr, exitCode] = yield* Effect.all( | |
| 89 | [ | |
| 90 | collectOutput(commandInput, child.stdout, maxOutputBytes), | |
| 91 | collectOutput(commandInput, child.stderr, maxOutputBytes), | |
| 92 | child.exitCode.pipe( | |
| 93 | Effect.map((value) => Number(value)), | |
| 94 | Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), | |
| 95 | ), | |
| 96 | ], | |
| 97 | { concurrency: "unbounded" }, | |
| 98 | ); | |
| 99 | ||
| 100 | if (!input.allowNonZeroExit && exitCode !== 0) { | |
| 101 | const trimmedStderr = stderr.trim(); | |
| 102 | return yield* new GitCommandError({ | |
| 103 | operation: commandInput.operation, | |
| 104 | command: quoteGitCommand(commandInput.args), | |
| 105 | cwd: commandInput.cwd, | |
| 106 | detail: | |
| 107 | trimmedStderr.length > 0 | |
| 108 | ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` | |
| 109 | : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, | |
| 110 | }); | |
| 111 | } | |
| 112 | ||
| 113 | return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult; | |
| 114 | }); | |
| 115 | ||
| 116 | return yield* commandEffect.pipe( | |
| 117 | Effect.scoped, | |
| 118 | Effect.timeoutOption(timeoutMs), | |
| 119 | Effect.flatMap((result) => | |
| 120 | Option.match(result, { | |
| 121 | onNone: () => | |
| 122 | Effect.fail( | |
| 123 | new GitCommandError({ | |
| 124 | operation: commandInput.operation, | |
| 125 | command: quoteGitCommand(commandInput.args), | |
| 126 | cwd: commandInput.cwd, | |
| 127 | detail: `${quoteGitCommand(commandInput.args)} timed out.`, | |
| 128 | }), | |
| 129 | ), | |
| 130 | onSome: Effect.succeed, | |
| 131 | }), | |
| 132 | ), | |
| 133 | ); | |
| 134 | }); | |
| 135 | ||
| 136 | return { | |
| 137 | execute, | |
| 138 | } satisfies GitServiceShape; | |
| 139 | }); | |
| 140 | ||
| 141 | export const GitServiceLive = Layer.effect(GitService, makeGitService); |
apps/server/src/git/Layers/CodexTextGeneration.test.ts created+205| ... | ... | @@ -0,0 +1,205 @@ |
| 1 | import { NodeServices } from "@effect/platform-node"; | |
| 2 | import { it } from "@effect/vitest"; | |
| 3 | import { Effect, FileSystem, Layer, Path } from "effect"; | |
| 4 | import { expect } from "vitest"; | |
| 5 | ||
| 6 | import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; | |
| 7 | import { TextGenerationError } from "../Errors.ts"; | |
| 8 | import { TextGeneration } from "../Services/TextGeneration.ts"; | |
| 9 | ||
| 10 | function makeFakeCodexBinary(dir: string) { | |
| 11 | return Effect.gen(function* () { | |
| 12 | const fs = yield* FileSystem.FileSystem; | |
| 13 | const path = yield* Path.Path; | |
| 14 | const binDir = path.join(dir, "bin"); | |
| 15 | const codexPath = path.join(binDir, "codex"); | |
| 16 | yield* fs.makeDirectory(binDir, { recursive: true }); | |
| 17 | ||
| 18 | yield* fs.writeFileString( | |
| 19 | codexPath, | |
| 20 | [ | |
| 21 | "#!/bin/sh", | |
| 22 | 'output_path=""', | |
| 23 | "while [ $# -gt 0 ]; do", | |
| 24 | ' if [ "$1" = "--output-last-message" ]; then', | |
| 25 | " shift", | |
| 26 | ' output_path="$1"', | |
| 27 | " fi", | |
| 28 | " shift", | |
| 29 | "done", | |
| 30 | "cat >/dev/null", | |
| 31 | 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then', | |
| 32 | ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2', | |
| 33 | "fi", | |
| 34 | 'if [ -n "$output_path" ]; then', | |
| 35 | ' 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=}"', | |
| 36 | "fi", | |
| 37 | 'exit "${T3_FAKE_CODEX_EXIT_CODE:-0}"', | |
| 38 | "", | |
| 39 | ].join("\n"), | |
| 40 | ); | |
| 41 | yield* fs.chmod(codexPath, 0o755); | |
| 42 | return binDir; | |
| 43 | }); | |
| 44 | } | |
| 45 | ||
| 46 | function withFakeCodexEnv<A, E, R>( | |
| 47 | input: { | |
| 48 | output: string; | |
| 49 | exitCode?: number; | |
| 50 | stderr?: string; | |
| 51 | }, | |
| 52 | effect: Effect.Effect<A, E, R>, | |
| 53 | ) { | |
| 54 | return Effect.acquireUseRelease( | |
| 55 | Effect.gen(function* () { | |
| 56 | const fs = yield* FileSystem.FileSystem; | |
| 57 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); | |
| 58 | const binDir = yield* makeFakeCodexBinary(tempDir); | |
| 59 | const previousPath = process.env.PATH; | |
| 60 | const previousOutput = process.env.T3_FAKE_CODEX_OUTPUT_B64; | |
| 61 | const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE; | |
| 62 | const previousStderr = process.env.T3_FAKE_CODEX_STDERR; | |
| 63 | ||
| 64 | yield* Effect.sync(() => { | |
| 65 | process.env.PATH = `${binDir}:${previousPath ?? ""}`; | |
| 66 | process.env.T3_FAKE_CODEX_OUTPUT_B64 = Buffer.from(input.output, "utf8").toString("base64"); | |
| 67 | ||
| 68 | if (input.exitCode !== undefined) { | |
| 69 | process.env.T3_FAKE_CODEX_EXIT_CODE = String(input.exitCode); | |
| 70 | } else { | |
| 71 | delete process.env.T3_FAKE_CODEX_EXIT_CODE; | |
| 72 | } | |
| 73 | ||
| 74 | if (input.stderr !== undefined) { | |
| 75 | process.env.T3_FAKE_CODEX_STDERR = input.stderr; | |
| 76 | } else { | |
| 77 | delete process.env.T3_FAKE_CODEX_STDERR; | |
| 78 | } | |
| 79 | }); | |
| 80 | ||
| 81 | return { | |
| 82 | previousPath, | |
| 83 | previousOutput, | |
| 84 | previousExitCode, | |
| 85 | previousStderr, | |
| 86 | }; | |
| 87 | }), | |
| 88 | () => effect, | |
| 89 | (previous) => | |
| 90 | Effect.sync(() => { | |
| 91 | process.env.PATH = previous.previousPath; | |
| 92 | ||
| 93 | if (previous.previousOutput === undefined) { | |
| 94 | delete process.env.T3_FAKE_CODEX_OUTPUT_B64; | |
| 95 | } else { | |
| 96 | process.env.T3_FAKE_CODEX_OUTPUT_B64 = previous.previousOutput; | |
| 97 | } | |
| 98 | ||
| 99 | if (previous.previousExitCode === undefined) { | |
| 100 | delete process.env.T3_FAKE_CODEX_EXIT_CODE; | |
| 101 | } else { | |
| 102 | process.env.T3_FAKE_CODEX_EXIT_CODE = previous.previousExitCode; | |
| 103 | } | |
| 104 | ||
| 105 | if (previous.previousStderr === undefined) { | |
| 106 | delete process.env.T3_FAKE_CODEX_STDERR; | |
| 107 | } else { | |
| 108 | process.env.T3_FAKE_CODEX_STDERR = previous.previousStderr; | |
| 109 | } | |
| 110 | }), | |
| 111 | ); | |
| 112 | } | |
| 113 | ||
| 114 | const CodexTextGenerationTestLayer = Layer.provideMerge( | |
| 115 | CodexTextGenerationLive, | |
| 116 | NodeServices.layer, | |
| 117 | ); | |
| 118 | ||
| 119 | it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | |
| 120 | it.effect("generates and sanitizes commit messages", () => | |
| 121 | withFakeCodexEnv( | |
| 122 | { | |
| 123 | output: JSON.stringify({ | |
| 124 | subject: | |
| 125 | " Add important change to the system with too much detail and a trailing period.\nsecondary line", | |
| 126 | body: "\n- added migration\n- updated tests\n", | |
| 127 | }), | |
| 128 | }, | |
| 129 | Effect.gen(function* () { | |
| 130 | const textGeneration = yield* TextGeneration; | |
| 131 | ||
| 132 | const generated = yield* textGeneration.generateCommitMessage({ | |
| 133 | cwd: process.cwd(), | |
| 134 | branch: "feature/codex-effect", | |
| 135 | stagedSummary: "M README.md", | |
| 136 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 137 | }); | |
| 138 | ||
| 139 | expect(generated.subject.length).toBeLessThanOrEqual(72); | |
| 140 | expect(generated.subject.endsWith(".")).toBe(false); | |
| 141 | expect(generated.body).toBe("- added migration\n- updated tests"); | |
| 142 | }), | |
| 143 | ), | |
| 144 | ); | |
| 145 | ||
| 146 | it.effect("generates PR content and trims markdown body", () => | |
| 147 | withFakeCodexEnv( | |
| 148 | { | |
| 149 | output: JSON.stringify({ | |
| 150 | title: " Improve orchestration flow\nwith ignored suffix", | |
| 151 | body: "\n## Summary\n- improve flow\n\n## Testing\n- bun test\n\n", | |
| 152 | }), | |
| 153 | }, | |
| 154 | Effect.gen(function* () { | |
| 155 | const textGeneration = yield* TextGeneration; | |
| 156 | ||
| 157 | const generated = yield* textGeneration.generatePrContent({ | |
| 158 | cwd: process.cwd(), | |
| 159 | baseBranch: "main", | |
| 160 | headBranch: "feature/codex-effect", | |
| 161 | commitSummary: "feat: improve orchestration flow", | |
| 162 | diffSummary: "2 files changed", | |
| 163 | diffPatch: "diff --git a/a.ts b/a.ts", | |
| 164 | }); | |
| 165 | ||
| 166 | expect(generated.title).toBe("Improve orchestration flow"); | |
| 167 | expect(generated.body.startsWith("## Summary")).toBe(true); | |
| 168 | expect(generated.body.endsWith("\n\n")).toBe(false); | |
| 169 | }), | |
| 170 | ), | |
| 171 | ); | |
| 172 | ||
| 173 | it.effect("returns typed TextGenerationError when codex exits non-zero", () => | |
| 174 | withFakeCodexEnv( | |
| 175 | { | |
| 176 | output: JSON.stringify({ subject: "ignored", body: "" }), | |
| 177 | exitCode: 1, | |
| 178 | stderr: "codex execution failed", | |
| 179 | }, | |
| 180 | Effect.gen(function* () { | |
| 181 | const textGeneration = yield* TextGeneration; | |
| 182 | ||
| 183 | const result = yield* textGeneration | |
| 184 | .generateCommitMessage({ | |
| 185 | cwd: process.cwd(), | |
| 186 | branch: "feature/codex-error", | |
| 187 | stagedSummary: "M README.md", | |
| 188 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 189 | }) | |
| 190 | .pipe( | |
| 191 | Effect.match({ | |
| 192 | onFailure: (error) => ({ _tag: "Left" as const, left: error }), | |
| 193 | onSuccess: (value) => ({ _tag: "Right" as const, right: value }), | |
| 194 | }), | |
| 195 | ); | |
| 196 | ||
| 197 | expect(result._tag).toBe("Left"); | |
| 198 | if (result._tag === "Left") { | |
| 199 | expect(result.left).toBeInstanceOf(TextGenerationError); | |
| 200 | expect(result.left.message).toContain("Codex CLI command failed: codex execution failed"); | |
| 201 | } | |
| 202 | }), | |
| 203 | ), | |
| 204 | ); | |
| 205 | }); |
apps/server/src/git/Layers/CodexTextGeneration.ts created+393| ... | ... | @@ -0,0 +1,393 @@ |
| 1 | import { randomUUID } from "node:crypto"; | |
| 2 | ||
| 3 | import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; | |
| 4 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 5 | ||
| 6 | import { TextGenerationError } from "../Errors.ts"; | |
| 7 | import { | |
| 8 | type CommitMessageGenerationResult, | |
| 9 | type PrContentGenerationResult, | |
| 10 | type TextGenerationShape, | |
| 11 | TextGeneration, | |
| 12 | } from "../Services/TextGeneration.ts"; | |
| 13 | ||
| 14 | const CODEX_MODEL = "gpt-5.3-codex"; | |
| 15 | const CODEX_REASONING_EFFORT = "low"; | |
| 16 | const CODEX_TIMEOUT_MS = 180_000; | |
| 17 | ||
| 18 | const COMMIT_OUTPUT_SCHEMA_JSON = { | |
| 19 | type: "object", | |
| 20 | properties: { | |
| 21 | subject: { type: "string" }, | |
| 22 | body: { type: "string" }, | |
| 23 | }, | |
| 24 | required: ["subject", "body"], | |
| 25 | additionalProperties: false, | |
| 26 | } as const; | |
| 27 | ||
| 28 | const PR_OUTPUT_SCHEMA_JSON = { | |
| 29 | type: "object", | |
| 30 | properties: { | |
| 31 | title: { type: "string" }, | |
| 32 | body: { type: "string" }, | |
| 33 | }, | |
| 34 | required: ["title", "body"], | |
| 35 | additionalProperties: false, | |
| 36 | } as const; | |
| 37 | ||
| 38 | function normalizeCodexError( | |
| 39 | operation: string, | |
| 40 | error: unknown, | |
| 41 | fallback: string, | |
| 42 | ): TextGenerationError { | |
| 43 | if (error instanceof TextGenerationError) { | |
| 44 | return error; | |
| 45 | } | |
| 46 | ||
| 47 | if (error instanceof Error) { | |
| 48 | const lower = error.message.toLowerCase(); | |
| 49 | if ( | |
| 50 | error.message.includes("Command not found: codex") || | |
| 51 | lower.includes("spawn codex") || | |
| 52 | lower.includes("enoent") | |
| 53 | ) { | |
| 54 | return new TextGenerationError({ | |
| 55 | operation, | |
| 56 | detail: "Codex CLI (`codex`) is required but not available on PATH.", | |
| 57 | cause: error, | |
| 58 | }); | |
| 59 | } | |
| 60 | return new TextGenerationError({ | |
| 61 | operation, | |
| 62 | detail: `${fallback}: ${error.message}`, | |
| 63 | cause: error, | |
| 64 | }); | |
| 65 | } | |
| 66 | ||
| 67 | return new TextGenerationError({ | |
| 68 | operation, | |
| 69 | detail: fallback, | |
| 70 | cause: error, | |
| 71 | }); | |
| 72 | } | |
| 73 | ||
| 74 | function parseCommitOutput(raw: unknown): { subject: string; body: string } { | |
| 75 | if (!raw || typeof raw !== "object") { | |
| 76 | throw new Error("Codex returned a non-object commit message payload."); | |
| 77 | } | |
| 78 | const record = raw as Record<string, unknown>; | |
| 79 | const subject = typeof record.subject === "string" ? record.subject.trim() : ""; | |
| 80 | const body = typeof record.body === "string" ? record.body : ""; | |
| 81 | if (subject.length === 0) { | |
| 82 | throw new Error("Codex returned an empty commit subject."); | |
| 83 | } | |
| 84 | return { subject, body }; | |
| 85 | } | |
| 86 | ||
| 87 | function parsePrOutput(raw: unknown): { title: string; body: string } { | |
| 88 | if (!raw || typeof raw !== "object") { | |
| 89 | throw new Error("Codex returned a non-object PR payload."); | |
| 90 | } | |
| 91 | const record = raw as Record<string, unknown>; | |
| 92 | const title = typeof record.title === "string" ? record.title.trim() : ""; | |
| 93 | const body = typeof record.body === "string" ? record.body.trim() : ""; | |
| 94 | if (title.length === 0 || body.length === 0) { | |
| 95 | throw new Error("Codex returned an invalid PR title/body payload."); | |
| 96 | } | |
| 97 | return { title, body }; | |
| 98 | } | |
| 99 | ||
| 100 | function limitSection(value: string, maxChars: number): string { | |
| 101 | if (value.length <= maxChars) return value; | |
| 102 | const truncated = value.slice(0, maxChars); | |
| 103 | return `${truncated}\n\n[truncated]`; | |
| 104 | } | |
| 105 | ||
| 106 | function sanitizeCommitSubject(raw: string): string { | |
| 107 | const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | |
| 108 | const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); | |
| 109 | if (withoutTrailingPeriod.length === 0) { | |
| 110 | return "Update project files"; | |
| 111 | } | |
| 112 | ||
| 113 | if (withoutTrailingPeriod.length <= 72) { | |
| 114 | return withoutTrailingPeriod; | |
| 115 | } | |
| 116 | return withoutTrailingPeriod.slice(0, 72).trimEnd(); | |
| 117 | } | |
| 118 | ||
| 119 | function sanitizePrTitle(raw: string): string { | |
| 120 | const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | |
| 121 | if (singleLine.length > 0) { | |
| 122 | return singleLine; | |
| 123 | } | |
| 124 | return "Update project changes"; | |
| 125 | } | |
| 126 | ||
| 127 | const makeCodexTextGeneration = Effect.gen(function* () { | |
| 128 | const fileSystem = yield* FileSystem.FileSystem; | |
| 129 | const path = yield* Path.Path; | |
| 130 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 131 | ||
| 132 | const readStreamAsString = ( | |
| 133 | operation: string, | |
| 134 | stream: Stream.Stream<Uint8Array, unknown>, | |
| 135 | ): Effect.Effect<string, TextGenerationError> => | |
| 136 | Effect.gen(function* () { | |
| 137 | let text = ""; | |
| 138 | yield* Stream.runForEach(stream, (chunk) => | |
| 139 | Effect.sync(() => { | |
| 140 | text += Buffer.from(chunk).toString("utf8"); | |
| 141 | }), | |
| 142 | ).pipe( | |
| 143 | Effect.mapError((cause) => | |
| 144 | normalizeCodexError(operation, cause, "Failed to collect process output"), | |
| 145 | ), | |
| 146 | ); | |
| 147 | return text; | |
| 148 | }); | |
| 149 | ||
| 150 | const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; | |
| 151 | ||
| 152 | const writeTempFile = ( | |
| 153 | operation: string, | |
| 154 | prefix: string, | |
| 155 | content: string, | |
| 156 | ): Effect.Effect<string, TextGenerationError> => { | |
| 157 | const filePath = path.join(tempDir, `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`); | |
| 158 | return fileSystem.writeFileString(filePath, content).pipe( | |
| 159 | Effect.mapError( | |
| 160 | (cause) => | |
| 161 | new TextGenerationError({ | |
| 162 | operation, | |
| 163 | detail: `Failed to write temp file at ${filePath}.`, | |
| 164 | cause, | |
| 165 | }), | |
| 166 | ), | |
| 167 | Effect.as(filePath), | |
| 168 | ); | |
| 169 | }; | |
| 170 | ||
| 171 | const safeUnlink = (filePath: string): Effect.Effect<void, never> => | |
| 172 | fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); | |
| 173 | ||
| 174 | const runCodexJson = <T>({ | |
| 175 | operation, | |
| 176 | cwd, | |
| 177 | prompt, | |
| 178 | outputSchemaJson, | |
| 179 | parse, | |
| 180 | }: { | |
| 181 | operation: "generateCommitMessage" | "generatePrContent"; | |
| 182 | cwd: string; | |
| 183 | prompt: string; | |
| 184 | outputSchemaJson: object; | |
| 185 | parse: (raw: unknown) => T; | |
| 186 | }): Effect.Effect<T, TextGenerationError> => | |
| 187 | Effect.gen(function* () { | |
| 188 | const schemaPath = yield* writeTempFile( | |
| 189 | operation, | |
| 190 | "codex-schema", | |
| 191 | JSON.stringify(outputSchemaJson), | |
| 192 | ); | |
| 193 | const outputPath = yield* writeTempFile(operation, "codex-output", ""); | |
| 194 | ||
| 195 | const runCodexCommand = Effect.gen(function* () { | |
| 196 | const command = ChildProcess.make( | |
| 197 | "codex", | |
| 198 | [ | |
| 199 | "exec", | |
| 200 | "--ephemeral", | |
| 201 | "-s", | |
| 202 | "read-only", | |
| 203 | "--model", | |
| 204 | CODEX_MODEL, | |
| 205 | "--config", | |
| 206 | `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`, | |
| 207 | "--output-schema", | |
| 208 | schemaPath, | |
| 209 | "--output-last-message", | |
| 210 | outputPath, | |
| 211 | "-", | |
| 212 | ], | |
| 213 | { | |
| 214 | cwd, | |
| 215 | stdin: { | |
| 216 | stream: Stream.make(new TextEncoder().encode(prompt)), | |
| 217 | }, | |
| 218 | }, | |
| 219 | ); | |
| 220 | ||
| 221 | const child = yield* commandSpawner | |
| 222 | .spawn(command) | |
| 223 | .pipe( | |
| 224 | Effect.mapError((cause) => | |
| 225 | normalizeCodexError(operation, cause, "Failed to spawn Codex CLI process"), | |
| 226 | ), | |
| 227 | ); | |
| 228 | ||
| 229 | const [stdout, stderr, exitCode] = yield* Effect.all( | |
| 230 | [ | |
| 231 | readStreamAsString(operation, child.stdout), | |
| 232 | readStreamAsString(operation, child.stderr), | |
| 233 | child.exitCode.pipe( | |
| 234 | Effect.map((value) => Number(value)), | |
| 235 | Effect.mapError((cause) => | |
| 236 | normalizeCodexError(operation, cause, "Failed to read Codex CLI exit code"), | |
| 237 | ), | |
| 238 | ), | |
| 239 | ], | |
| 240 | { concurrency: "unbounded" }, | |
| 241 | ); | |
| 242 | ||
| 243 | if (exitCode !== 0) { | |
| 244 | const stderrDetail = stderr.trim(); | |
| 245 | const stdoutDetail = stdout.trim(); | |
| 246 | const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; | |
| 247 | return yield* new TextGenerationError({ | |
| 248 | operation, | |
| 249 | detail: | |
| 250 | detail.length > 0 | |
| 251 | ? `Codex CLI command failed: ${detail}` | |
| 252 | : `Codex CLI command failed with code ${exitCode}.`, | |
| 253 | }); | |
| 254 | } | |
| 255 | }); | |
| 256 | ||
| 257 | const cleanup = Effect.all([safeUnlink(schemaPath), safeUnlink(outputPath)], { | |
| 258 | concurrency: "unbounded", | |
| 259 | }).pipe(Effect.asVoid); | |
| 260 | ||
| 261 | return yield* Effect.gen(function* () { | |
| 262 | yield* runCodexCommand.pipe( | |
| 263 | Effect.scoped, | |
| 264 | Effect.timeoutOption(CODEX_TIMEOUT_MS), | |
| 265 | Effect.flatMap( | |
| 266 | Option.match({ | |
| 267 | onNone: () => | |
| 268 | Effect.fail( | |
| 269 | new TextGenerationError({ operation, detail: "Codex CLI request timed out." }), | |
| 270 | ), | |
| 271 | onSome: () => Effect.void, | |
| 272 | }), | |
| 273 | ), | |
| 274 | ); | |
| 275 | ||
| 276 | const rawOutput = yield* fileSystem.readFileString(outputPath).pipe( | |
| 277 | Effect.mapError( | |
| 278 | (cause) => | |
| 279 | new TextGenerationError({ | |
| 280 | operation, | |
| 281 | detail: "Failed to read Codex output file.", | |
| 282 | cause, | |
| 283 | }), | |
| 284 | ), | |
| 285 | ); | |
| 286 | const trimmed = rawOutput.trim(); | |
| 287 | if (trimmed.length === 0) { | |
| 288 | return yield* new TextGenerationError({ | |
| 289 | operation, | |
| 290 | detail: "Codex returned an empty response.", | |
| 291 | }); | |
| 292 | } | |
| 293 | ||
| 294 | const parsedJson = yield* Effect.try({ | |
| 295 | try: () => JSON.parse(trimmed) as unknown, | |
| 296 | catch: (cause) => | |
| 297 | new TextGenerationError({ | |
| 298 | operation, | |
| 299 | detail: "Codex returned invalid JSON output.", | |
| 300 | cause, | |
| 301 | }), | |
| 302 | }); | |
| 303 | ||
| 304 | return yield* Effect.try({ | |
| 305 | try: () => parse(parsedJson), | |
| 306 | catch: (cause) => | |
| 307 | normalizeCodexError(operation, cause, "Codex returned invalid structured output"), | |
| 308 | }); | |
| 309 | }).pipe(Effect.ensuring(cleanup)); | |
| 310 | }); | |
| 311 | ||
| 312 | const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => { | |
| 313 | const prompt = [ | |
| 314 | "You write concise git commit messages.", | |
| 315 | "Return a JSON object with keys: subject, body.", | |
| 316 | "Rules:", | |
| 317 | "- subject must be imperative, <= 72 chars, and no trailing period", | |
| 318 | "- body can be empty string or short bullet points", | |
| 319 | "- capture the primary user-visible or developer-visible change", | |
| 320 | "", | |
| 321 | `Branch: ${input.branch ?? "(detached)"}`, | |
| 322 | "", | |
| 323 | "Staged files:", | |
| 324 | limitSection(input.stagedSummary, 6_000), | |
| 325 | "", | |
| 326 | "Staged patch:", | |
| 327 | limitSection(input.stagedPatch, 40_000), | |
| 328 | ].join("\n"); | |
| 329 | ||
| 330 | return runCodexJson({ | |
| 331 | operation: "generateCommitMessage", | |
| 332 | cwd: input.cwd, | |
| 333 | prompt, | |
| 334 | outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON, | |
| 335 | parse: (raw) => parseCommitOutput(raw), | |
| 336 | }).pipe( | |
| 337 | Effect.map( | |
| 338 | (generated) => | |
| 339 | ({ | |
| 340 | subject: sanitizeCommitSubject(generated.subject), | |
| 341 | body: generated.body.trim(), | |
| 342 | }) satisfies CommitMessageGenerationResult, | |
| 343 | ), | |
| 344 | ); | |
| 345 | }; | |
| 346 | ||
| 347 | const generatePrContent: TextGenerationShape["generatePrContent"] = (input) => { | |
| 348 | const prompt = [ | |
| 349 | "You write GitHub pull request content.", | |
| 350 | "Return a JSON object with keys: title, body.", | |
| 351 | "Rules:", | |
| 352 | "- title should be concise and specific", | |
| 353 | "- body must be markdown and include headings '## Summary' and '## Testing'", | |
| 354 | "- under Summary, provide short bullet points", | |
| 355 | "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", | |
| 356 | "", | |
| 357 | `Base branch: ${input.baseBranch}`, | |
| 358 | `Head branch: ${input.headBranch}`, | |
| 359 | "", | |
| 360 | "Commits:", | |
| 361 | limitSection(input.commitSummary, 12_000), | |
| 362 | "", | |
| 363 | "Diff stat:", | |
| 364 | limitSection(input.diffSummary, 12_000), | |
| 365 | "", | |
| 366 | "Diff patch:", | |
| 367 | limitSection(input.diffPatch, 40_000), | |
| 368 | ].join("\n"); | |
| 369 | ||
| 370 | return runCodexJson({ | |
| 371 | operation: "generatePrContent", | |
| 372 | cwd: input.cwd, | |
| 373 | prompt, | |
| 374 | outputSchemaJson: PR_OUTPUT_SCHEMA_JSON, | |
| 375 | parse: (raw) => parsePrOutput(raw), | |
| 376 | }).pipe( | |
| 377 | Effect.map( | |
| 378 | (generated) => | |
| 379 | ({ | |
| 380 | title: sanitizePrTitle(generated.title), | |
| 381 | body: generated.body.trim(), | |
| 382 | }) satisfies PrContentGenerationResult, | |
| 383 | ), | |
| 384 | ); | |
| 385 | }; | |
| 386 | ||
| 387 | return { | |
| 388 | generateCommitMessage, | |
| 389 | generatePrContent, | |
| 390 | } satisfies TextGenerationShape; | |
| 391 | }); | |
| 392 | ||
| 393 | export const CodexTextGenerationLive = Layer.effect(TextGeneration, makeCodexTextGeneration); |
apps/server/src/git/Layers/GitCore.test.ts created+1157| ... | ... | @@ -0,0 +1,1157 @@ |
| 1 | import { existsSync } from "node:fs"; | |
| 2 | import path from "node:path"; | |
| 3 | ||
| 4 | import { NodeServices } from "@effect/platform-node"; | |
| 5 | import { it } from "@effect/vitest"; | |
| 6 | import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; | |
| 7 | import { describe, expect, vi } from "vitest"; | |
| 8 | ||
| 9 | import { GitServiceLive } from "./GitService.ts"; | |
| 10 | import { GitService, type GitServiceShape } from "../Services/GitService.ts"; | |
| 11 | import { GitCoreLive } from "./GitCore.ts"; | |
| 12 | import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; | |
| 13 | import { GitCommandError } from "../Errors.ts"; | |
| 14 | import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; | |
| 15 | ||
| 16 | // ── Helpers ── | |
| 17 | ||
| 18 | const GitServiceTestLayer = GitServiceLive.pipe(Layer.provide(NodeServices.layer)); | |
| 19 | const GitCoreTestLayer = GitCoreLive.pipe( | |
| 20 | Layer.provide(GitServiceTestLayer), | |
| 21 | Layer.provide(NodeServices.layer), | |
| 22 | ); | |
| 23 | const TestLayer = Layer.mergeAll(NodeServices.layer, GitServiceTestLayer, GitCoreTestLayer); | |
| 24 | ||
| 25 | function makeTmpDir( | |
| 26 | prefix = "git-test-", | |
| 27 | ): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> { | |
| 28 | return Effect.gen(function* () { | |
| 29 | const fileSystem = yield* FileSystem.FileSystem; | |
| 30 | return yield* fileSystem.makeTempDirectoryScoped({ prefix }); | |
| 31 | }); | |
| 32 | } | |
| 33 | ||
| 34 | function writeTextFile( | |
| 35 | filePath: string, | |
| 36 | contents: string, | |
| 37 | ): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> { | |
| 38 | return Effect.gen(function* () { | |
| 39 | const fileSystem = yield* FileSystem.FileSystem; | |
| 40 | yield* fileSystem.writeFileString(filePath, contents); | |
| 41 | }); | |
| 42 | } | |
| 43 | ||
| 44 | /** Run a raw git command for test setup (not under test). */ | |
| 45 | function git( | |
| 46 | cwd: string, | |
| 47 | args: ReadonlyArray<string>, | |
| 48 | env?: NodeJS.ProcessEnv, | |
| 49 | ): Effect.Effect<string, GitCommandError, GitService> { | |
| 50 | return Effect.gen(function* () { | |
| 51 | const gitService = yield* GitService; | |
| 52 | const result = yield* gitService.execute({ | |
| 53 | operation: "GitCore.test.git", | |
| 54 | cwd, | |
| 55 | args, | |
| 56 | ...(env ? { env } : {}), | |
| 57 | timeoutMs: 10_000, | |
| 58 | }); | |
| 59 | return result.stdout.trim(); | |
| 60 | }); | |
| 61 | } | |
| 62 | ||
| 63 | function runShellCommand(input: { | |
| 64 | command: string; | |
| 65 | cwd: string; | |
| 66 | timeoutMs?: number; | |
| 67 | maxOutputBytes?: number; | |
| 68 | }): Effect.Effect<ProcessRunResult, Error> { | |
| 69 | return Effect.promise(() => { | |
| 70 | const shellPath = | |
| 71 | process.platform === "win32" | |
| 72 | ? (process.env.ComSpec ?? "cmd.exe") | |
| 73 | : (process.env.SHELL ?? "/bin/sh"); | |
| 74 | ||
| 75 | const args = | |
| 76 | process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command]; | |
| 77 | ||
| 78 | return runProcess(shellPath, args, { | |
| 79 | cwd: input.cwd, | |
| 80 | timeoutMs: input.timeoutMs ?? 30_000, | |
| 81 | allowNonZeroExit: true, | |
| 82 | maxBufferBytes: input.maxOutputBytes ?? 1_000_000, | |
| 83 | outputMode: "truncate", | |
| 84 | }); | |
| 85 | }); | |
| 86 | } | |
| 87 | ||
| 88 | const makeIsolatedGitCore = (gitService: GitServiceShape) => | |
| 89 | Effect.promise(async () => { | |
| 90 | const gitServiceLayer = Layer.succeed(GitService, gitService); | |
| 91 | const coreLayer = GitCoreLive.pipe( | |
| 92 | Layer.provide(gitServiceLayer), | |
| 93 | Layer.provide(NodeServices.layer), | |
| 94 | ); | |
| 95 | const core = await Effect.runPromise(Effect.service(GitCore).pipe(Effect.provide(coreLayer))); | |
| 96 | ||
| 97 | return { | |
| 98 | status: (input) => core.status(input), | |
| 99 | statusDetails: (cwd) => core.statusDetails(cwd), | |
| 100 | prepareCommitContext: (cwd) => core.prepareCommitContext(cwd), | |
| 101 | commit: (cwd, subject, body) => core.commit(cwd, subject, body), | |
| 102 | pushCurrentBranch: (cwd, fallbackBranch) => core.pushCurrentBranch(cwd, fallbackBranch), | |
| 103 | pullCurrentBranch: (cwd) => core.pullCurrentBranch(cwd), | |
| 104 | readRangeContext: (cwd, baseBranch) => core.readRangeContext(cwd, baseBranch), | |
| 105 | readConfigValue: (cwd, key) => core.readConfigValue(cwd, key), | |
| 106 | listBranches: (input) => core.listBranches(input), | |
| 107 | createWorktree: (input) => core.createWorktree(input), | |
| 108 | removeWorktree: (input) => core.removeWorktree(input), | |
| 109 | createBranch: (input) => core.createBranch(input), | |
| 110 | checkoutBranch: (input) => core.checkoutBranch(input), | |
| 111 | initRepo: (input) => core.initRepo(input), | |
| 112 | } satisfies GitCoreShape; | |
| 113 | }); | |
| 114 | ||
| 115 | function listGitBranches(input: Parameters<GitCoreShape["listBranches"]>[0]) { | |
| 116 | return Effect.gen(function* () { | |
| 117 | const core = yield* GitCore; | |
| 118 | return yield* core.listBranches(input); | |
| 119 | }); | |
| 120 | } | |
| 121 | ||
| 122 | function initGitRepo(input: Parameters<GitCoreShape["initRepo"]>[0]) { | |
| 123 | return Effect.gen(function* () { | |
| 124 | const core = yield* GitCore; | |
| 125 | return yield* core.initRepo(input); | |
| 126 | }); | |
| 127 | } | |
| 128 | ||
| 129 | function createGitBranch(input: Parameters<GitCoreShape["createBranch"]>[0]) { | |
| 130 | return Effect.gen(function* () { | |
| 131 | const core = yield* GitCore; | |
| 132 | return yield* core.createBranch(input); | |
| 133 | }); | |
| 134 | } | |
| 135 | ||
| 136 | function checkoutGitBranch(input: Parameters<GitCoreShape["checkoutBranch"]>[0]) { | |
| 137 | return Effect.gen(function* () { | |
| 138 | const core = yield* GitCore; | |
| 139 | return yield* core.checkoutBranch(input); | |
| 140 | }); | |
| 141 | } | |
| 142 | ||
| 143 | function createGitWorktree(input: Parameters<GitCoreShape["createWorktree"]>[0]) { | |
| 144 | return Effect.gen(function* () { | |
| 145 | const core = yield* GitCore; | |
| 146 | return yield* core.createWorktree(input); | |
| 147 | }); | |
| 148 | } | |
| 149 | ||
| 150 | function removeGitWorktree(input: Parameters<GitCoreShape["removeWorktree"]>[0]) { | |
| 151 | return Effect.gen(function* () { | |
| 152 | const core = yield* GitCore; | |
| 153 | return yield* core.removeWorktree(input); | |
| 154 | }); | |
| 155 | } | |
| 156 | ||
| 157 | function pullGitBranch({ cwd }: { cwd: string }) { | |
| 158 | return Effect.gen(function* () { | |
| 159 | const core = yield* GitCore; | |
| 160 | return yield* core.pullCurrentBranch(cwd); | |
| 161 | }); | |
| 162 | } | |
| 163 | ||
| 164 | /** Create a repo with an initial commit so branches work. */ | |
| 165 | function initRepoWithCommit( | |
| 166 | cwd: string, | |
| 167 | ): Effect.Effect< | |
| 168 | void, | |
| 169 | GitCommandError | PlatformError.PlatformError, | |
| 170 | GitCore | GitService | FileSystem.FileSystem | |
| 171 | > { | |
| 172 | return Effect.gen(function* () { | |
| 173 | yield* initGitRepo({ cwd }); | |
| 174 | yield* git(cwd, ["config", "user.email", "test@test.com"]); | |
| 175 | yield* git(cwd, ["config", "user.name", "Test"]); | |
| 176 | yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); | |
| 177 | yield* git(cwd, ["add", "."]); | |
| 178 | yield* git(cwd, ["commit", "-m", "initial commit"]); | |
| 179 | }); | |
| 180 | } | |
| 181 | ||
| 182 | function commitWithDate( | |
| 183 | cwd: string, | |
| 184 | fileName: string, | |
| 185 | fileContents: string, | |
| 186 | dateIsoString: string, | |
| 187 | message: string, | |
| 188 | ): Effect.Effect< | |
| 189 | void, | |
| 190 | GitCommandError | PlatformError.PlatformError, | |
| 191 | GitService | FileSystem.FileSystem | |
| 192 | > { | |
| 193 | return Effect.gen(function* () { | |
| 194 | yield* writeTextFile(path.join(cwd, fileName), fileContents); | |
| 195 | yield* git(cwd, ["add", fileName]); | |
| 196 | yield* git(cwd, ["commit", "-m", message], { | |
| 197 | ...process.env, | |
| 198 | GIT_AUTHOR_DATE: dateIsoString, | |
| 199 | GIT_COMMITTER_DATE: dateIsoString, | |
| 200 | }); | |
| 201 | }); | |
| 202 | } | |
| 203 | ||
| 204 | // ── Tests ── | |
| 205 | ||
| 206 | it.layer(TestLayer)("git integration", (it) => { | |
| 207 | describe("shell process execution", () => { | |
| 208 | it.effect("caps captured output when maxOutputBytes is exceeded", () => | |
| 209 | Effect.gen(function* () { | |
| 210 | const result = yield* runShellCommand({ | |
| 211 | command: `node -e "process.stdout.write('x'.repeat(2000))"`, | |
| 212 | cwd: process.cwd(), | |
| 213 | timeoutMs: 10_000, | |
| 214 | maxOutputBytes: 128, | |
| 215 | }); | |
| 216 | ||
| 217 | expect(result.code).toBe(0); | |
| 218 | expect(result.stdout.length).toBeLessThanOrEqual(128); | |
| 219 | expect(result.stdoutTruncated || result.stderrTruncated).toBe(true); | |
| 220 | }), | |
| 221 | ); | |
| 222 | }); | |
| 223 | ||
| 224 | // ── initGitRepo ── | |
| 225 | ||
| 226 | describe("initGitRepo", () => { | |
| 227 | it.effect("creates a valid git repo", () => | |
| 228 | Effect.gen(function* () { | |
| 229 | const tmp = yield* makeTmpDir(); | |
| 230 | yield* initGitRepo({ cwd: tmp }); | |
| 231 | expect(existsSync(path.join(tmp, ".git"))).toBe(true); | |
| 232 | }), | |
| 233 | ); | |
| 234 | ||
| 235 | it.effect("listGitBranches reports isRepo: true after init + commit", () => | |
| 236 | Effect.gen(function* () { | |
| 237 | const tmp = yield* makeTmpDir(); | |
| 238 | yield* initRepoWithCommit(tmp); | |
| 239 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 240 | expect(result.isRepo).toBe(true); | |
| 241 | expect(result.branches.length).toBeGreaterThanOrEqual(1); | |
| 242 | }), | |
| 243 | ); | |
| 244 | }); | |
| 245 | ||
| 246 | // ── listGitBranches ── | |
| 247 | ||
| 248 | describe("listGitBranches", () => { | |
| 249 | it.effect("returns isRepo: false for non-git directory", () => | |
| 250 | Effect.gen(function* () { | |
| 251 | const tmp = yield* makeTmpDir(); | |
| 252 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 253 | expect(result.isRepo).toBe(false); | |
| 254 | expect(result.branches).toEqual([]); | |
| 255 | }), | |
| 256 | ); | |
| 257 | ||
| 258 | it.effect("returns the current branch with current: true", () => | |
| 259 | Effect.gen(function* () { | |
| 260 | const tmp = yield* makeTmpDir(); | |
| 261 | yield* initRepoWithCommit(tmp); | |
| 262 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 263 | const current = result.branches.find((b) => b.current); | |
| 264 | expect(current).toBeDefined(); | |
| 265 | expect(current!.current).toBe(true); | |
| 266 | }), | |
| 267 | ); | |
| 268 | ||
| 269 | it.effect("keeps current branch first and sorts the remaining branches by recency", () => | |
| 270 | Effect.gen(function* () { | |
| 271 | const tmp = yield* makeTmpDir(); | |
| 272 | yield* initRepoWithCommit(tmp); | |
| 273 | const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 274 | (branch) => branch.current, | |
| 275 | )!.name; | |
| 276 | ||
| 277 | yield* createGitBranch({ cwd: tmp, branch: "older-branch" }); | |
| 278 | yield* checkoutGitBranch({ cwd: tmp, branch: "older-branch" }); | |
| 279 | yield* commitWithDate( | |
| 280 | tmp, | |
| 281 | "older.txt", | |
| 282 | "older branch change\n", | |
| 283 | "Thu, 1 Jan 2037 00:00:00 +0000", | |
| 284 | "older branch change", | |
| 285 | ); | |
| 286 | ||
| 287 | yield* checkoutGitBranch({ cwd: tmp, branch: initialBranch }); | |
| 288 | yield* createGitBranch({ cwd: tmp, branch: "newer-branch" }); | |
| 289 | yield* checkoutGitBranch({ cwd: tmp, branch: "newer-branch" }); | |
| 290 | yield* commitWithDate( | |
| 291 | tmp, | |
| 292 | "newer.txt", | |
| 293 | "newer branch change\n", | |
| 294 | "Fri, 1 Jan 2038 00:00:00 +0000", | |
| 295 | "newer branch change", | |
| 296 | ); | |
| 297 | ||
| 298 | // Switch away to show current branch is pinned, then remaining branches are recency-sorted. | |
| 299 | yield* checkoutGitBranch({ cwd: tmp, branch: "older-branch" }); | |
| 300 | ||
| 301 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 302 | expect(result.branches[0]!.name).toBe("older-branch"); | |
| 303 | expect(result.branches[1]!.name).toBe("newer-branch"); | |
| 304 | }), | |
| 305 | ); | |
| 306 | ||
| 307 | it.effect("keeps default branch right after current branch", () => | |
| 308 | Effect.gen(function* () { | |
| 309 | const tmp = yield* makeTmpDir(); | |
| 310 | const remote = yield* makeTmpDir(); | |
| 311 | yield* initRepoWithCommit(tmp); | |
| 312 | const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 313 | (branch) => branch.current, | |
| 314 | )!.name; | |
| 315 | ||
| 316 | yield* git(remote, ["init", "--bare"]); | |
| 317 | yield* git(tmp, ["remote", "add", "origin", remote]); | |
| 318 | yield* git(tmp, ["push", "-u", "origin", defaultBranch]); | |
| 319 | yield* git(tmp, ["remote", "set-head", "origin", defaultBranch]); | |
| 320 | ||
| 321 | yield* createGitBranch({ cwd: tmp, branch: "current-branch" }); | |
| 322 | yield* checkoutGitBranch({ cwd: tmp, branch: "current-branch" }); | |
| 323 | yield* commitWithDate( | |
| 324 | tmp, | |
| 325 | "current.txt", | |
| 326 | "current change\n", | |
| 327 | "Thu, 1 Jan 2037 00:00:00 +0000", | |
| 328 | "current change", | |
| 329 | ); | |
| 330 | ||
| 331 | yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch }); | |
| 332 | yield* createGitBranch({ cwd: tmp, branch: "newer-branch" }); | |
| 333 | yield* checkoutGitBranch({ cwd: tmp, branch: "newer-branch" }); | |
| 334 | yield* commitWithDate( | |
| 335 | tmp, | |
| 336 | "newer.txt", | |
| 337 | "newer change\n", | |
| 338 | "Fri, 1 Jan 2038 00:00:00 +0000", | |
| 339 | "newer change", | |
| 340 | ); | |
| 341 | ||
| 342 | yield* checkoutGitBranch({ cwd: tmp, branch: "current-branch" }); | |
| 343 | ||
| 344 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 345 | expect(result.branches[0]!.name).toBe("current-branch"); | |
| 346 | expect(result.branches[1]!.name).toBe(defaultBranch); | |
| 347 | expect(result.branches[2]!.name).toBe("newer-branch"); | |
| 348 | }), | |
| 349 | ); | |
| 350 | ||
| 351 | it.effect("lists multiple branches after creating them", () => | |
| 352 | Effect.gen(function* () { | |
| 353 | const tmp = yield* makeTmpDir(); | |
| 354 | yield* initRepoWithCommit(tmp); | |
| 355 | yield* createGitBranch({ cwd: tmp, branch: "feature-a" }); | |
| 356 | yield* createGitBranch({ cwd: tmp, branch: "feature-b" }); | |
| 357 | ||
| 358 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 359 | const names = result.branches.map((b) => b.name); | |
| 360 | expect(names).toContain("feature-a"); | |
| 361 | expect(names).toContain("feature-b"); | |
| 362 | }), | |
| 363 | ); | |
| 364 | ||
| 365 | it.effect("isDefault is false when no remote exists", () => | |
| 366 | Effect.gen(function* () { | |
| 367 | const tmp = yield* makeTmpDir(); | |
| 368 | yield* initRepoWithCommit(tmp); | |
| 369 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 370 | expect(result.branches.every((b) => b.isDefault === false)).toBe(true); | |
| 371 | }), | |
| 372 | ); | |
| 373 | }); | |
| 374 | ||
| 375 | // ── checkoutGitBranch ── | |
| 376 | ||
| 377 | describe("checkoutGitBranch", () => { | |
| 378 | it.effect("checks out an existing branch", () => | |
| 379 | Effect.gen(function* () { | |
| 380 | const tmp = yield* makeTmpDir(); | |
| 381 | yield* initRepoWithCommit(tmp); | |
| 382 | yield* createGitBranch({ cwd: tmp, branch: "feature" }); | |
| 383 | ||
| 384 | yield* checkoutGitBranch({ cwd: tmp, branch: "feature" }); | |
| 385 | ||
| 386 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 387 | const current = result.branches.find((b) => b.current); | |
| 388 | expect(current!.name).toBe("feature"); | |
| 389 | }), | |
| 390 | ); | |
| 391 | ||
| 392 | it.effect("refreshes upstream behind count after checkout when remote branch advanced", () => | |
| 393 | Effect.gen(function* () { | |
| 394 | const remote = yield* makeTmpDir(); | |
| 395 | const source = yield* makeTmpDir(); | |
| 396 | const clone = yield* makeTmpDir(); | |
| 397 | yield* git(remote, ["init", "--bare"]); | |
| 398 | ||
| 399 | yield* initRepoWithCommit(source); | |
| 400 | const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 401 | (branch) => branch.current, | |
| 402 | )!.name; | |
| 403 | yield* git(source, ["remote", "add", "origin", remote]); | |
| 404 | yield* git(source, ["push", "-u", "origin", defaultBranch]); | |
| 405 | ||
| 406 | const featureBranch = "feature-behind"; | |
| 407 | yield* createGitBranch({ cwd: source, branch: featureBranch }); | |
| 408 | yield* checkoutGitBranch({ cwd: source, branch: featureBranch }); | |
| 409 | yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); | |
| 410 | yield* git(source, ["add", "feature.txt"]); | |
| 411 | yield* git(source, ["commit", "-m", "feature base"]); | |
| 412 | yield* git(source, ["push", "-u", "origin", featureBranch]); | |
| 413 | yield* checkoutGitBranch({ cwd: source, branch: defaultBranch }); | |
| 414 | ||
| 415 | yield* git(clone, ["clone", remote, "."]); | |
| 416 | yield* git(clone, ["config", "user.email", "test@test.com"]); | |
| 417 | yield* git(clone, ["config", "user.name", "Test"]); | |
| 418 | yield* git(clone, ["checkout", "-b", featureBranch, "--track", `origin/${featureBranch}`]); | |
| 419 | yield* writeTextFile(path.join(clone, "feature.txt"), "feature from remote\n"); | |
| 420 | yield* git(clone, ["add", "feature.txt"]); | |
| 421 | yield* git(clone, ["commit", "-m", "remote feature update"]); | |
| 422 | yield* git(clone, ["push", "origin", featureBranch]); | |
| 423 | ||
| 424 | yield* checkoutGitBranch({ cwd: source, branch: featureBranch }); | |
| 425 | const core = yield* GitCore; | |
| 426 | yield* Effect.promise(() => | |
| 427 | vi.waitFor(async () => { | |
| 428 | const details = await Effect.runPromise(core.statusDetails(source)); | |
| 429 | expect(details.branch).toBe(featureBranch); | |
| 430 | expect(details.aheadCount).toBe(0); | |
| 431 | expect(details.behindCount).toBe(1); | |
| 432 | }), | |
| 433 | ); | |
| 434 | }), | |
| 435 | ); | |
| 436 | ||
| 437 | it.effect("keeps checkout successful when upstream refresh fails", () => | |
| 438 | Effect.gen(function* () { | |
| 439 | const remote = yield* makeTmpDir(); | |
| 440 | const source = yield* makeTmpDir(); | |
| 441 | yield* git(remote, ["init", "--bare"]); | |
| 442 | ||
| 443 | yield* initRepoWithCommit(source); | |
| 444 | const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 445 | (branch) => branch.current, | |
| 446 | )!.name; | |
| 447 | yield* git(source, ["remote", "add", "origin", remote]); | |
| 448 | yield* git(source, ["push", "-u", "origin", defaultBranch]); | |
| 449 | ||
| 450 | const featureBranch = "feature-refresh-failure"; | |
| 451 | yield* git(source, ["branch", featureBranch]); | |
| 452 | yield* git(source, ["checkout", featureBranch]); | |
| 453 | yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); | |
| 454 | yield* git(source, ["add", "feature.txt"]); | |
| 455 | yield* git(source, ["commit", "-m", "feature base"]); | |
| 456 | yield* git(source, ["push", "-u", "origin", featureBranch]); | |
| 457 | yield* git(source, ["checkout", defaultBranch]); | |
| 458 | ||
| 459 | const realGitService = yield* GitService; | |
| 460 | let refreshFetchAttempts = 0; | |
| 461 | const core = yield* makeIsolatedGitCore({ | |
| 462 | execute: (input) => { | |
| 463 | if (input.args[0] === "fetch") { | |
| 464 | refreshFetchAttempts += 1; | |
| 465 | return Effect.fail( | |
| 466 | new GitCommandError({ | |
| 467 | operation: "git.test.refreshFailure", | |
| 468 | command: `git ${input.args.join(" ")}`, | |
| 469 | cwd: input.cwd, | |
| 470 | detail: "simulated fetch timeout", | |
| 471 | }), | |
| 472 | ); | |
| 473 | } | |
| 474 | return realGitService.execute(input); | |
| 475 | }, | |
| 476 | }); | |
| 477 | yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); | |
| 478 | yield* Effect.promise(() => | |
| 479 | vi.waitFor(() => { | |
| 480 | expect(refreshFetchAttempts).toBe(1); | |
| 481 | }), | |
| 482 | ); | |
| 483 | expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); | |
| 484 | }), | |
| 485 | ); | |
| 486 | ||
| 487 | it.effect("refresh fetch is scoped to the checked out branch upstream refspec", () => | |
| 488 | Effect.gen(function* () { | |
| 489 | const remote = yield* makeTmpDir(); | |
| 490 | const source = yield* makeTmpDir(); | |
| 491 | yield* git(remote, ["init", "--bare"]); | |
| 492 | ||
| 493 | yield* initRepoWithCommit(source); | |
| 494 | const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 495 | (branch) => branch.current, | |
| 496 | )!.name; | |
| 497 | yield* git(source, ["remote", "add", "origin", remote]); | |
| 498 | yield* git(source, ["push", "-u", "origin", defaultBranch]); | |
| 499 | ||
| 500 | const featureBranch = "feature/scoped-fetch"; | |
| 501 | yield* git(source, ["checkout", "-b", featureBranch]); | |
| 502 | yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); | |
| 503 | yield* git(source, ["add", "feature.txt"]); | |
| 504 | yield* git(source, ["commit", "-m", "feature base"]); | |
| 505 | yield* git(source, ["push", "-u", "origin", featureBranch]); | |
| 506 | yield* git(source, ["checkout", defaultBranch]); | |
| 507 | ||
| 508 | const realGitService = yield* GitService; | |
| 509 | let fetchArgs: readonly string[] | null = null; | |
| 510 | const core = yield* makeIsolatedGitCore({ | |
| 511 | execute: (input) => { | |
| 512 | if (input.args[0] === "fetch") { | |
| 513 | fetchArgs = [...input.args]; | |
| 514 | return Effect.succeed({ code: 0, stdout: "", stderr: "" }); | |
| 515 | } | |
| 516 | return realGitService.execute(input); | |
| 517 | }, | |
| 518 | }); | |
| 519 | yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); | |
| 520 | yield* Effect.promise(() => | |
| 521 | vi.waitFor(() => { | |
| 522 | expect(fetchArgs).not.toBeNull(); | |
| 523 | }), | |
| 524 | ); | |
| 525 | ||
| 526 | expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); | |
| 527 | expect(fetchArgs).toEqual([ | |
| 528 | "fetch", | |
| 529 | "--quiet", | |
| 530 | "--no-tags", | |
| 531 | "origin", | |
| 532 | `+refs/heads/${featureBranch}:refs/remotes/origin/${featureBranch}`, | |
| 533 | ]); | |
| 534 | }), | |
| 535 | ); | |
| 536 | ||
| 537 | it.effect("returns checkout result before background upstream refresh completes", () => | |
| 538 | Effect.gen(function* () { | |
| 539 | const remote = yield* makeTmpDir(); | |
| 540 | const source = yield* makeTmpDir(); | |
| 541 | yield* git(remote, ["init", "--bare"]); | |
| 542 | ||
| 543 | yield* initRepoWithCommit(source); | |
| 544 | const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 545 | (branch) => branch.current, | |
| 546 | )!.name; | |
| 547 | yield* git(source, ["remote", "add", "origin", remote]); | |
| 548 | yield* git(source, ["push", "-u", "origin", defaultBranch]); | |
| 549 | ||
| 550 | const featureBranch = "feature/background-refresh"; | |
| 551 | yield* git(source, ["checkout", "-b", featureBranch]); | |
| 552 | yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); | |
| 553 | yield* git(source, ["add", "feature.txt"]); | |
| 554 | yield* git(source, ["commit", "-m", "feature base"]); | |
| 555 | yield* git(source, ["push", "-u", "origin", featureBranch]); | |
| 556 | yield* git(source, ["checkout", defaultBranch]); | |
| 557 | ||
| 558 | const realGitService = yield* GitService; | |
| 559 | let fetchStarted = false; | |
| 560 | let releaseFetch: () => void = () => undefined; | |
| 561 | const waitForReleasePromise = new Promise<void>((resolve) => { | |
| 562 | releaseFetch = resolve; | |
| 563 | }); | |
| 564 | const core = yield* makeIsolatedGitCore({ | |
| 565 | execute: (input) => { | |
| 566 | if (input.args[0] === "fetch") { | |
| 567 | fetchStarted = true; | |
| 568 | return Effect.promise(() => | |
| 569 | waitForReleasePromise.then(() => ({ code: 0, stdout: "", stderr: "" })), | |
| 570 | ); | |
| 571 | } | |
| 572 | return realGitService.execute(input); | |
| 573 | }, | |
| 574 | }); | |
| 575 | yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); | |
| 576 | yield* Effect.promise(() => | |
| 577 | vi.waitFor(() => { | |
| 578 | expect(fetchStarted).toBe(true); | |
| 579 | }), | |
| 580 | ); | |
| 581 | expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); | |
| 582 | releaseFetch(); | |
| 583 | }), | |
| 584 | ); | |
| 585 | ||
| 586 | it.effect("throws when branch does not exist", () => | |
| 587 | Effect.gen(function* () { | |
| 588 | const tmp = yield* makeTmpDir(); | |
| 589 | yield* initRepoWithCommit(tmp); | |
| 590 | const result = yield* Effect.result(checkoutGitBranch({ cwd: tmp, branch: "nonexistent" })); | |
| 591 | expect(result._tag).toBe("Failure"); | |
| 592 | }), | |
| 593 | ); | |
| 594 | ||
| 595 | it.effect("throws when checkout would overwrite uncommitted changes", () => | |
| 596 | Effect.gen(function* () { | |
| 597 | const tmp = yield* makeTmpDir(); | |
| 598 | yield* initRepoWithCommit(tmp); | |
| 599 | yield* createGitBranch({ cwd: tmp, branch: "other" }); | |
| 600 | ||
| 601 | // Create a conflicting change: modify README on current branch | |
| 602 | yield* writeTextFile(path.join(tmp, "README.md"), "modified\n"); | |
| 603 | yield* git(tmp, ["add", "README.md"]); | |
| 604 | ||
| 605 | // First, checkout other branch cleanly | |
| 606 | yield* git(tmp, ["stash"]); | |
| 607 | yield* checkoutGitBranch({ cwd: tmp, branch: "other" }); | |
| 608 | yield* writeTextFile(path.join(tmp, "README.md"), "other content\n"); | |
| 609 | yield* git(tmp, ["add", "."]); | |
| 610 | yield* git(tmp, ["commit", "-m", "other change"]); | |
| 611 | ||
| 612 | // Go back to default branch | |
| 613 | const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 614 | (b) => !b.current, | |
| 615 | )!.name; | |
| 616 | yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch }); | |
| 617 | ||
| 618 | // Make uncommitted changes to the same file | |
| 619 | yield* writeTextFile(path.join(tmp, "README.md"), "conflicting local\n"); | |
| 620 | ||
| 621 | // Checkout should fail due to uncommitted changes | |
| 622 | const result = yield* Effect.result(checkoutGitBranch({ cwd: tmp, branch: "other" })); | |
| 623 | expect(result._tag).toBe("Failure"); | |
| 624 | }), | |
| 625 | ); | |
| 626 | }); | |
| 627 | ||
| 628 | // ── createGitBranch ── | |
| 629 | ||
| 630 | describe("createGitBranch", () => { | |
| 631 | it.effect("creates a new branch visible in listGitBranches", () => | |
| 632 | Effect.gen(function* () { | |
| 633 | const tmp = yield* makeTmpDir(); | |
| 634 | yield* initRepoWithCommit(tmp); | |
| 635 | yield* createGitBranch({ cwd: tmp, branch: "new-feature" }); | |
| 636 | ||
| 637 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 638 | expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); | |
| 639 | }), | |
| 640 | ); | |
| 641 | ||
| 642 | it.effect("throws when branch already exists", () => | |
| 643 | Effect.gen(function* () { | |
| 644 | const tmp = yield* makeTmpDir(); | |
| 645 | yield* initRepoWithCommit(tmp); | |
| 646 | yield* createGitBranch({ cwd: tmp, branch: "dupe" }); | |
| 647 | const result = yield* Effect.result(createGitBranch({ cwd: tmp, branch: "dupe" })); | |
| 648 | expect(result._tag).toBe("Failure"); | |
| 649 | }), | |
| 650 | ); | |
| 651 | }); | |
| 652 | ||
| 653 | // ── createGitWorktree + removeGitWorktree ── | |
| 654 | ||
| 655 | describe("createGitWorktree", () => { | |
| 656 | it.effect("creates a worktree with a new branch from the base branch", () => | |
| 657 | Effect.gen(function* () { | |
| 658 | const tmp = yield* makeTmpDir(); | |
| 659 | yield* initRepoWithCommit(tmp); | |
| 660 | ||
| 661 | const wtPath = path.join(tmp, "worktree-out"); | |
| 662 | const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 663 | (b) => b.current, | |
| 664 | )!.name; | |
| 665 | ||
| 666 | const result = yield* createGitWorktree({ | |
| 667 | cwd: tmp, | |
| 668 | branch: currentBranch, | |
| 669 | newBranch: "wt-branch", | |
| 670 | path: wtPath, | |
| 671 | }); | |
| 672 | ||
| 673 | expect(result.worktree.path).toBe(wtPath); | |
| 674 | expect(result.worktree.branch).toBe("wt-branch"); | |
| 675 | expect(existsSync(wtPath)).toBe(true); | |
| 676 | expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); | |
| 677 | ||
| 678 | // Clean up worktree before tmp dir disposal | |
| 679 | yield* removeGitWorktree({ cwd: tmp, path: wtPath }); | |
| 680 | }), | |
| 681 | ); | |
| 682 | ||
| 683 | it.effect("worktree has the new branch checked out", () => | |
| 684 | Effect.gen(function* () { | |
| 685 | const tmp = yield* makeTmpDir(); | |
| 686 | yield* initRepoWithCommit(tmp); | |
| 687 | ||
| 688 | const wtPath = path.join(tmp, "wt-check-dir"); | |
| 689 | const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 690 | (b) => b.current, | |
| 691 | )!.name; | |
| 692 | ||
| 693 | yield* createGitWorktree({ | |
| 694 | cwd: tmp, | |
| 695 | branch: currentBranch, | |
| 696 | newBranch: "wt-check", | |
| 697 | path: wtPath, | |
| 698 | }); | |
| 699 | ||
| 700 | // Verify the worktree is on the new branch | |
| 701 | const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); | |
| 702 | expect(branchOutput).toBe("wt-check"); | |
| 703 | ||
| 704 | yield* removeGitWorktree({ cwd: tmp, path: wtPath }); | |
| 705 | }), | |
| 706 | ); | |
| 707 | ||
| 708 | it.effect("throws when new branch name already exists", () => | |
| 709 | Effect.gen(function* () { | |
| 710 | const tmp = yield* makeTmpDir(); | |
| 711 | yield* initRepoWithCommit(tmp); | |
| 712 | yield* createGitBranch({ cwd: tmp, branch: "existing" }); | |
| 713 | ||
| 714 | const wtPath = path.join(tmp, "wt-conflict"); | |
| 715 | const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 716 | (b) => b.current, | |
| 717 | )!.name; | |
| 718 | ||
| 719 | const result = yield* Effect.result( | |
| 720 | createGitWorktree({ | |
| 721 | cwd: tmp, | |
| 722 | branch: currentBranch, | |
| 723 | newBranch: "existing", | |
| 724 | path: wtPath, | |
| 725 | }), | |
| 726 | ); | |
| 727 | expect(result._tag).toBe("Failure"); | |
| 728 | }), | |
| 729 | ); | |
| 730 | ||
| 731 | it.effect("listGitBranches from worktree cwd reports worktree branch as current", () => | |
| 732 | Effect.gen(function* () { | |
| 733 | const tmp = yield* makeTmpDir(); | |
| 734 | yield* initRepoWithCommit(tmp); | |
| 735 | ||
| 736 | const wtPath = path.join(tmp, "wt-list-dir"); | |
| 737 | const mainBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 738 | (b) => b.current, | |
| 739 | )!.name; | |
| 740 | ||
| 741 | yield* createGitWorktree({ | |
| 742 | cwd: tmp, | |
| 743 | branch: mainBranch, | |
| 744 | newBranch: "wt-list", | |
| 745 | path: wtPath, | |
| 746 | }); | |
| 747 | ||
| 748 | // listGitBranches from the worktree should show wt-list as current | |
| 749 | const wtBranches = yield* listGitBranches({ cwd: wtPath }); | |
| 750 | expect(wtBranches.isRepo).toBe(true); | |
| 751 | const wtCurrent = wtBranches.branches.find((b) => b.current); | |
| 752 | expect(wtCurrent!.name).toBe("wt-list"); | |
| 753 | ||
| 754 | // Main repo should still show the original branch as current | |
| 755 | const mainBranches = yield* listGitBranches({ cwd: tmp }); | |
| 756 | const mainCurrent = mainBranches.branches.find((b) => b.current); | |
| 757 | expect(mainCurrent!.name).toBe(mainBranch); | |
| 758 | ||
| 759 | yield* removeGitWorktree({ cwd: tmp, path: wtPath }); | |
| 760 | }), | |
| 761 | ); | |
| 762 | ||
| 763 | it.effect("removeGitWorktree cleans up the worktree", () => | |
| 764 | Effect.gen(function* () { | |
| 765 | const tmp = yield* makeTmpDir(); | |
| 766 | yield* initRepoWithCommit(tmp); | |
| 767 | ||
| 768 | const wtPath = path.join(tmp, "wt-remove-dir"); | |
| 769 | const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 770 | (b) => b.current, | |
| 771 | )!.name; | |
| 772 | ||
| 773 | yield* createGitWorktree({ | |
| 774 | cwd: tmp, | |
| 775 | branch: currentBranch, | |
| 776 | newBranch: "wt-remove", | |
| 777 | path: wtPath, | |
| 778 | }); | |
| 779 | expect(existsSync(wtPath)).toBe(true); | |
| 780 | ||
| 781 | yield* removeGitWorktree({ cwd: tmp, path: wtPath }); | |
| 782 | expect(existsSync(wtPath)).toBe(false); | |
| 783 | }), | |
| 784 | ); | |
| 785 | ||
| 786 | it.effect("removeGitWorktree force removes a dirty worktree", () => | |
| 787 | Effect.gen(function* () { | |
| 788 | const tmp = yield* makeTmpDir(); | |
| 789 | yield* initRepoWithCommit(tmp); | |
| 790 | ||
| 791 | const wtPath = path.join(tmp, "wt-dirty-dir"); | |
| 792 | const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 793 | (b) => b.current, | |
| 794 | )!.name; | |
| 795 | ||
| 796 | yield* createGitWorktree({ | |
| 797 | cwd: tmp, | |
| 798 | branch: currentBranch, | |
| 799 | newBranch: "wt-dirty", | |
| 800 | path: wtPath, | |
| 801 | }); | |
| 802 | expect(existsSync(wtPath)).toBe(true); | |
| 803 | ||
| 804 | yield* writeTextFile(path.join(wtPath, "README.md"), "dirty change\n"); | |
| 805 | ||
| 806 | const failedRemove = yield* Effect.result(removeGitWorktree({ cwd: tmp, path: wtPath })); | |
| 807 | expect(failedRemove._tag).toBe("Failure"); | |
| 808 | expect(existsSync(wtPath)).toBe(true); | |
| 809 | ||
| 810 | yield* removeGitWorktree({ cwd: tmp, path: wtPath, force: true }); | |
| 811 | expect(existsSync(wtPath)).toBe(false); | |
| 812 | }), | |
| 813 | ); | |
| 814 | }); | |
| 815 | ||
| 816 | // ── Full flow: local branch checkout ── | |
| 817 | ||
| 818 | describe("full flow: local branch checkout", () => { | |
| 819 | it.effect("init → commit → create branch → checkout → verify current", () => | |
| 820 | Effect.gen(function* () { | |
| 821 | const tmp = yield* makeTmpDir(); | |
| 822 | yield* initRepoWithCommit(tmp); | |
| 823 | yield* createGitBranch({ cwd: tmp, branch: "feature-login" }); | |
| 824 | yield* checkoutGitBranch({ cwd: tmp, branch: "feature-login" }); | |
| 825 | ||
| 826 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 827 | const current = result.branches.find((b) => b.current); | |
| 828 | expect(current!.name).toBe("feature-login"); | |
| 829 | }), | |
| 830 | ); | |
| 831 | }); | |
| 832 | ||
| 833 | // ── Full flow: worktree creation from base branch ── | |
| 834 | ||
| 835 | describe("full flow: worktree creation", () => { | |
| 836 | it.effect("creates worktree with new branch from current branch", () => | |
| 837 | Effect.gen(function* () { | |
| 838 | const tmp = yield* makeTmpDir(); | |
| 839 | yield* initRepoWithCommit(tmp); | |
| 840 | ||
| 841 | const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 842 | (b) => b.current, | |
| 843 | )!.name; | |
| 844 | ||
| 845 | const wtPath = path.join(tmp, "my-worktree"); | |
| 846 | const result = yield* createGitWorktree({ | |
| 847 | cwd: tmp, | |
| 848 | branch: currentBranch, | |
| 849 | newBranch: "feature-wt", | |
| 850 | path: wtPath, | |
| 851 | }); | |
| 852 | ||
| 853 | // Worktree exists | |
| 854 | expect(existsSync(result.worktree.path)).toBe(true); | |
| 855 | ||
| 856 | // Main repo still on original branch | |
| 857 | const mainBranches = yield* listGitBranches({ cwd: tmp }); | |
| 858 | const mainCurrent = mainBranches.branches.find((b) => b.current); | |
| 859 | expect(mainCurrent!.name).toBe(currentBranch); | |
| 860 | ||
| 861 | // Worktree is on the new branch | |
| 862 | const wtBranch = yield* git(wtPath, ["branch", "--show-current"]); | |
| 863 | expect(wtBranch).toBe("feature-wt"); | |
| 864 | ||
| 865 | yield* removeGitWorktree({ cwd: tmp, path: wtPath }); | |
| 866 | }), | |
| 867 | ); | |
| 868 | }); | |
| 869 | ||
| 870 | // ── Full flow: thread switching simulation ── | |
| 871 | ||
| 872 | describe("full flow: thread switching (checkout toggling)", () => { | |
| 873 | it.effect("checkout a → checkout b → checkout a → current matches", () => | |
| 874 | Effect.gen(function* () { | |
| 875 | const tmp = yield* makeTmpDir(); | |
| 876 | yield* initRepoWithCommit(tmp); | |
| 877 | yield* createGitBranch({ cwd: tmp, branch: "branch-a" }); | |
| 878 | yield* createGitBranch({ cwd: tmp, branch: "branch-b" }); | |
| 879 | ||
| 880 | // Simulate switching to thread A's branch | |
| 881 | yield* checkoutGitBranch({ cwd: tmp, branch: "branch-a" }); | |
| 882 | let branches = yield* listGitBranches({ cwd: tmp }); | |
| 883 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); | |
| 884 | ||
| 885 | // Simulate switching to thread B's branch | |
| 886 | yield* checkoutGitBranch({ cwd: tmp, branch: "branch-b" }); | |
| 887 | branches = yield* listGitBranches({ cwd: tmp }); | |
| 888 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b"); | |
| 889 | ||
| 890 | // Switch back to thread A | |
| 891 | yield* checkoutGitBranch({ cwd: tmp, branch: "branch-a" }); | |
| 892 | branches = yield* listGitBranches({ cwd: tmp }); | |
| 893 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); | |
| 894 | }), | |
| 895 | ); | |
| 896 | }); | |
| 897 | ||
| 898 | // ── Full flow: checkout conflict ── | |
| 899 | ||
| 900 | describe("full flow: checkout conflict", () => { | |
| 901 | it.effect("uncommitted changes prevent checkout to a diverged branch", () => | |
| 902 | Effect.gen(function* () { | |
| 903 | const tmp = yield* makeTmpDir(); | |
| 904 | yield* initRepoWithCommit(tmp); | |
| 905 | yield* createGitBranch({ cwd: tmp, branch: "diverged" }); | |
| 906 | ||
| 907 | // Make diverged branch have different file content | |
| 908 | yield* checkoutGitBranch({ cwd: tmp, branch: "diverged" }); | |
| 909 | yield* writeTextFile(path.join(tmp, "README.md"), "diverged content\n"); | |
| 910 | yield* git(tmp, ["add", "."]); | |
| 911 | yield* git(tmp, ["commit", "-m", "diverge"]); | |
| 912 | ||
| 913 | // Actually, let's just get back to the initial branch explicitly | |
| 914 | const allBranches = yield* listGitBranches({ cwd: tmp }); | |
| 915 | const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name; | |
| 916 | yield* checkoutGitBranch({ cwd: tmp, branch: initialBranch }); | |
| 917 | ||
| 918 | // Make local uncommitted changes to the same file | |
| 919 | yield* writeTextFile(path.join(tmp, "README.md"), "local uncommitted\n"); | |
| 920 | ||
| 921 | // Attempt checkout should fail | |
| 922 | const failedCheckout = yield* Effect.result( | |
| 923 | checkoutGitBranch({ cwd: tmp, branch: "diverged" }), | |
| 924 | ); | |
| 925 | expect(failedCheckout._tag).toBe("Failure"); | |
| 926 | ||
| 927 | // Current branch should still be the initial one | |
| 928 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 929 | expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); | |
| 930 | }), | |
| 931 | ); | |
| 932 | }); | |
| 933 | ||
| 934 | describe("GitCore", () => { | |
| 935 | it.effect("supports branch lifecycle operations through the service API", () => | |
| 936 | Effect.gen(function* () { | |
| 937 | const tmp = yield* makeTmpDir(); | |
| 938 | const core = yield* GitCore; | |
| 939 | ||
| 940 | yield* core.initRepo({ cwd: tmp }); | |
| 941 | yield* git(tmp, ["config", "user.email", "test@test.com"]); | |
| 942 | yield* git(tmp, ["config", "user.name", "Test"]); | |
| 943 | yield* writeTextFile(path.join(tmp, "README.md"), "# test\n"); | |
| 944 | yield* git(tmp, ["add", "."]); | |
| 945 | yield* git(tmp, ["commit", "-m", "initial commit"]); | |
| 946 | ||
| 947 | yield* core.createBranch({ cwd: tmp, branch: "feature/service-api" }); | |
| 948 | yield* core.checkoutBranch({ cwd: tmp, branch: "feature/service-api" }); | |
| 949 | const branches = yield* core.listBranches({ cwd: tmp }); | |
| 950 | ||
| 951 | expect(branches.isRepo).toBe(true); | |
| 952 | expect( | |
| 953 | branches.branches.find((branch: { current: boolean; name: string }) => branch.current) | |
| 954 | ?.name, | |
| 955 | ).toBe("feature/service-api"); | |
| 956 | }), | |
| 957 | ); | |
| 958 | ||
| 959 | it.effect("reports status details and dirty state", () => | |
| 960 | Effect.gen(function* () { | |
| 961 | const tmp = yield* makeTmpDir(); | |
| 962 | yield* initRepoWithCommit(tmp); | |
| 963 | const core = yield* GitCore; | |
| 964 | ||
| 965 | const clean = yield* core.status({ cwd: tmp }); | |
| 966 | expect(clean.hasWorkingTreeChanges).toBe(false); | |
| 967 | expect(clean.branch).toBeTruthy(); | |
| 968 | ||
| 969 | yield* writeTextFile(path.join(tmp, "README.md"), "updated\n"); | |
| 970 | const dirty = yield* core.statusDetails(tmp); | |
| 971 | expect(dirty.hasWorkingTreeChanges).toBe(true); | |
| 972 | }), | |
| 973 | ); | |
| 974 | ||
| 975 | it.effect("includes command context when worktree removal fails", () => | |
| 976 | Effect.gen(function* () { | |
| 977 | const tmp = yield* makeTmpDir(); | |
| 978 | yield* initRepoWithCommit(tmp); | |
| 979 | const core = yield* GitCore; | |
| 980 | const missingWorktreePath = path.join(tmp, "missing-worktree"); | |
| 981 | ||
| 982 | const removeResult = yield* Effect.result( | |
| 983 | core.removeWorktree({ cwd: tmp, path: missingWorktreePath }), | |
| 984 | ); | |
| 985 | expect(removeResult._tag).toBe("Failure"); | |
| 986 | if (removeResult._tag !== "Failure") { | |
| 987 | return; | |
| 988 | } | |
| 989 | const message = removeResult.failure.message; | |
| 990 | expect(message).toContain("git worktree remove"); | |
| 991 | expect(message).toContain(`cwd: ${tmp}`); | |
| 992 | expect(message).toContain(missingWorktreePath); | |
| 993 | }), | |
| 994 | ); | |
| 995 | ||
| 996 | it.effect( | |
| 997 | "refreshes upstream before statusDetails so behind count reflects remote updates", | |
| 998 | () => | |
| 999 | Effect.gen(function* () { | |
| 1000 | const remote = yield* makeTmpDir(); | |
| 1001 | const source = yield* makeTmpDir(); | |
| 1002 | const clone = yield* makeTmpDir(); | |
| 1003 | yield* git(remote, ["init", "--bare"]); | |
| 1004 | ||
| 1005 | yield* initRepoWithCommit(source); | |
| 1006 | const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 1007 | (branch) => branch.current, | |
| 1008 | )!.name; | |
| 1009 | yield* git(source, ["remote", "add", "origin", remote]); | |
| 1010 | yield* git(source, ["push", "-u", "origin", initialBranch]); | |
| 1011 | ||
| 1012 | yield* git(clone, ["clone", remote, "."]); | |
| 1013 | yield* git(clone, ["config", "user.email", "test@test.com"]); | |
| 1014 | yield* git(clone, ["config", "user.name", "Test"]); | |
| 1015 | yield* git(clone, [ | |
| 1016 | "checkout", | |
| 1017 | "-B", | |
| 1018 | initialBranch, | |
| 1019 | "--track", | |
| 1020 | `origin/${initialBranch}`, | |
| 1021 | ]); | |
| 1022 | yield* writeTextFile(path.join(clone, "CHANGELOG.md"), "remote change\n"); | |
| 1023 | yield* git(clone, ["add", "CHANGELOG.md"]); | |
| 1024 | yield* git(clone, ["commit", "-m", "remote update"]); | |
| 1025 | yield* git(clone, ["push", "origin", initialBranch]); | |
| 1026 | ||
| 1027 | const core = yield* GitCore; | |
| 1028 | const details = yield* core.statusDetails(source); | |
| 1029 | expect(details.branch).toBe(initialBranch); | |
| 1030 | expect(details.aheadCount).toBe(0); | |
| 1031 | expect(details.behindCount).toBe(1); | |
| 1032 | }), | |
| 1033 | ); | |
| 1034 | ||
| 1035 | it.effect("prepares commit context by auto-staging and creates commit", () => | |
| 1036 | Effect.gen(function* () { | |
| 1037 | const tmp = yield* makeTmpDir(); | |
| 1038 | yield* initRepoWithCommit(tmp); | |
| 1039 | const core = yield* GitCore; | |
| 1040 | ||
| 1041 | yield* writeTextFile(path.join(tmp, "README.md"), "new content\n"); | |
| 1042 | const context = yield* core.prepareCommitContext(tmp); | |
| 1043 | expect(context).not.toBeNull(); | |
| 1044 | expect(context!.stagedSummary.length).toBeGreaterThan(0); | |
| 1045 | expect(context!.stagedPatch.length).toBeGreaterThan(0); | |
| 1046 | ||
| 1047 | const created = yield* core.commit(tmp, "Add README update", "- include updated content"); | |
| 1048 | expect(created.commitSha.length).toBeGreaterThan(0); | |
| 1049 | expect(yield* git(tmp, ["log", "-1", "--pretty=%s"])).toBe("Add README update"); | |
| 1050 | }), | |
| 1051 | ); | |
| 1052 | ||
| 1053 | it.effect("pushes with upstream setup and then skips when up to date", () => | |
| 1054 | Effect.gen(function* () { | |
| 1055 | const tmp = yield* makeTmpDir(); | |
| 1056 | const remote = yield* makeTmpDir(); | |
| 1057 | yield* initRepoWithCommit(tmp); | |
| 1058 | yield* git(remote, ["init", "--bare"]); | |
| 1059 | yield* git(tmp, ["remote", "add", "origin", remote]); | |
| 1060 | yield* createGitBranch({ cwd: tmp, branch: "feature/core-push" }); | |
| 1061 | yield* checkoutGitBranch({ cwd: tmp, branch: "feature/core-push" }); | |
| 1062 | ||
| 1063 | yield* writeTextFile(path.join(tmp, "feature.txt"), "push me\n"); | |
| 1064 | const core = yield* GitCore; | |
| 1065 | const context = yield* core.prepareCommitContext(tmp); | |
| 1066 | expect(context).not.toBeNull(); | |
| 1067 | yield* core.commit(tmp, "Add feature file", ""); | |
| 1068 | ||
| 1069 | const pushed = yield* core.pushCurrentBranch(tmp, null); | |
| 1070 | expect(pushed.status).toBe("pushed"); | |
| 1071 | expect(pushed.setUpstream).toBe(true); | |
| 1072 | expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( | |
| 1073 | "origin/feature/core-push", | |
| 1074 | ); | |
| 1075 | ||
| 1076 | const skipped = yield* core.pushCurrentBranch(tmp, null); | |
| 1077 | expect(skipped.status).toBe("skipped_up_to_date"); | |
| 1078 | }), | |
| 1079 | ); | |
| 1080 | ||
| 1081 | it.effect("pulls behind branch and then reports up-to-date", () => | |
| 1082 | Effect.gen(function* () { | |
| 1083 | const remote = yield* makeTmpDir(); | |
| 1084 | const source = yield* makeTmpDir(); | |
| 1085 | const clone = yield* makeTmpDir(); | |
| 1086 | yield* git(remote, ["init", "--bare"]); | |
| 1087 | ||
| 1088 | yield* initRepoWithCommit(source); | |
| 1089 | const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 1090 | (branch) => branch.current, | |
| 1091 | )!.name; | |
| 1092 | yield* git(source, ["remote", "add", "origin", remote]); | |
| 1093 | yield* git(source, ["push", "-u", "origin", initialBranch]); | |
| 1094 | ||
| 1095 | yield* git(clone, ["clone", remote, "."]); | |
| 1096 | yield* git(clone, ["config", "user.email", "test@test.com"]); | |
| 1097 | yield* git(clone, ["config", "user.name", "Test"]); | |
| 1098 | yield* writeTextFile(path.join(clone, "CHANGELOG.md"), "remote change\n"); | |
| 1099 | yield* git(clone, ["add", "CHANGELOG.md"]); | |
| 1100 | yield* git(clone, ["commit", "-m", "remote update"]); | |
| 1101 | yield* git(clone, ["push", "origin", initialBranch]); | |
| 1102 | ||
| 1103 | const core = yield* GitCore; | |
| 1104 | const pulled = yield* core.pullCurrentBranch(source); | |
| 1105 | expect(pulled.status).toBe("pulled"); | |
| 1106 | expect((yield* core.statusDetails(source)).behindCount).toBe(0); | |
| 1107 | ||
| 1108 | const skipped = yield* core.pullCurrentBranch(source); | |
| 1109 | expect(skipped.status).toBe("skipped_up_to_date"); | |
| 1110 | }), | |
| 1111 | ); | |
| 1112 | ||
| 1113 | it.effect("top-level pullGitBranch rejects when no upstream exists", () => | |
| 1114 | Effect.gen(function* () { | |
| 1115 | const tmp = yield* makeTmpDir(); | |
| 1116 | yield* initRepoWithCommit(tmp); | |
| 1117 | const result = yield* Effect.result(pullGitBranch({ cwd: tmp })); | |
| 1118 | expect(result._tag).toBe("Failure"); | |
| 1119 | if (result._tag === "Failure") { | |
| 1120 | expect(result.failure.message.toLowerCase()).toContain("no upstream"); | |
| 1121 | } | |
| 1122 | }), | |
| 1123 | ); | |
| 1124 | ||
| 1125 | it.effect("lists branches when recency lookup fails", () => | |
| 1126 | Effect.gen(function* () { | |
| 1127 | const tmp = yield* makeTmpDir(); | |
| 1128 | yield* initRepoWithCommit(tmp); | |
| 1129 | const realGitService = yield* GitService; | |
| 1130 | let didFailRecency = false; | |
| 1131 | const core = yield* makeIsolatedGitCore({ | |
| 1132 | execute: (input) => { | |
| 1133 | if (!didFailRecency && input.args[0] === "for-each-ref") { | |
| 1134 | didFailRecency = true; | |
| 1135 | return Effect.fail( | |
| 1136 | new GitCommandError({ | |
| 1137 | operation: "git.test.listBranchesRecency", | |
| 1138 | command: `git ${input.args.join(" ")}`, | |
| 1139 | cwd: input.cwd, | |
| 1140 | detail: "timeout", | |
| 1141 | }), | |
| 1142 | ); | |
| 1143 | } | |
| 1144 | return realGitService.execute(input); | |
| 1145 | }, | |
| 1146 | }); | |
| 1147 | ||
| 1148 | const result = yield* core.listBranches({ cwd: tmp }); | |
| 1149 | ||
| 1150 | expect(result.isRepo).toBe(true); | |
| 1151 | expect(result.branches.length).toBeGreaterThan(0); | |
| 1152 | expect(result.branches[0]?.current).toBe(true); | |
| 1153 | expect(didFailRecency).toBe(true); | |
| 1154 | }), | |
| 1155 | ); | |
| 1156 | }); | |
| 1157 | }); |
apps/server/src/git/Layers/GitCore.ts created+746| ... | ... | @@ -0,0 +1,746 @@ |
| 1 | import { Effect, FileSystem, Layer, Path } from "effect"; | |
| 2 | ||
| 3 | import { GitCommandError } from "../Errors.ts"; | |
| 4 | import { GitService } from "../Services/GitService.ts"; | |
| 5 | import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; | |
| 6 | ||
| 7 | const STATUS_UPSTREAM_REFRESH_INTERVAL_MS = 15_000; | |
| 8 | const STATUS_UPSTREAM_REFRESH_TIMEOUT_MS = 5_000; | |
| 9 | ||
| 10 | interface ExecuteGitOptions { | |
| 11 | timeoutMs?: number | undefined; | |
| 12 | allowNonZeroExit?: boolean | undefined; | |
| 13 | fallbackErrorMessage?: string | undefined; | |
| 14 | } | |
| 15 | ||
| 16 | function trimStdout(value: string): string { | |
| 17 | return value.trim(); | |
| 18 | } | |
| 19 | ||
| 20 | function parseBranchAb(value: string): { ahead: number; behind: number } { | |
| 21 | const match = value.match(/^\+(\d+)\s+-(\d+)$/); | |
| 22 | if (!match) return { ahead: 0, behind: 0 }; | |
| 23 | return { | |
| 24 | ahead: Number(match[1] ?? "0"), | |
| 25 | behind: Number(match[2] ?? "0"), | |
| 26 | }; | |
| 27 | } | |
| 28 | ||
| 29 | function parseNumstatEntries( | |
| 30 | stdout: string, | |
| 31 | ): Array<{ path: string; insertions: number; deletions: number }> { | |
| 32 | const entries: Array<{ path: string; insertions: number; deletions: number }> = []; | |
| 33 | for (const line of stdout.split(/\r?\n/g)) { | |
| 34 | if (line.trim().length === 0) continue; | |
| 35 | const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); | |
| 36 | const rawPath = | |
| 37 | pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); | |
| 38 | if (rawPath.length === 0) continue; | |
| 39 | const added = Number.parseInt(addedRaw ?? "0", 10); | |
| 40 | const deleted = Number.parseInt(deletedRaw ?? "0", 10); | |
| 41 | const renameArrowIndex = rawPath.indexOf(" => "); | |
| 42 | const normalizedPath = | |
| 43 | renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; | |
| 44 | entries.push({ | |
| 45 | path: normalizedPath.length > 0 ? normalizedPath : rawPath, | |
| 46 | insertions: Number.isFinite(added) ? added : 0, | |
| 47 | deletions: Number.isFinite(deleted) ? deleted : 0, | |
| 48 | }); | |
| 49 | } | |
| 50 | return entries; | |
| 51 | } | |
| 52 | ||
| 53 | function parsePorcelainPath(line: string): string | null { | |
| 54 | if (line.startsWith("? ") || line.startsWith("! ")) { | |
| 55 | const simple = line.slice(2).trim(); | |
| 56 | return simple.length > 0 ? simple : null; | |
| 57 | } | |
| 58 | ||
| 59 | if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { | |
| 60 | return null; | |
| 61 | } | |
| 62 | ||
| 63 | const tabIndex = line.indexOf("\t"); | |
| 64 | if (tabIndex >= 0) { | |
| 65 | const fromTab = line.slice(tabIndex + 1); | |
| 66 | const [filePath] = fromTab.split("\t"); | |
| 67 | return filePath?.trim().length ? filePath.trim() : null; | |
| 68 | } | |
| 69 | ||
| 70 | const parts = line.trim().split(/\s+/g); | |
| 71 | const filePath = parts.at(-1) ?? ""; | |
| 72 | return filePath.length > 0 ? filePath : null; | |
| 73 | } | |
| 74 | ||
| 75 | function commandLabel(args: readonly string[]): string { | |
| 76 | return `git ${args.join(" ")}`; | |
| 77 | } | |
| 78 | ||
| 79 | function createGitCommandError( | |
| 80 | operation: string, | |
| 81 | cwd: string, | |
| 82 | args: readonly string[], | |
| 83 | detail: string, | |
| 84 | cause?: unknown, | |
| 85 | ): GitCommandError { | |
| 86 | return new GitCommandError({ | |
| 87 | operation, | |
| 88 | command: commandLabel(args), | |
| 89 | cwd, | |
| 90 | detail, | |
| 91 | ...(cause !== undefined ? { cause } : {}), | |
| 92 | }); | |
| 93 | } | |
| 94 | ||
| 95 | const makeGitCore = Effect.gen(function* () { | |
| 96 | const git = yield* GitService; | |
| 97 | const fileSystem = yield* FileSystem.FileSystem; | |
| 98 | const path = yield* Path.Path; | |
| 99 | const lastUpstreamRefreshAt = new Map<string, number>(); | |
| 100 | ||
| 101 | const executeGit = ( | |
| 102 | operation: string, | |
| 103 | cwd: string, | |
| 104 | args: readonly string[], | |
| 105 | options: ExecuteGitOptions = {}, | |
| 106 | ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> => | |
| 107 | git | |
| 108 | .execute({ | |
| 109 | operation, | |
| 110 | cwd, | |
| 111 | args, | |
| 112 | allowNonZeroExit: true, | |
| 113 | ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), | |
| 114 | }) | |
| 115 | .pipe( | |
| 116 | Effect.flatMap((result) => { | |
| 117 | if (options.allowNonZeroExit || result.code === 0) { | |
| 118 | return Effect.succeed(result); | |
| 119 | } | |
| 120 | const stderr = result.stderr.trim(); | |
| 121 | if (stderr.length > 0) { | |
| 122 | return Effect.fail(createGitCommandError(operation, cwd, args, stderr)); | |
| 123 | } | |
| 124 | if (options.fallbackErrorMessage) { | |
| 125 | return Effect.fail( | |
| 126 | createGitCommandError(operation, cwd, args, options.fallbackErrorMessage), | |
| 127 | ); | |
| 128 | } | |
| 129 | return Effect.fail( | |
| 130 | createGitCommandError( | |
| 131 | operation, | |
| 132 | cwd, | |
| 133 | args, | |
| 134 | `${commandLabel(args)} failed: code=${result.code ?? "null"}`, | |
| 135 | ), | |
| 136 | ); | |
| 137 | }), | |
| 138 | ); | |
| 139 | ||
| 140 | const runGit = ( | |
| 141 | operation: string, | |
| 142 | cwd: string, | |
| 143 | args: readonly string[], | |
| 144 | allowNonZeroExit = false, | |
| 145 | ): Effect.Effect<void, GitCommandError> => | |
| 146 | executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid); | |
| 147 | ||
| 148 | const runGitStdout = ( | |
| 149 | operation: string, | |
| 150 | cwd: string, | |
| 151 | args: readonly string[], | |
| 152 | allowNonZeroExit = false, | |
| 153 | ): Effect.Effect<string, GitCommandError> => | |
| 154 | executeGit(operation, cwd, args, { allowNonZeroExit }).pipe( | |
| 155 | Effect.map((result) => result.stdout), | |
| 156 | ); | |
| 157 | ||
| 158 | const resolveCurrentUpstream = ( | |
| 159 | cwd: string, | |
| 160 | ): Effect.Effect< | |
| 161 | { upstreamRef: string; remoteName: string; upstreamBranch: string } | null, | |
| 162 | GitCommandError | |
| 163 | > => | |
| 164 | Effect.gen(function* () { | |
| 165 | const upstreamRef = trimStdout( | |
| 166 | yield* runGitStdout( | |
| 167 | "GitCore.resolveCurrentUpstream", | |
| 168 | cwd, | |
| 169 | ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], | |
| 170 | true, | |
| 171 | ), | |
| 172 | ); | |
| 173 | if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { | |
| 174 | return null; | |
| 175 | } | |
| 176 | ||
| 177 | const separatorIndex = upstreamRef.indexOf("/"); | |
| 178 | if (separatorIndex <= 0) { | |
| 179 | return null; | |
| 180 | } | |
| 181 | const remoteName = upstreamRef.slice(0, separatorIndex); | |
| 182 | const upstreamBranch = upstreamRef.slice(separatorIndex + 1); | |
| 183 | if (remoteName.length === 0 || upstreamBranch.length === 0) { | |
| 184 | return null; | |
| 185 | } | |
| 186 | ||
| 187 | return { | |
| 188 | upstreamRef, | |
| 189 | remoteName, | |
| 190 | upstreamBranch, | |
| 191 | }; | |
| 192 | }); | |
| 193 | ||
| 194 | const fetchUpstreamRef = ( | |
| 195 | cwd: string, | |
| 196 | upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, | |
| 197 | ): Effect.Effect<void, GitCommandError> => { | |
| 198 | const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; | |
| 199 | return runGit( | |
| 200 | "GitCore.fetchUpstreamRef", | |
| 201 | cwd, | |
| 202 | ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], | |
| 203 | true, | |
| 204 | ); | |
| 205 | }; | |
| 206 | ||
| 207 | const fetchUpstreamRefForStatus = ( | |
| 208 | cwd: string, | |
| 209 | upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, | |
| 210 | ): Effect.Effect<void, GitCommandError> => { | |
| 211 | const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; | |
| 212 | return executeGit( | |
| 213 | "GitCore.fetchUpstreamRefForStatus", | |
| 214 | cwd, | |
| 215 | ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], | |
| 216 | { | |
| 217 | allowNonZeroExit: true, | |
| 218 | timeoutMs: STATUS_UPSTREAM_REFRESH_TIMEOUT_MS, | |
| 219 | }, | |
| 220 | ).pipe(Effect.asVoid); | |
| 221 | }; | |
| 222 | ||
| 223 | const refreshStatusUpstreamIfStale = (cwd: string): Effect.Effect<void, GitCommandError> => | |
| 224 | Effect.gen(function* () { | |
| 225 | const upstream = yield* resolveCurrentUpstream(cwd); | |
| 226 | if (!upstream) { | |
| 227 | return; | |
| 228 | } | |
| 229 | ||
| 230 | const cacheKey = `${cwd}\u0000${upstream.upstreamRef}`; | |
| 231 | const now = Date.now(); | |
| 232 | const lastRefreshAt = lastUpstreamRefreshAt.get(cacheKey) ?? 0; | |
| 233 | if (now - lastRefreshAt < STATUS_UPSTREAM_REFRESH_INTERVAL_MS) { | |
| 234 | return; | |
| 235 | } | |
| 236 | ||
| 237 | // Record attempt time first to avoid repeated fetch spikes when status is polled often. | |
| 238 | lastUpstreamRefreshAt.set(cacheKey, now); | |
| 239 | yield* fetchUpstreamRefForStatus(cwd, upstream); | |
| 240 | }); | |
| 241 | ||
| 242 | const refreshCheckedOutBranchUpstream = (cwd: string): Effect.Effect<void, GitCommandError> => | |
| 243 | Effect.gen(function* () { | |
| 244 | const upstream = yield* resolveCurrentUpstream(cwd); | |
| 245 | if (!upstream) { | |
| 246 | return; | |
| 247 | } | |
| 248 | yield* fetchUpstreamRef(cwd, upstream); | |
| 249 | }); | |
| 250 | ||
| 251 | const readBranchRecency = (cwd: string): Effect.Effect<Map<string, number>, GitCommandError> => | |
| 252 | Effect.gen(function* () { | |
| 253 | const branchRecency = yield* executeGit( | |
| 254 | "GitCore.readBranchRecency", | |
| 255 | cwd, | |
| 256 | ["for-each-ref", "--format=%(refname:short)%09%(committerdate:unix)", "refs/heads"], | |
| 257 | { | |
| 258 | timeoutMs: 15_000, | |
| 259 | allowNonZeroExit: true, | |
| 260 | }, | |
| 261 | ); | |
| 262 | ||
| 263 | const branchLastCommit = new Map<string, number>(); | |
| 264 | if (branchRecency.code !== 0) { | |
| 265 | return branchLastCommit; | |
| 266 | } | |
| 267 | ||
| 268 | for (const line of branchRecency.stdout.split("\n")) { | |
| 269 | if (line.length === 0) { | |
| 270 | continue; | |
| 271 | } | |
| 272 | const [name, lastCommitRaw] = line.split("\t"); | |
| 273 | if (!name) { | |
| 274 | continue; | |
| 275 | } | |
| 276 | const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); | |
| 277 | branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); | |
| 278 | } | |
| 279 | ||
| 280 | return branchLastCommit; | |
| 281 | }); | |
| 282 | ||
| 283 | const statusDetails: GitCoreShape["statusDetails"] = (cwd) => | |
| 284 | Effect.gen(function* () { | |
| 285 | yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.catch(() => Effect.void)); | |
| 286 | ||
| 287 | const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all( | |
| 288 | [ | |
| 289 | runGitStdout("GitCore.statusDetails.status", cwd, [ | |
| 290 | "status", | |
| 291 | "--porcelain=2", | |
| 292 | "--branch", | |
| 293 | ]), | |
| 294 | runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), | |
| 295 | runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [ | |
| 296 | "diff", | |
| 297 | "--cached", | |
| 298 | "--numstat", | |
| 299 | ]), | |
| 300 | ], | |
| 301 | { concurrency: "unbounded" }, | |
| 302 | ); | |
| 303 | ||
| 304 | let branch: string | null = null; | |
| 305 | let upstreamRef: string | null = null; | |
| 306 | let aheadCount = 0; | |
| 307 | let behindCount = 0; | |
| 308 | let hasWorkingTreeChanges = false; | |
| 309 | const changedFilesWithoutNumstat = new Set<string>(); | |
| 310 | ||
| 311 | for (const line of statusStdout.split(/\r?\n/g)) { | |
| 312 | if (line.startsWith("# branch.head ")) { | |
| 313 | const value = line.slice("# branch.head ".length).trim(); | |
| 314 | branch = value.startsWith("(") ? null : value; | |
| 315 | continue; | |
| 316 | } | |
| 317 | if (line.startsWith("# branch.upstream ")) { | |
| 318 | const value = line.slice("# branch.upstream ".length).trim(); | |
| 319 | upstreamRef = value.length > 0 ? value : null; | |
| 320 | continue; | |
| 321 | } | |
| 322 | if (line.startsWith("# branch.ab ")) { | |
| 323 | const value = line.slice("# branch.ab ".length).trim(); | |
| 324 | const parsed = parseBranchAb(value); | |
| 325 | aheadCount = parsed.ahead; | |
| 326 | behindCount = parsed.behind; | |
| 327 | continue; | |
| 328 | } | |
| 329 | if (line.trim().length > 0 && !line.startsWith("#")) { | |
| 330 | hasWorkingTreeChanges = true; | |
| 331 | const pathValue = parsePorcelainPath(line); | |
| 332 | if (pathValue) changedFilesWithoutNumstat.add(pathValue); | |
| 333 | } | |
| 334 | } | |
| 335 | const stagedEntries = parseNumstatEntries(stagedNumstatStdout); | |
| 336 | const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); | |
| 337 | const fileStatMap = new Map<string, { insertions: number; deletions: number }>(); | |
| 338 | for (const entry of [...stagedEntries, ...unstagedEntries]) { | |
| 339 | const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; | |
| 340 | existing.insertions += entry.insertions; | |
| 341 | existing.deletions += entry.deletions; | |
| 342 | fileStatMap.set(entry.path, existing); | |
| 343 | } | |
| 344 | ||
| 345 | let insertions = 0; | |
| 346 | let deletions = 0; | |
| 347 | const files = Array.from(fileStatMap.entries()) | |
| 348 | .map(([filePath, stat]) => { | |
| 349 | insertions += stat.insertions; | |
| 350 | deletions += stat.deletions; | |
| 351 | return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; | |
| 352 | }) | |
| 353 | .toSorted((a, b) => a.path.localeCompare(b.path)); | |
| 354 | ||
| 355 | for (const filePath of changedFilesWithoutNumstat) { | |
| 356 | if (fileStatMap.has(filePath)) continue; | |
| 357 | files.push({ path: filePath, insertions: 0, deletions: 0 }); | |
| 358 | } | |
| 359 | files.sort((a, b) => a.path.localeCompare(b.path)); | |
| 360 | ||
| 361 | return { | |
| 362 | branch, | |
| 363 | upstreamRef, | |
| 364 | hasWorkingTreeChanges, | |
| 365 | workingTree: { | |
| 366 | files, | |
| 367 | insertions, | |
| 368 | deletions, | |
| 369 | }, | |
| 370 | hasUpstream: upstreamRef !== null, | |
| 371 | aheadCount, | |
| 372 | behindCount, | |
| 373 | }; | |
| 374 | }); | |
| 375 | ||
| 376 | const status: GitCoreShape["status"] = (input) => | |
| 377 | statusDetails(input.cwd).pipe( | |
| 378 | Effect.map((details) => ({ | |
| 379 | branch: details.branch, | |
| 380 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | |
| 381 | workingTree: details.workingTree, | |
| 382 | hasUpstream: details.hasUpstream, | |
| 383 | aheadCount: details.aheadCount, | |
| 384 | behindCount: details.behindCount, | |
| 385 | openPr: null, | |
| 386 | })), | |
| 387 | ); | |
| 388 | ||
| 389 | const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd) => | |
| 390 | Effect.gen(function* () { | |
| 391 | yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); | |
| 392 | ||
| 393 | const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [ | |
| 394 | "diff", | |
| 395 | "--cached", | |
| 396 | "--name-status", | |
| 397 | ]); | |
| 398 | if (trimStdout(stagedSummary).length === 0) { | |
| 399 | return null; | |
| 400 | } | |
| 401 | ||
| 402 | const stagedPatch = yield* runGitStdout("GitCore.prepareCommitContext.stagedPatch", cwd, [ | |
| 403 | "diff", | |
| 404 | "--cached", | |
| 405 | "--patch", | |
| 406 | "--minimal", | |
| 407 | ]); | |
| 408 | ||
| 409 | return { | |
| 410 | stagedSummary, | |
| 411 | stagedPatch, | |
| 412 | }; | |
| 413 | }); | |
| 414 | ||
| 415 | const commit: GitCoreShape["commit"] = (cwd, subject, body) => | |
| 416 | Effect.gen(function* () { | |
| 417 | const args = ["commit", "-m", subject]; | |
| 418 | const trimmedBody = body.trim(); | |
| 419 | if (trimmedBody.length > 0) { | |
| 420 | args.push("-m", trimmedBody); | |
| 421 | } | |
| 422 | yield* runGit("GitCore.commit.commit", cwd, args); | |
| 423 | const commitSha = trimStdout( | |
| 424 | yield* runGitStdout("GitCore.commit.revParseHead", cwd, ["rev-parse", "HEAD"]), | |
| 425 | ); | |
| 426 | return { commitSha }; | |
| 427 | }); | |
| 428 | ||
| 429 | const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = (cwd, fallbackBranch) => | |
| 430 | Effect.gen(function* () { | |
| 431 | const details = yield* statusDetails(cwd); | |
| 432 | const branch = details.branch ?? fallbackBranch; | |
| 433 | if (!branch) { | |
| 434 | return yield* createGitCommandError( | |
| 435 | "GitCore.pushCurrentBranch", | |
| 436 | cwd, | |
| 437 | ["push"], | |
| 438 | "Cannot push from detached HEAD.", | |
| 439 | ); | |
| 440 | } | |
| 441 | ||
| 442 | if (details.hasUpstream && details.aheadCount === 0 && details.behindCount === 0) { | |
| 443 | return { | |
| 444 | status: "skipped_up_to_date" as const, | |
| 445 | branch, | |
| 446 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | |
| 447 | }; | |
| 448 | } | |
| 449 | ||
| 450 | if (!details.hasUpstream) { | |
| 451 | yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [ | |
| 452 | "push", | |
| 453 | "-u", | |
| 454 | "origin", | |
| 455 | branch, | |
| 456 | ]); | |
| 457 | return { | |
| 458 | status: "pushed" as const, | |
| 459 | branch, | |
| 460 | upstreamBranch: `origin/${branch}`, | |
| 461 | setUpstream: true, | |
| 462 | }; | |
| 463 | } | |
| 464 | ||
| 465 | yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]); | |
| 466 | return { | |
| 467 | status: "pushed" as const, | |
| 468 | branch, | |
| 469 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | |
| 470 | setUpstream: false, | |
| 471 | }; | |
| 472 | }); | |
| 473 | ||
| 474 | const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = (cwd) => | |
| 475 | Effect.gen(function* () { | |
| 476 | const details = yield* statusDetails(cwd); | |
| 477 | const branch = details.branch; | |
| 478 | if (!branch) { | |
| 479 | return yield* createGitCommandError( | |
| 480 | "GitCore.pullCurrentBranch", | |
| 481 | cwd, | |
| 482 | ["pull", "--ff-only"], | |
| 483 | "Cannot pull from detached HEAD.", | |
| 484 | ); | |
| 485 | } | |
| 486 | if (!details.hasUpstream) { | |
| 487 | return yield* createGitCommandError( | |
| 488 | "GitCore.pullCurrentBranch", | |
| 489 | cwd, | |
| 490 | ["pull", "--ff-only"], | |
| 491 | "Current branch has no upstream configured. Push with upstream first.", | |
| 492 | ); | |
| 493 | } | |
| 494 | const beforeSha = trimStdout( | |
| 495 | yield* runGitStdout( | |
| 496 | "GitCore.pullCurrentBranch.beforeSha", | |
| 497 | cwd, | |
| 498 | ["rev-parse", "HEAD"], | |
| 499 | true, | |
| 500 | ), | |
| 501 | ); | |
| 502 | yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { | |
| 503 | timeoutMs: 30_000, | |
| 504 | fallbackErrorMessage: "git pull failed", | |
| 505 | }); | |
| 506 | const afterSha = trimStdout( | |
| 507 | yield* runGitStdout("GitCore.pullCurrentBranch.afterSha", cwd, ["rev-parse", "HEAD"], true), | |
| 508 | ); | |
| 509 | const refreshed = yield* statusDetails(cwd); | |
| 510 | return { | |
| 511 | status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", | |
| 512 | branch, | |
| 513 | upstreamBranch: refreshed.upstreamRef, | |
| 514 | }; | |
| 515 | }); | |
| 516 | ||
| 517 | const readRangeContext: GitCoreShape["readRangeContext"] = (cwd, baseBranch) => | |
| 518 | Effect.gen(function* () { | |
| 519 | const range = `${baseBranch}..HEAD`; | |
| 520 | const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( | |
| 521 | [ | |
| 522 | runGitStdout("GitCore.readRangeContext.log", cwd, ["log", "--oneline", range]), | |
| 523 | runGitStdout("GitCore.readRangeContext.diffStat", cwd, ["diff", "--stat", range]), | |
| 524 | runGitStdout("GitCore.readRangeContext.diffPatch", cwd, [ | |
| 525 | "diff", | |
| 526 | "--patch", | |
| 527 | "--minimal", | |
| 528 | range, | |
| 529 | ]), | |
| 530 | ], | |
| 531 | { concurrency: "unbounded" }, | |
| 532 | ); | |
| 533 | ||
| 534 | return { | |
| 535 | commitSummary, | |
| 536 | diffSummary, | |
| 537 | diffPatch, | |
| 538 | }; | |
| 539 | }); | |
| 540 | ||
| 541 | const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) => | |
| 542 | runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe( | |
| 543 | Effect.map((stdout) => { | |
| 544 | const value = trimStdout(stdout); | |
| 545 | return value.length > 0 ? value : null; | |
| 546 | }), | |
| 547 | ); | |
| 548 | ||
| 549 | const listBranches: GitCoreShape["listBranches"] = (input) => | |
| 550 | Effect.gen(function* () { | |
| 551 | const branchRecencyPromise = readBranchRecency(input.cwd).pipe( | |
| 552 | Effect.catch(() => Effect.succeed(new Map<string, number>())), | |
| 553 | ); | |
| 554 | const result = yield* executeGit( | |
| 555 | "GitCore.listBranches.branchNoColor", | |
| 556 | input.cwd, | |
| 557 | ["branch", "--no-color"], | |
| 558 | { | |
| 559 | timeoutMs: 10_000, | |
| 560 | allowNonZeroExit: true, | |
| 561 | }, | |
| 562 | ); | |
| 563 | ||
| 564 | if (result.code !== 0) { | |
| 565 | const stderr = result.stderr.trim(); | |
| 566 | if (stderr.toLowerCase().includes("not a git repository")) { | |
| 567 | return { branches: [], isRepo: false }; | |
| 568 | } | |
| 569 | return yield* createGitCommandError( | |
| 570 | "GitCore.listBranches", | |
| 571 | input.cwd, | |
| 572 | ["branch", "--no-color"], | |
| 573 | stderr || "git branch failed", | |
| 574 | ); | |
| 575 | } | |
| 576 | ||
| 577 | const [defaultRef, worktreeList, branchLastCommit] = yield* Effect.all( | |
| 578 | [ | |
| 579 | executeGit( | |
| 580 | "GitCore.listBranches.defaultRef", | |
| 581 | input.cwd, | |
| 582 | ["symbolic-ref", "refs/remotes/origin/HEAD"], | |
| 583 | { | |
| 584 | timeoutMs: 5_000, | |
| 585 | allowNonZeroExit: true, | |
| 586 | }, | |
| 587 | ), | |
| 588 | executeGit( | |
| 589 | "GitCore.listBranches.worktreeList", | |
| 590 | input.cwd, | |
| 591 | ["worktree", "list", "--porcelain"], | |
| 592 | { | |
| 593 | timeoutMs: 5_000, | |
| 594 | allowNonZeroExit: true, | |
| 595 | }, | |
| 596 | ), | |
| 597 | branchRecencyPromise, | |
| 598 | ], | |
| 599 | { concurrency: "unbounded" }, | |
| 600 | ); | |
| 601 | ||
| 602 | const defaultBranch = | |
| 603 | defaultRef.code === 0 | |
| 604 | ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") | |
| 605 | : null; | |
| 606 | ||
| 607 | const worktreeMap = new Map<string, string>(); | |
| 608 | if (worktreeList.code === 0) { | |
| 609 | let currentPath: string | null = null; | |
| 610 | for (const line of worktreeList.stdout.split("\n")) { | |
| 611 | if (line.startsWith("worktree ")) { | |
| 612 | const candidatePath = line.slice("worktree ".length); | |
| 613 | const exists = yield* fileSystem.stat(candidatePath).pipe( | |
| 614 | Effect.map(() => true), | |
| 615 | Effect.catch(() => Effect.succeed(false)), | |
| 616 | ); | |
| 617 | currentPath = exists ? candidatePath : null; | |
| 618 | } else if (line.startsWith("branch refs/heads/") && currentPath) { | |
| 619 | worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); | |
| 620 | } else if (line === "") { | |
| 621 | currentPath = null; | |
| 622 | } | |
| 623 | } | |
| 624 | } | |
| 625 | ||
| 626 | const branches = result.stdout | |
| 627 | .split("\n") | |
| 628 | .map((line) => line.trim()) | |
| 629 | .filter((line) => line.length > 0) | |
| 630 | .map((line) => { | |
| 631 | const name = line.replace(/^[*+]\s+/, ""); | |
| 632 | return { | |
| 633 | name, | |
| 634 | current: line.startsWith("* "), | |
| 635 | isDefault: name === defaultBranch, | |
| 636 | worktreePath: worktreeMap.get(name) ?? null, | |
| 637 | }; | |
| 638 | }) | |
| 639 | .toSorted((a, b) => { | |
| 640 | const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; | |
| 641 | const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; | |
| 642 | if (aPriority !== bPriority) return aPriority - bPriority; | |
| 643 | ||
| 644 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; | |
| 645 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; | |
| 646 | if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; | |
| 647 | return a.name.localeCompare(b.name); | |
| 648 | }); | |
| 649 | ||
| 650 | return { branches, isRepo: true }; | |
| 651 | }); | |
| 652 | ||
| 653 | const createWorktree: GitCoreShape["createWorktree"] = (input) => | |
| 654 | Effect.gen(function* () { | |
| 655 | const sanitizedBranch = input.newBranch.replace(/\//g, "-"); | |
| 656 | const repoName = path.basename(input.cwd); | |
| 657 | const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp"; | |
| 658 | const worktreePath = | |
| 659 | input.path ?? path.join(homeDir, ".t3", "worktrees", repoName, sanitizedBranch); | |
| 660 | ||
| 661 | yield* executeGit( | |
| 662 | "GitCore.createWorktree", | |
| 663 | input.cwd, | |
| 664 | ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch], | |
| 665 | { | |
| 666 | fallbackErrorMessage: "git worktree add failed", | |
| 667 | }, | |
| 668 | ); | |
| 669 | ||
| 670 | return { | |
| 671 | worktree: { | |
| 672 | path: worktreePath, | |
| 673 | branch: input.newBranch, | |
| 674 | }, | |
| 675 | }; | |
| 676 | }); | |
| 677 | ||
| 678 | const removeWorktree: GitCoreShape["removeWorktree"] = (input) => | |
| 679 | Effect.gen(function* () { | |
| 680 | const args = ["worktree", "remove"]; | |
| 681 | if (input.force) { | |
| 682 | args.push("--force"); | |
| 683 | } | |
| 684 | args.push(input.path); | |
| 685 | yield* executeGit("GitCore.removeWorktree", input.cwd, args, { | |
| 686 | timeoutMs: 15_000, | |
| 687 | fallbackErrorMessage: "git worktree remove failed", | |
| 688 | }).pipe( | |
| 689 | Effect.mapError((error) => | |
| 690 | createGitCommandError( | |
| 691 | "GitCore.removeWorktree", | |
| 692 | input.cwd, | |
| 693 | args, | |
| 694 | `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`, | |
| 695 | error, | |
| 696 | ), | |
| 697 | ), | |
| 698 | ); | |
| 699 | }); | |
| 700 | ||
| 701 | const createBranch: GitCoreShape["createBranch"] = (input) => | |
| 702 | executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { | |
| 703 | timeoutMs: 10_000, | |
| 704 | fallbackErrorMessage: "git branch create failed", | |
| 705 | }).pipe(Effect.asVoid); | |
| 706 | ||
| 707 | const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) => | |
| 708 | Effect.gen(function* () { | |
| 709 | yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, ["checkout", input.branch], { | |
| 710 | timeoutMs: 10_000, | |
| 711 | fallbackErrorMessage: "git checkout failed", | |
| 712 | }); | |
| 713 | ||
| 714 | // Refresh upstream refs in the background so checkout remains responsive. | |
| 715 | yield* Effect.sync(() => { | |
| 716 | void Effect.runPromise( | |
| 717 | refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.catch(() => Effect.void)), | |
| 718 | ); | |
| 719 | }); | |
| 720 | }); | |
| 721 | ||
| 722 | const initRepo: GitCoreShape["initRepo"] = (input) => | |
| 723 | executeGit("GitCore.initRepo", input.cwd, ["init"], { | |
| 724 | timeoutMs: 10_000, | |
| 725 | fallbackErrorMessage: "git init failed", | |
| 726 | }).pipe(Effect.asVoid); | |
| 727 | ||
| 728 | return { | |
| 729 | status, | |
| 730 | statusDetails, | |
| 731 | prepareCommitContext, | |
| 732 | commit, | |
| 733 | pushCurrentBranch, | |
| 734 | pullCurrentBranch, | |
| 735 | readRangeContext, | |
| 736 | readConfigValue, | |
| 737 | listBranches, | |
| 738 | createWorktree, | |
| 739 | removeWorktree, | |
| 740 | createBranch, | |
| 741 | checkoutBranch, | |
| 742 | initRepo, | |
| 743 | } satisfies GitCoreShape; | |
| 744 | }); | |
| 745 | ||
| 746 | export const GitCoreLive = Layer.effect(GitCore, makeGitCore); |
apps/server/src/git/Layers/GitHubCli.ts created+178| ... | ... | @@ -0,0 +1,178 @@ |
| 1 | import { Effect, Layer } from "effect"; | |
| 2 | ||
| 3 | import { runProcess } from "../../processRunner"; | |
| 4 | import { GitHubCliError } from "../Errors.ts"; | |
| 5 | import { GitHubCli, type GitHubCliShape } from "../Services/GitHubCli.ts"; | |
| 6 | ||
| 7 | const DEFAULT_TIMEOUT_MS = 30_000; | |
| 8 | ||
| 9 | function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown): GitHubCliError { | |
| 10 | if (error instanceof Error) { | |
| 11 | if (error.message.includes("Command not found: gh")) { | |
| 12 | return new GitHubCliError({ | |
| 13 | operation, | |
| 14 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", | |
| 15 | cause: error, | |
| 16 | }); | |
| 17 | } | |
| 18 | ||
| 19 | const lower = error.message.toLowerCase(); | |
| 20 | if ( | |
| 21 | lower.includes("authentication failed") || | |
| 22 | lower.includes("not logged in") || | |
| 23 | lower.includes("gh auth login") || | |
| 24 | lower.includes("no oauth token") | |
| 25 | ) { | |
| 26 | return new GitHubCliError({ | |
| 27 | operation, | |
| 28 | detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", | |
| 29 | cause: error, | |
| 30 | }); | |
| 31 | } | |
| 32 | ||
| 33 | return new GitHubCliError({ | |
| 34 | operation, | |
| 35 | detail: `GitHub CLI command failed: ${error.message}`, | |
| 36 | cause: error, | |
| 37 | }); | |
| 38 | } | |
| 39 | ||
| 40 | return new GitHubCliError({ | |
| 41 | operation, | |
| 42 | detail: "GitHub CLI command failed.", | |
| 43 | cause: error, | |
| 44 | }); | |
| 45 | } | |
| 46 | ||
| 47 | function parseOpenPullRequests(raw: string): ReadonlyArray<{ | |
| 48 | number: number; | |
| 49 | title: string; | |
| 50 | url: string; | |
| 51 | baseRefName: string; | |
| 52 | headRefName: string; | |
| 53 | }> { | |
| 54 | const trimmed = raw.trim(); | |
| 55 | if (trimmed.length === 0) return []; | |
| 56 | ||
| 57 | const parsed: unknown = JSON.parse(trimmed); | |
| 58 | if (!Array.isArray(parsed)) { | |
| 59 | throw new Error("GitHub CLI returned non-array JSON."); | |
| 60 | } | |
| 61 | ||
| 62 | const result: Array<{ | |
| 63 | number: number; | |
| 64 | title: string; | |
| 65 | url: string; | |
| 66 | baseRefName: string; | |
| 67 | headRefName: string; | |
| 68 | }> = []; | |
| 69 | for (const entry of parsed) { | |
| 70 | if (!entry || typeof entry !== "object") { | |
| 71 | continue; | |
| 72 | } | |
| 73 | const record = entry as Record<string, unknown>; | |
| 74 | const number = record.number; | |
| 75 | const title = record.title; | |
| 76 | const url = record.url; | |
| 77 | const baseRefName = record.baseRefName; | |
| 78 | const headRefName = record.headRefName; | |
| 79 | if ( | |
| 80 | typeof number !== "number" || | |
| 81 | !Number.isInteger(number) || | |
| 82 | number <= 0 || | |
| 83 | typeof title !== "string" || | |
| 84 | typeof url !== "string" || | |
| 85 | typeof baseRefName !== "string" || | |
| 86 | typeof headRefName !== "string" | |
| 87 | ) { | |
| 88 | continue; | |
| 89 | } | |
| 90 | result.push({ | |
| 91 | number, | |
| 92 | title, | |
| 93 | url, | |
| 94 | baseRefName, | |
| 95 | headRefName, | |
| 96 | }); | |
| 97 | } | |
| 98 | ||
| 99 | return result; | |
| 100 | } | |
| 101 | ||
| 102 | const makeGitHubCli = Effect.sync(() => { | |
| 103 | const execute: GitHubCliShape["execute"] = (input) => | |
| 104 | Effect.tryPromise({ | |
| 105 | try: () => | |
| 106 | runProcess("gh", input.args, { | |
| 107 | cwd: input.cwd, | |
| 108 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, | |
| 109 | }), | |
| 110 | catch: (error) => normalizeGitHubCliError("execute", error), | |
| 111 | }); | |
| 112 | ||
| 113 | const service = { | |
| 114 | execute, | |
| 115 | listOpenPullRequests: (input) => | |
| 116 | execute({ | |
| 117 | cwd: input.cwd, | |
| 118 | args: [ | |
| 119 | "pr", | |
| 120 | "list", | |
| 121 | "--head", | |
| 122 | input.headBranch, | |
| 123 | "--state", | |
| 124 | "open", | |
| 125 | "--limit", | |
| 126 | String(input.limit ?? 1), | |
| 127 | "--json", | |
| 128 | "number,title,url,baseRefName,headRefName", | |
| 129 | ], | |
| 130 | }).pipe( | |
| 131 | Effect.map((result) => result.stdout), | |
| 132 | Effect.flatMap((raw) => | |
| 133 | Effect.try({ | |
| 134 | try: () => parseOpenPullRequests(raw), | |
| 135 | catch: (error: unknown) => | |
| 136 | new GitHubCliError({ | |
| 137 | operation: "listOpenPullRequests", | |
| 138 | detail: | |
| 139 | error instanceof Error | |
| 140 | ? `GitHub CLI returned invalid PR list JSON: ${error.message}` | |
| 141 | : "GitHub CLI returned invalid PR list JSON.", | |
| 142 | ...(error !== undefined ? { cause: error } : {}), | |
| 143 | }), | |
| 144 | }), | |
| 145 | ), | |
| 146 | ), | |
| 147 | createPullRequest: (input) => | |
| 148 | execute({ | |
| 149 | cwd: input.cwd, | |
| 150 | args: [ | |
| 151 | "pr", | |
| 152 | "create", | |
| 153 | "--base", | |
| 154 | input.baseBranch, | |
| 155 | "--head", | |
| 156 | input.headBranch, | |
| 157 | "--title", | |
| 158 | input.title, | |
| 159 | "--body-file", | |
| 160 | input.bodyFile, | |
| 161 | ], | |
| 162 | }).pipe(Effect.asVoid), | |
| 163 | getDefaultBranch: (input) => | |
| 164 | execute({ | |
| 165 | cwd: input.cwd, | |
| 166 | args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], | |
| 167 | }).pipe( | |
| 168 | Effect.map((value) => { | |
| 169 | const trimmed = value.stdout.trim(); | |
| 170 | return trimmed.length > 0 ? trimmed : null; | |
| 171 | }), | |
| 172 | ), | |
| 173 | } satisfies GitHubCliShape; | |
| 174 | ||
| 175 | return service; | |
| 176 | }); | |
| 177 | ||
| 178 | export const GitHubCliLive = Layer.effect(GitHubCli, makeGitHubCli); |
apps/server/src/git/Layers/GitManager.test.ts created+660| ... | ... | @@ -0,0 +1,660 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import path from "node:path"; | |
| 3 | ||
| 4 | import { NodeServices } from "@effect/platform-node"; | |
| 5 | import { it } from "@effect/vitest"; | |
| 6 | import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; | |
| 7 | import { expect } from "vitest"; | |
| 8 | ||
| 9 | import { GitHubCliError, TextGenerationError } from "../Errors.ts"; | |
| 10 | import { type GitManagerShape } from "../Services/GitManager.ts"; | |
| 11 | import { | |
| 12 | type GitHubCliShape, | |
| 13 | type GitHubPullRequestSummary, | |
| 14 | GitHubCli, | |
| 15 | } from "../Services/GitHubCli.ts"; | |
| 16 | import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; | |
| 17 | import { GitServiceLive } from "./GitService.ts"; | |
| 18 | import { GitService } from "../Services/GitService.ts"; | |
| 19 | import { GitCoreLive } from "./GitCore.ts"; | |
| 20 | import { makeGitManager } from "./GitManager.ts"; | |
| 21 | ||
| 22 | interface FakeGhScenario { | |
| 23 | prListSequence?: string[]; | |
| 24 | createdPrUrl?: string; | |
| 25 | defaultBranch?: string; | |
| 26 | failWith?: Error; | |
| 27 | } | |
| 28 | ||
| 29 | interface FakeGitTextGeneration { | |
| 30 | generateCommitMessage: (input: { | |
| 31 | cwd: string; | |
| 32 | branch: string | null; | |
| 33 | stagedSummary: string; | |
| 34 | stagedPatch: string; | |
| 35 | }) => Effect.Effect<{ subject: string; body: string }, unknown>; | |
| 36 | generatePrContent: (input: { | |
| 37 | cwd: string; | |
| 38 | baseBranch: string; | |
| 39 | headBranch: string; | |
| 40 | commitSummary: string; | |
| 41 | diffSummary: string; | |
| 42 | diffPatch: string; | |
| 43 | }) => Effect.Effect<{ title: string; body: string }, unknown>; | |
| 44 | } | |
| 45 | ||
| 46 | function makeTempDir( | |
| 47 | prefix: string, | |
| 48 | ): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> { | |
| 49 | return Effect.gen(function* () { | |
| 50 | const fileSystem = yield* FileSystem.FileSystem; | |
| 51 | return yield* fileSystem.makeTempDirectoryScoped({ prefix }); | |
| 52 | }); | |
| 53 | } | |
| 54 | ||
| 55 | function runGit( | |
| 56 | cwd: string, | |
| 57 | args: readonly string[], | |
| 58 | allowNonZeroExit = false, | |
| 59 | ): Effect.Effect< | |
| 60 | { readonly code: number; readonly stdout: string; readonly stderr: string }, | |
| 61 | unknown, | |
| 62 | GitService | |
| 63 | > { | |
| 64 | return Effect.gen(function* () { | |
| 65 | const gitService = yield* GitService; | |
| 66 | return yield* gitService.execute({ | |
| 67 | operation: "GitManager.test.runGit", | |
| 68 | cwd, | |
| 69 | args, | |
| 70 | allowNonZeroExit, | |
| 71 | }); | |
| 72 | }); | |
| 73 | } | |
| 74 | ||
| 75 | function initRepo( | |
| 76 | cwd: string, | |
| 77 | ): Effect.Effect<void, unknown, FileSystem.FileSystem | Scope.Scope | GitService> { | |
| 78 | return Effect.gen(function* () { | |
| 79 | const fs = yield* FileSystem.FileSystem; | |
| 80 | yield* runGit(cwd, ["init", "--initial-branch=main"]); | |
| 81 | yield* runGit(cwd, ["config", "user.email", "test@example.com"]); | |
| 82 | yield* runGit(cwd, ["config", "user.name", "Test User"]); | |
| 83 | yield* fs.writeFileString(path.join(cwd, "README.md"), "hello\n"); | |
| 84 | yield* runGit(cwd, ["add", "README.md"]); | |
| 85 | yield* runGit(cwd, ["commit", "-m", "Initial commit"]); | |
| 86 | }); | |
| 87 | } | |
| 88 | ||
| 89 | function createBareRemote(): Effect.Effect< | |
| 90 | string, | |
| 91 | unknown, | |
| 92 | FileSystem.FileSystem | Scope.Scope | GitService | |
| 93 | > { | |
| 94 | return Effect.gen(function* () { | |
| 95 | const remoteDir = yield* makeTempDir("t3code-git-remote-"); | |
| 96 | yield* runGit(remoteDir, ["init", "--bare"]); | |
| 97 | return remoteDir; | |
| 98 | }); | |
| 99 | } | |
| 100 | ||
| 101 | function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): TextGenerationShape { | |
| 102 | const implementation: FakeGitTextGeneration = { | |
| 103 | generateCommitMessage: () => | |
| 104 | Effect.succeed({ | |
| 105 | subject: "Implement stacked git actions", | |
| 106 | body: "", | |
| 107 | }), | |
| 108 | generatePrContent: () => | |
| 109 | Effect.succeed({ | |
| 110 | title: "Add stacked git actions", | |
| 111 | body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", | |
| 112 | }), | |
| 113 | ...overrides, | |
| 114 | }; | |
| 115 | ||
| 116 | return { | |
| 117 | generateCommitMessage: (input) => | |
| 118 | implementation.generateCommitMessage(input).pipe( | |
| 119 | Effect.mapError( | |
| 120 | (cause) => | |
| 121 | new TextGenerationError({ | |
| 122 | operation: "generateCommitMessage", | |
| 123 | detail: "fake text generation failed", | |
| 124 | ...(cause !== undefined ? { cause } : {}), | |
| 125 | }), | |
| 126 | ), | |
| 127 | ), | |
| 128 | generatePrContent: (input) => | |
| 129 | implementation.generatePrContent(input).pipe( | |
| 130 | Effect.mapError( | |
| 131 | (cause) => | |
| 132 | new TextGenerationError({ | |
| 133 | operation: "generatePrContent", | |
| 134 | detail: "fake text generation failed", | |
| 135 | ...(cause !== undefined ? { cause } : {}), | |
| 136 | }), | |
| 137 | ), | |
| 138 | ), | |
| 139 | }; | |
| 140 | } | |
| 141 | ||
| 142 | const normalizeGhError = (error: Error): GitHubCliError => { | |
| 143 | if (error.message.includes("Command not found: gh")) { | |
| 144 | return new GitHubCliError({ | |
| 145 | operation: "execute", | |
| 146 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", | |
| 147 | cause: error, | |
| 148 | }); | |
| 149 | } | |
| 150 | const lower = error.message.toLowerCase(); | |
| 151 | if ( | |
| 152 | lower.includes("authentication failed") || | |
| 153 | lower.includes("not logged in") || | |
| 154 | lower.includes("gh auth login") || | |
| 155 | lower.includes("no oauth token") | |
| 156 | ) { | |
| 157 | return new GitHubCliError({ | |
| 158 | operation: "execute", | |
| 159 | detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", | |
| 160 | cause: error, | |
| 161 | }); | |
| 162 | } | |
| 163 | return new GitHubCliError({ | |
| 164 | operation: "execute", | |
| 165 | detail: error.message, | |
| 166 | cause: error, | |
| 167 | }); | |
| 168 | }; | |
| 169 | ||
| 170 | function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | |
| 171 | service: GitHubCliShape; | |
| 172 | ghCalls: string[]; | |
| 173 | } { | |
| 174 | const prListQueue = [...(scenario.prListSequence ?? [])]; | |
| 175 | const ghCalls: string[] = []; | |
| 176 | ||
| 177 | const execute: GitHubCliShape["execute"] = (input) => { | |
| 178 | const args = [...input.args]; | |
| 179 | ghCalls.push(args.join(" ")); | |
| 180 | ||
| 181 | if (scenario.failWith) { | |
| 182 | return Effect.fail(normalizeGhError(scenario.failWith)); | |
| 183 | } | |
| 184 | ||
| 185 | if (args[0] === "pr" && args[1] === "list") { | |
| 186 | const stdout = (prListQueue.shift() ?? "[]") + "\n"; | |
| 187 | return Effect.succeed({ | |
| 188 | stdout, | |
| 189 | stderr: "", | |
| 190 | code: 0, | |
| 191 | signal: null, | |
| 192 | timedOut: false, | |
| 193 | }); | |
| 194 | } | |
| 195 | ||
| 196 | if (args[0] === "pr" && args[1] === "create") { | |
| 197 | return Effect.succeed({ | |
| 198 | stdout: | |
| 199 | (scenario.createdPrUrl ?? "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n", | |
| 200 | stderr: "", | |
| 201 | code: 0, | |
| 202 | signal: null, | |
| 203 | timedOut: false, | |
| 204 | }); | |
| 205 | } | |
| 206 | ||
| 207 | if (args[0] === "pr" && args[1] === "view") { | |
| 208 | return Effect.succeed({ | |
| 209 | stdout: "", | |
| 210 | stderr: "", | |
| 211 | code: 0, | |
| 212 | signal: null, | |
| 213 | timedOut: false, | |
| 214 | }); | |
| 215 | } | |
| 216 | ||
| 217 | if (args[0] === "repo" && args[1] === "view") { | |
| 218 | return Effect.succeed({ | |
| 219 | stdout: `${scenario.defaultBranch ?? "main"}\n`, | |
| 220 | stderr: "", | |
| 221 | code: 0, | |
| 222 | signal: null, | |
| 223 | timedOut: false, | |
| 224 | }); | |
| 225 | } | |
| 226 | ||
| 227 | return Effect.fail( | |
| 228 | new GitHubCliError({ | |
| 229 | operation: "execute", | |
| 230 | detail: `Unexpected gh command: ${args.join(" ")}`, | |
| 231 | }), | |
| 232 | ); | |
| 233 | }; | |
| 234 | ||
| 235 | return { | |
| 236 | service: { | |
| 237 | execute, | |
| 238 | listOpenPullRequests: (input) => | |
| 239 | execute({ | |
| 240 | cwd: input.cwd, | |
| 241 | args: [ | |
| 242 | "pr", | |
| 243 | "list", | |
| 244 | "--head", | |
| 245 | input.headBranch, | |
| 246 | "--state", | |
| 247 | "open", | |
| 248 | "--limit", | |
| 249 | String(input.limit ?? 1), | |
| 250 | "--json", | |
| 251 | "number,title,url,baseRefName,headRefName", | |
| 252 | ], | |
| 253 | }).pipe( | |
| 254 | Effect.map( | |
| 255 | (result) => JSON.parse(result.stdout) as ReadonlyArray<GitHubPullRequestSummary>, | |
| 256 | ), | |
| 257 | ), | |
| 258 | createPullRequest: (input) => | |
| 259 | execute({ | |
| 260 | cwd: input.cwd, | |
| 261 | args: [ | |
| 262 | "pr", | |
| 263 | "create", | |
| 264 | "--base", | |
| 265 | input.baseBranch, | |
| 266 | "--head", | |
| 267 | input.headBranch, | |
| 268 | "--title", | |
| 269 | input.title, | |
| 270 | "--body-file", | |
| 271 | input.bodyFile, | |
| 272 | ], | |
| 273 | }).pipe(Effect.asVoid), | |
| 274 | getDefaultBranch: (input) => | |
| 275 | execute({ | |
| 276 | cwd: input.cwd, | |
| 277 | args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], | |
| 278 | }).pipe( | |
| 279 | Effect.map((result) => { | |
| 280 | const value = result.stdout.trim(); | |
| 281 | return value.length > 0 ? value : null; | |
| 282 | }), | |
| 283 | ), | |
| 284 | }, | |
| 285 | ghCalls, | |
| 286 | }; | |
| 287 | } | |
| 288 | ||
| 289 | function runStackedAction( | |
| 290 | manager: GitManagerShape, | |
| 291 | input: { | |
| 292 | cwd: string; | |
| 293 | action: "commit" | "commit_push" | "commit_push_pr"; | |
| 294 | commitMessage?: string; | |
| 295 | }, | |
| 296 | ) { | |
| 297 | return manager.runStackedAction(input); | |
| 298 | } | |
| 299 | ||
| 300 | function makeManager(input?: { | |
| 301 | ghScenario?: FakeGhScenario; | |
| 302 | textGeneration?: Partial<FakeGitTextGeneration>; | |
| 303 | }) { | |
| 304 | const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); | |
| 305 | const textGeneration = createTextGeneration(input?.textGeneration); | |
| 306 | ||
| 307 | const gitCoreLayer = GitCoreLive.pipe( | |
| 308 | Layer.provideMerge(GitServiceLive), | |
| 309 | Layer.provideMerge(NodeServices.layer), | |
| 310 | ); | |
| 311 | ||
| 312 | const managerLayer = Layer.mergeAll( | |
| 313 | Layer.succeed(GitHubCli, gitHubCli), | |
| 314 | Layer.succeed(TextGeneration, textGeneration), | |
| 315 | gitCoreLayer, | |
| 316 | NodeServices.layer, | |
| 317 | ); | |
| 318 | ||
| 319 | return makeGitManager.pipe( | |
| 320 | Effect.provide(managerLayer), | |
| 321 | Effect.map((manager) => ({ manager, ghCalls })), | |
| 322 | ); | |
| 323 | } | |
| 324 | ||
| 325 | const GitManagerTestLayer = Layer.provideMerge(GitServiceLive, NodeServices.layer); | |
| 326 | ||
| 327 | it.layer(GitManagerTestLayer)("GitManager", (it) => { | |
| 328 | it.effect("status includes open PR metadata when branch already has an open PR", () => | |
| 329 | Effect.gen(function* () { | |
| 330 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 331 | yield* initRepo(repoDir); | |
| 332 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]); | |
| 333 | const remoteDir = yield* createBareRemote(); | |
| 334 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 335 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); | |
| 336 | ||
| 337 | const { manager } = yield* makeManager({ | |
| 338 | ghScenario: { | |
| 339 | prListSequence: [ | |
| 340 | JSON.stringify([ | |
| 341 | { | |
| 342 | number: 13, | |
| 343 | title: "Existing PR", | |
| 344 | url: "https://github.com/pingdotgg/codething-mvp/pull/13", | |
| 345 | baseRefName: "main", | |
| 346 | headRefName: "feature/status-open-pr", | |
| 347 | }, | |
| 348 | ]), | |
| 349 | ], | |
| 350 | }, | |
| 351 | }); | |
| 352 | ||
| 353 | const status = yield* manager.status({ cwd: repoDir }); | |
| 354 | expect(status.branch).toBe("feature/status-open-pr"); | |
| 355 | expect(status.openPr).toEqual({ | |
| 356 | number: 13, | |
| 357 | title: "Existing PR", | |
| 358 | url: "https://github.com/pingdotgg/codething-mvp/pull/13", | |
| 359 | baseBranch: "main", | |
| 360 | headBranch: "feature/status-open-pr", | |
| 361 | }); | |
| 362 | }), | |
| 363 | ); | |
| 364 | ||
| 365 | it.effect("status is resilient to gh lookup failures and returns openPr null", () => | |
| 366 | Effect.gen(function* () { | |
| 367 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 368 | yield* initRepo(repoDir); | |
| 369 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); | |
| 370 | const remoteDir = yield* createBareRemote(); | |
| 371 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 372 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]); | |
| 373 | ||
| 374 | const { manager } = yield* makeManager({ | |
| 375 | ghScenario: { | |
| 376 | failWith: new Error("Command not found: gh"), | |
| 377 | }, | |
| 378 | }); | |
| 379 | ||
| 380 | const status = yield* manager.status({ cwd: repoDir }); | |
| 381 | expect(status.branch).toBe("feature/status-no-gh"); | |
| 382 | expect(status.openPr).toBeNull(); | |
| 383 | }), | |
| 384 | ); | |
| 385 | ||
| 386 | it.effect("creates a commit when working tree is dirty", () => | |
| 387 | Effect.gen(function* () { | |
| 388 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 389 | yield* initRepo(repoDir); | |
| 390 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n"); | |
| 391 | ||
| 392 | const { manager } = yield* makeManager(); | |
| 393 | const result = yield* runStackedAction(manager, { | |
| 394 | cwd: repoDir, | |
| 395 | action: "commit", | |
| 396 | }); | |
| 397 | ||
| 398 | expect(result.commit.status).toBe("created"); | |
| 399 | expect(result.push.status).toBe("skipped_not_requested"); | |
| 400 | expect(result.pr.status).toBe("skipped_not_requested"); | |
| 401 | expect( | |
| 402 | yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( | |
| 403 | Effect.map((result) => result.stdout.trim()), | |
| 404 | ), | |
| 405 | ).toBe("Implement stacked git actions"); | |
| 406 | }), | |
| 407 | ); | |
| 408 | ||
| 409 | it.effect("uses custom commit message when provided", () => | |
| 410 | Effect.gen(function* () { | |
| 411 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 412 | yield* initRepo(repoDir); | |
| 413 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n"); | |
| 414 | let generatedCount = 0; | |
| 415 | ||
| 416 | const { manager } = yield* makeManager({ | |
| 417 | textGeneration: { | |
| 418 | generateCommitMessage: () => | |
| 419 | Effect.sync(() => { | |
| 420 | generatedCount += 1; | |
| 421 | return { | |
| 422 | subject: "this should not be used", | |
| 423 | body: "", | |
| 424 | }; | |
| 425 | }), | |
| 426 | }, | |
| 427 | }); | |
| 428 | const result = yield* runStackedAction(manager, { | |
| 429 | cwd: repoDir, | |
| 430 | action: "commit", | |
| 431 | commitMessage: "feat: custom summary line\n\n- details from user", | |
| 432 | }); | |
| 433 | ||
| 434 | expect(result.commit.status).toBe("created"); | |
| 435 | expect(result.commit.subject).toBe("feat: custom summary line"); | |
| 436 | expect(generatedCount).toBe(0); | |
| 437 | expect( | |
| 438 | yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( | |
| 439 | Effect.map((result) => result.stdout.trim()), | |
| 440 | ), | |
| 441 | ).toBe("feat: custom summary line"); | |
| 442 | expect( | |
| 443 | yield* runGit(repoDir, ["log", "-1", "--pretty=%b"]).pipe( | |
| 444 | Effect.map((result) => result.stdout.trim()), | |
| 445 | ), | |
| 446 | ).toContain("- details from user"); | |
| 447 | }), | |
| 448 | ); | |
| 449 | ||
| 450 | it.effect("skips commit when there are no uncommitted changes", () => | |
| 451 | Effect.gen(function* () { | |
| 452 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 453 | yield* initRepo(repoDir); | |
| 454 | ||
| 455 | const { manager } = yield* makeManager(); | |
| 456 | const result = yield* runStackedAction(manager, { | |
| 457 | cwd: repoDir, | |
| 458 | action: "commit", | |
| 459 | }); | |
| 460 | ||
| 461 | expect(result.commit.status).toBe("skipped_no_changes"); | |
| 462 | expect(result.push.status).toBe("skipped_not_requested"); | |
| 463 | expect(result.pr.status).toBe("skipped_not_requested"); | |
| 464 | }), | |
| 465 | ); | |
| 466 | ||
| 467 | it.effect("commits and pushes with upstream auto-setup when needed", () => | |
| 468 | Effect.gen(function* () { | |
| 469 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 470 | yield* initRepo(repoDir); | |
| 471 | yield* runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]); | |
| 472 | const remoteDir = yield* createBareRemote(); | |
| 473 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 474 | fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); | |
| 475 | ||
| 476 | const { manager } = yield* makeManager(); | |
| 477 | const result = yield* runStackedAction(manager, { | |
| 478 | cwd: repoDir, | |
| 479 | action: "commit_push", | |
| 480 | }); | |
| 481 | ||
| 482 | expect(result.commit.status).toBe("created"); | |
| 483 | expect(result.push.status).toBe("pushed"); | |
| 484 | expect(result.push.setUpstream).toBe(true); | |
| 485 | expect( | |
| 486 | yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( | |
| 487 | Effect.map((result) => result.stdout.trim()), | |
| 488 | ), | |
| 489 | ).toBe("origin/feature/stacked-flow"); | |
| 490 | }), | |
| 491 | ); | |
| 492 | ||
| 493 | it.effect("skips push when branch is already up to date", () => | |
| 494 | Effect.gen(function* () { | |
| 495 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 496 | yield* initRepo(repoDir); | |
| 497 | yield* runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]); | |
| 498 | const remoteDir = yield* createBareRemote(); | |
| 499 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 500 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]); | |
| 501 | ||
| 502 | const { manager } = yield* makeManager(); | |
| 503 | const result = yield* runStackedAction(manager, { | |
| 504 | cwd: repoDir, | |
| 505 | action: "commit_push", | |
| 506 | }); | |
| 507 | ||
| 508 | expect(result.commit.status).toBe("skipped_no_changes"); | |
| 509 | expect(result.push.status).toBe("skipped_up_to_date"); | |
| 510 | }), | |
| 511 | ); | |
| 512 | ||
| 513 | it.effect("returns existing PR metadata for commit/push/pr action", () => | |
| 514 | Effect.gen(function* () { | |
| 515 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 516 | yield* initRepo(repoDir); | |
| 517 | yield* runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]); | |
| 518 | const remoteDir = yield* createBareRemote(); | |
| 519 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 520 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]); | |
| 521 | ||
| 522 | const { manager, ghCalls } = yield* makeManager({ | |
| 523 | ghScenario: { | |
| 524 | prListSequence: [ | |
| 525 | JSON.stringify([ | |
| 526 | { | |
| 527 | number: 42, | |
| 528 | title: "Existing PR", | |
| 529 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | |
| 530 | baseRefName: "main", | |
| 531 | headRefName: "feature/existing-pr", | |
| 532 | }, | |
| 533 | ]), | |
| 534 | ], | |
| 535 | }, | |
| 536 | }); | |
| 537 | const result = yield* runStackedAction(manager, { | |
| 538 | cwd: repoDir, | |
| 539 | action: "commit_push_pr", | |
| 540 | }); | |
| 541 | ||
| 542 | expect(result.pr.status).toBe("opened_existing"); | |
| 543 | expect(result.pr.number).toBe(42); | |
| 544 | expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); | |
| 545 | }), | |
| 546 | ); | |
| 547 | ||
| 548 | it.effect("creates PR when one does not already exist", () => | |
| 549 | Effect.gen(function* () { | |
| 550 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 551 | yield* initRepo(repoDir); | |
| 552 | yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); | |
| 553 | const remoteDir = yield* createBareRemote(); | |
| 554 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 555 | fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); | |
| 556 | yield* runGit(repoDir, ["add", "changes.txt"]); | |
| 557 | yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); | |
| 558 | yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); | |
| 559 | yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]); | |
| 560 | ||
| 561 | const { manager, ghCalls } = yield* makeManager({ | |
| 562 | ghScenario: { | |
| 563 | prListSequence: [ | |
| 564 | "[]", | |
| 565 | JSON.stringify([ | |
| 566 | { | |
| 567 | number: 88, | |
| 568 | title: "Add stacked git actions", | |
| 569 | url: "https://github.com/pingdotgg/codething-mvp/pull/88", | |
| 570 | baseRefName: "main", | |
| 571 | headRefName: "feature-create-pr", | |
| 572 | }, | |
| 573 | ]), | |
| 574 | ], | |
| 575 | }, | |
| 576 | }); | |
| 577 | const result = yield* runStackedAction(manager, { | |
| 578 | cwd: repoDir, | |
| 579 | action: "commit_push_pr", | |
| 580 | }); | |
| 581 | ||
| 582 | expect(result.pr.status).toBe("created"); | |
| 583 | expect(result.pr.number).toBe(88); | |
| 584 | expect( | |
| 585 | ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), | |
| 586 | ).toBe(true); | |
| 587 | expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); | |
| 588 | }), | |
| 589 | ); | |
| 590 | ||
| 591 | it.effect("rejects push/pr actions from detached HEAD", () => | |
| 592 | Effect.gen(function* () { | |
| 593 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 594 | yield* initRepo(repoDir); | |
| 595 | yield* runGit(repoDir, ["checkout", "--detach", "HEAD"]); | |
| 596 | ||
| 597 | const { manager } = yield* makeManager(); | |
| 598 | const errorMessage = yield* runStackedAction(manager, { | |
| 599 | cwd: repoDir, | |
| 600 | action: "commit_push", | |
| 601 | }).pipe( | |
| 602 | Effect.flip, | |
| 603 | Effect.map((error) => (error instanceof Error ? error.message : String(error))), | |
| 604 | ); | |
| 605 | expect(errorMessage).toContain("detached HEAD"); | |
| 606 | }), | |
| 607 | ); | |
| 608 | ||
| 609 | it.effect("surfaces missing gh binary errors", () => | |
| 610 | Effect.gen(function* () { | |
| 611 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 612 | yield* initRepo(repoDir); | |
| 613 | yield* runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]); | |
| 614 | const remoteDir = yield* createBareRemote(); | |
| 615 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 616 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]); | |
| 617 | ||
| 618 | const { manager } = yield* makeManager({ | |
| 619 | ghScenario: { | |
| 620 | failWith: new Error("Command not found: gh"), | |
| 621 | }, | |
| 622 | }); | |
| 623 | ||
| 624 | const errorMessage = yield* runStackedAction(manager, { | |
| 625 | cwd: repoDir, | |
| 626 | action: "commit_push_pr", | |
| 627 | }).pipe( | |
| 628 | Effect.flip, | |
| 629 | Effect.map((error) => (error instanceof Error ? error.message : String(error))), | |
| 630 | ); | |
| 631 | expect(errorMessage).toContain("GitHub CLI (`gh`) is required"); | |
| 632 | }), | |
| 633 | ); | |
| 634 | ||
| 635 | it.effect("surfaces gh auth errors with guidance", () => | |
| 636 | Effect.gen(function* () { | |
| 637 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 638 | yield* initRepo(repoDir); | |
| 639 | yield* runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]); | |
| 640 | const remoteDir = yield* createBareRemote(); | |
| 641 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 642 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]); | |
| 643 | ||
| 644 | const { manager } = yield* makeManager({ | |
| 645 | ghScenario: { | |
| 646 | failWith: new Error("authentication failed"), | |
| 647 | }, | |
| 648 | }); | |
| 649 | ||
| 650 | const errorMessage = yield* runStackedAction(manager, { | |
| 651 | cwd: repoDir, | |
| 652 | action: "commit_push_pr", | |
| 653 | }).pipe( | |
| 654 | Effect.flip, | |
| 655 | Effect.map((error) => (error instanceof Error ? error.message : String(error))), | |
| 656 | ); | |
| 657 | expect(errorMessage).toContain("gh auth login"); | |
| 658 | }), | |
| 659 | ); | |
| 660 | }); |
apps/server/src/git/Layers/GitManager.ts created+318| ... | ... | @@ -0,0 +1,318 @@ |
| 1 | import { randomUUID } from "node:crypto"; | |
| 2 | ||
| 3 | import { Effect, FileSystem, Layer, Path } from "effect"; | |
| 4 | ||
| 5 | import { GitManagerError } from "../Errors.ts"; | |
| 6 | import { GitManager, type GitManagerShape } from "../Services/GitManager.ts"; | |
| 7 | import { GitCore } from "../Services/GitCore.ts"; | |
| 8 | import { GitHubCli } from "../Services/GitHubCli.ts"; | |
| 9 | import { TextGeneration } from "../Services/TextGeneration.ts"; | |
| 10 | ||
| 11 | interface OpenPrInfo { | |
| 12 | number: number; | |
| 13 | title: string; | |
| 14 | url: string; | |
| 15 | baseRefName: string; | |
| 16 | headRefName: string; | |
| 17 | } | |
| 18 | ||
| 19 | function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { | |
| 20 | return new GitManagerError({ | |
| 21 | operation, | |
| 22 | detail, | |
| 23 | ...(cause !== undefined ? { cause } : {}), | |
| 24 | }); | |
| 25 | } | |
| 26 | ||
| 27 | function limitContext(value: string, maxChars: number): string { | |
| 28 | if (value.length <= maxChars) return value; | |
| 29 | return `${value.slice(0, maxChars)}\n\n[truncated]`; | |
| 30 | } | |
| 31 | ||
| 32 | function sanitizeCommitMessage(generated: { | |
| 33 | subject: string; | |
| 34 | body: string; | |
| 35 | }): { | |
| 36 | subject: string; | |
| 37 | body: string; | |
| 38 | } { | |
| 39 | const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | |
| 40 | const subject = rawSubject.replace(/[.]+$/g, "").trim(); | |
| 41 | const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files"; | |
| 42 | return { | |
| 43 | subject: safeSubject, | |
| 44 | body: generated.body.trim(), | |
| 45 | }; | |
| 46 | } | |
| 47 | ||
| 48 | function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null { | |
| 49 | const normalized = raw.replace(/\r\n/g, "\n").trim(); | |
| 50 | if (normalized.length === 0) { | |
| 51 | return null; | |
| 52 | } | |
| 53 | ||
| 54 | const [firstLine, ...rest] = normalized.split("\n"); | |
| 55 | const subject = firstLine?.trim() ?? ""; | |
| 56 | if (subject.length === 0) { | |
| 57 | return null; | |
| 58 | } | |
| 59 | ||
| 60 | return { | |
| 61 | subject, | |
| 62 | body: rest.join("\n").trim(), | |
| 63 | }; | |
| 64 | } | |
| 65 | ||
| 66 | function extractBranchFromRef(ref: string): string { | |
| 67 | const normalized = ref.trim(); | |
| 68 | ||
| 69 | if (normalized.startsWith("refs/remotes/")) { | |
| 70 | const withoutPrefix = normalized.slice("refs/remotes/".length); | |
| 71 | const firstSlash = withoutPrefix.indexOf("/"); | |
| 72 | if (firstSlash === -1) { | |
| 73 | return withoutPrefix.trim(); | |
| 74 | } | |
| 75 | return withoutPrefix.slice(firstSlash + 1).trim(); | |
| 76 | } | |
| 77 | ||
| 78 | const firstSlash = normalized.indexOf("/"); | |
| 79 | if (firstSlash === -1) { | |
| 80 | return normalized; | |
| 81 | } | |
| 82 | return normalized.slice(firstSlash + 1).trim(); | |
| 83 | } | |
| 84 | ||
| 85 | function toStatusOpenPr(pr: OpenPrInfo): { | |
| 86 | number: number; | |
| 87 | title: string; | |
| 88 | url: string; | |
| 89 | baseBranch: string; | |
| 90 | headBranch: string; | |
| 91 | } { | |
| 92 | return { | |
| 93 | number: pr.number, | |
| 94 | title: pr.title, | |
| 95 | url: pr.url, | |
| 96 | baseBranch: pr.baseRefName, | |
| 97 | headBranch: pr.headRefName, | |
| 98 | }; | |
| 99 | } | |
| 100 | ||
| 101 | export const makeGitManager = Effect.gen(function* () { | |
| 102 | const gitCore = yield* GitCore; | |
| 103 | const gitHubCli = yield* GitHubCli; | |
| 104 | const textGeneration = yield* TextGeneration; | |
| 105 | const fileSystem = yield* FileSystem.FileSystem; | |
| 106 | const path = yield* Path.Path; | |
| 107 | ||
| 108 | const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; | |
| 109 | ||
| 110 | const findOpenPr = (cwd: string, branch: string) => | |
| 111 | gitHubCli | |
| 112 | .listOpenPullRequests({ | |
| 113 | cwd, | |
| 114 | headBranch: branch, | |
| 115 | limit: 1, | |
| 116 | }) | |
| 117 | .pipe( | |
| 118 | Effect.map((prs) => { | |
| 119 | const [first] = prs; | |
| 120 | if (!first) { | |
| 121 | return null; | |
| 122 | } | |
| 123 | return { | |
| 124 | number: first.number, | |
| 125 | title: first.title, | |
| 126 | url: first.url, | |
| 127 | baseRefName: first.baseRefName, | |
| 128 | headRefName: first.headRefName, | |
| 129 | } satisfies OpenPrInfo; | |
| 130 | }), | |
| 131 | ); | |
| 132 | ||
| 133 | const resolveBaseBranch = (cwd: string, branch: string, upstreamRef: string | null) => | |
| 134 | Effect.gen(function* () { | |
| 135 | const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); | |
| 136 | if (configured) return configured; | |
| 137 | ||
| 138 | if (upstreamRef) { | |
| 139 | const upstreamBranch = extractBranchFromRef(upstreamRef); | |
| 140 | if (upstreamBranch.length > 0 && upstreamBranch !== branch) { | |
| 141 | return upstreamBranch; | |
| 142 | } | |
| 143 | } | |
| 144 | ||
| 145 | const defaultFromGh = yield* gitHubCli | |
| 146 | .getDefaultBranch({ cwd }) | |
| 147 | .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 148 | if (defaultFromGh) { | |
| 149 | return defaultFromGh; | |
| 150 | } | |
| 151 | ||
| 152 | return "main"; | |
| 153 | }); | |
| 154 | ||
| 155 | const runCommitStep = (cwd: string, branch: string | null, commitMessage?: string) => | |
| 156 | Effect.gen(function* () { | |
| 157 | const context = yield* gitCore.prepareCommitContext(cwd); | |
| 158 | if (!context) { | |
| 159 | return { status: "skipped_no_changes" as const }; | |
| 160 | } | |
| 161 | ||
| 162 | let generated = parseCustomCommitMessage(commitMessage ?? ""); | |
| 163 | if (!generated) { | |
| 164 | generated = yield* textGeneration.generateCommitMessage({ | |
| 165 | cwd, | |
| 166 | branch, | |
| 167 | stagedSummary: limitContext(context.stagedSummary, 8_000), | |
| 168 | stagedPatch: limitContext(context.stagedPatch, 50_000), | |
| 169 | }).pipe(Effect.map((result) => sanitizeCommitMessage(result))); | |
| 170 | } | |
| 171 | ||
| 172 | const { commitSha } = yield* gitCore.commit(cwd, generated.subject, generated.body); | |
| 173 | return { | |
| 174 | status: "created" as const, | |
| 175 | commitSha, | |
| 176 | subject: generated.subject, | |
| 177 | }; | |
| 178 | }); | |
| 179 | ||
| 180 | const runPrStep = (cwd: string, fallbackBranch: string | null) => | |
| 181 | Effect.gen(function* () { | |
| 182 | const details = yield* gitCore.statusDetails(cwd); | |
| 183 | const branch = details.branch ?? fallbackBranch; | |
| 184 | if (!branch) { | |
| 185 | return yield* gitManagerError("runPrStep", "Cannot create a pull request from detached HEAD."); | |
| 186 | } | |
| 187 | if (!details.hasUpstream) { | |
| 188 | return yield* gitManagerError( | |
| 189 | "runPrStep", | |
| 190 | "Current branch has not been pushed. Push before creating a PR.", | |
| 191 | ); | |
| 192 | } | |
| 193 | ||
| 194 | const existing = yield* findOpenPr(cwd, branch); | |
| 195 | if (existing) { | |
| 196 | return { | |
| 197 | status: "opened_existing" as const, | |
| 198 | url: existing.url, | |
| 199 | number: existing.number, | |
| 200 | baseBranch: existing.baseRefName, | |
| 201 | headBranch: existing.headRefName, | |
| 202 | title: existing.title, | |
| 203 | }; | |
| 204 | } | |
| 205 | ||
| 206 | const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef); | |
| 207 | const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); | |
| 208 | ||
| 209 | const generated = yield* textGeneration.generatePrContent({ | |
| 210 | cwd, | |
| 211 | baseBranch, | |
| 212 | headBranch: branch, | |
| 213 | commitSummary: limitContext(rangeContext.commitSummary, 20_000), | |
| 214 | diffSummary: limitContext(rangeContext.diffSummary, 20_000), | |
| 215 | diffPatch: limitContext(rangeContext.diffPatch, 60_000), | |
| 216 | }); | |
| 217 | ||
| 218 | const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`); | |
| 219 | yield* fileSystem | |
| 220 | .writeFileString(bodyFile, generated.body) | |
| 221 | .pipe( | |
| 222 | Effect.mapError((cause) => | |
| 223 | gitManagerError( | |
| 224 | "runPrStep", | |
| 225 | "Failed to write pull request body temp file.", | |
| 226 | cause, | |
| 227 | ), | |
| 228 | ), | |
| 229 | ); | |
| 230 | yield* gitHubCli | |
| 231 | .createPullRequest({ | |
| 232 | cwd, | |
| 233 | baseBranch, | |
| 234 | headBranch: branch, | |
| 235 | title: generated.title, | |
| 236 | bodyFile, | |
| 237 | }) | |
| 238 | .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); | |
| 239 | ||
| 240 | const created = yield* findOpenPr(cwd, branch); | |
| 241 | if (!created) { | |
| 242 | return { | |
| 243 | status: "created" as const, | |
| 244 | baseBranch, | |
| 245 | headBranch: branch, | |
| 246 | title: generated.title, | |
| 247 | }; | |
| 248 | } | |
| 249 | ||
| 250 | return { | |
| 251 | status: "created" as const, | |
| 252 | url: created.url, | |
| 253 | number: created.number, | |
| 254 | baseBranch: created.baseRefName, | |
| 255 | headBranch: created.headRefName, | |
| 256 | title: created.title, | |
| 257 | }; | |
| 258 | }); | |
| 259 | ||
| 260 | const status: GitManagerShape["status"] = Effect.fnUntraced(function* (input) { | |
| 261 | const details = yield* gitCore.statusDetails(input.cwd); | |
| 262 | ||
| 263 | const openPr = | |
| 264 | details.branch && details.hasUpstream | |
| 265 | ? yield* findOpenPr(input.cwd, details.branch).pipe( | |
| 266 | Effect.map((pr) => (pr ? toStatusOpenPr(pr) : null)), | |
| 267 | Effect.catch(() => Effect.succeed(null)), | |
| 268 | ) | |
| 269 | : null; | |
| 270 | ||
| 271 | return { | |
| 272 | branch: details.branch, | |
| 273 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | |
| 274 | workingTree: details.workingTree, | |
| 275 | hasUpstream: details.hasUpstream, | |
| 276 | aheadCount: details.aheadCount, | |
| 277 | behindCount: details.behindCount, | |
| 278 | openPr, | |
| 279 | }; | |
| 280 | }); | |
| 281 | ||
| 282 | const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced(function* (input) { | |
| 283 | const wantsPush = input.action !== "commit"; | |
| 284 | const wantsPr = input.action === "commit_push_pr"; | |
| 285 | ||
| 286 | const initialStatus = yield* gitCore.statusDetails(input.cwd); | |
| 287 | if (wantsPush && !initialStatus.branch) { | |
| 288 | return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); | |
| 289 | } | |
| 290 | if (wantsPr && !initialStatus.branch) { | |
| 291 | return yield* gitManagerError("runStackedAction", "Cannot create a pull request from detached HEAD."); | |
| 292 | } | |
| 293 | ||
| 294 | const commit = yield* runCommitStep(input.cwd, initialStatus.branch, input.commitMessage); | |
| 295 | ||
| 296 | const push = wantsPush | |
| 297 | ? yield* gitCore.pushCurrentBranch(input.cwd, initialStatus.branch) | |
| 298 | : { status: "skipped_not_requested" as const }; | |
| 299 | ||
| 300 | const pr = wantsPr | |
| 301 | ? yield* runPrStep(input.cwd, initialStatus.branch) | |
| 302 | : { status: "skipped_not_requested" as const }; | |
| 303 | ||
| 304 | return { | |
| 305 | action: input.action, | |
| 306 | commit, | |
| 307 | push, | |
| 308 | pr, | |
| 309 | }; | |
| 310 | }); | |
| 311 | ||
| 312 | return { | |
| 313 | status, | |
| 314 | runStackedAction, | |
| 315 | } satisfies GitManagerShape; | |
| 316 | }); | |
| 317 | ||
| 318 | export const GitManagerLive = Layer.effect(GitManager, makeGitManager); |
apps/server/src/git/Layers/GitService.test.ts created+59| ... | ... | @@ -0,0 +1,59 @@ |
| 1 | import { NodeServices } from "@effect/platform-node"; | |
| 2 | import { it, assert } from "@effect/vitest"; | |
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import { GitCommandError } from "../Errors.ts"; | |
| 6 | import { GitServiceLive } from "./GitService.ts"; | |
| 7 | import { GitService } from "../Services/GitService.ts"; | |
| 8 | ||
| 9 | const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer)); | |
| 10 | ||
| 11 | layer("GitServiceLive", (it) => { | |
| 12 | it.effect("runGit executes successful git commands", () => | |
| 13 | Effect.gen(function* () { | |
| 14 | const gitService = yield* GitService; | |
| 15 | const result = yield* gitService.execute({ | |
| 16 | operation: "GitProcess.test.version", | |
| 17 | cwd: process.cwd(), | |
| 18 | args: ["--version"], | |
| 19 | }); | |
| 20 | ||
| 21 | assert.equal(result.code, 0); | |
| 22 | assert.ok(result.stdout.toLowerCase().includes("git version")); | |
| 23 | }), | |
| 24 | ); | |
| 25 | ||
| 26 | it.effect("runGit can return non-zero exit codes when allowed", () => | |
| 27 | Effect.gen(function* () { | |
| 28 | const gitService = yield* GitService; | |
| 29 | const result = yield* gitService.execute({ | |
| 30 | operation: "GitProcess.test.allowNonZero", | |
| 31 | cwd: process.cwd(), | |
| 32 | args: ["rev-parse", "--verify", "__definitely_missing_ref__"], | |
| 33 | allowNonZeroExit: true, | |
| 34 | }); | |
| 35 | ||
| 36 | assert.notEqual(result.code, 0); | |
| 37 | }), | |
| 38 | ); | |
| 39 | ||
| 40 | it.effect("runGit fails with GitCommandError when non-zero exits are not allowed", () => | |
| 41 | Effect.gen(function* () { | |
| 42 | const gitService = yield* GitService; | |
| 43 | const result = yield* Effect.result( | |
| 44 | gitService.execute({ | |
| 45 | operation: "GitProcess.test.failOnNonZero", | |
| 46 | cwd: process.cwd(), | |
| 47 | args: ["rev-parse", "--verify", "__definitely_missing_ref__"], | |
| 48 | }), | |
| 49 | ); | |
| 50 | ||
| 51 | assert.equal(result._tag, "Failure"); | |
| 52 | if (result._tag === "Failure") { | |
| 53 | assert.ok(result.failure instanceof GitCommandError); | |
| 54 | assert.equal(result.failure.operation, "GitProcess.test.failOnNonZero"); | |
| 55 | assert.equal(result.failure.command, "git rev-parse --verify __definitely_missing_ref__"); | |
| 56 | } | |
| 57 | }), | |
| 58 | ); | |
| 59 | }); |
apps/server/src/git/Layers/GitService.ts created+146| ... | ... | @@ -0,0 +1,146 @@ |
| 1 | /** | |
| 2 | * Git process helpers - Effect-native git execution with typed errors. | |
| 3 | * | |
| 4 | * Centralizes child-process git invocation for server modules. This module | |
| 5 | * only executes git commands and reports structured failures. | |
| 6 | * | |
| 7 | * @module GitServiceLive | |
| 8 | */ | |
| 9 | import { Effect, Layer, Option, Stream } from "effect"; | |
| 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 11 | import { GitCommandError } from "../Errors.ts"; | |
| 12 | import { | |
| 13 | ExecuteGitInput, | |
| 14 | ExecuteGitResult, | |
| 15 | GitService, | |
| 16 | GitServiceShape, | |
| 17 | } from "../Services/GitService.ts"; | |
| 18 | ||
| 19 | const DEFAULT_TIMEOUT_MS = 30_000; | |
| 20 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | |
| 21 | ||
| 22 | function quoteGitCommand(args: ReadonlyArray<string>): string { | |
| 23 | return `git ${args.join(" ")}`; | |
| 24 | } | |
| 25 | ||
| 26 | function toGitCommandError( | |
| 27 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | |
| 28 | detail: string, | |
| 29 | ) { | |
| 30 | return (cause: unknown) => | |
| 31 | cause instanceof GitCommandError | |
| 32 | ? cause | |
| 33 | : new GitCommandError({ | |
| 34 | operation: input.operation, | |
| 35 | command: quoteGitCommand(input.args), | |
| 36 | cwd: input.cwd, | |
| 37 | detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, | |
| 38 | ...(cause !== undefined ? { cause } : {}), | |
| 39 | }); | |
| 40 | } | |
| 41 | ||
| 42 | const collectOutput = Effect.fn(function* ( | |
| 43 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | |
| 44 | stream: Stream.Stream<Uint8Array, unknown>, | |
| 45 | maxOutputBytes: number, | |
| 46 | ): Effect.fn.Return<string, GitCommandError> { | |
| 47 | const decoder = new TextDecoder(); | |
| 48 | let bytes = 0; | |
| 49 | let text = ""; | |
| 50 | ||
| 51 | yield* Stream.runForEach(stream, (chunk) => | |
| 52 | Effect.gen(function* () { | |
| 53 | bytes += chunk.byteLength; | |
| 54 | if (bytes > maxOutputBytes) { | |
| 55 | return yield* new GitCommandError({ | |
| 56 | operation: input.operation, | |
| 57 | command: quoteGitCommand(input.args), | |
| 58 | cwd: input.cwd, | |
| 59 | detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, | |
| 60 | }); | |
| 61 | } | |
| 62 | text += decoder.decode(chunk, { stream: true }); | |
| 63 | }), | |
| 64 | ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); | |
| 65 | ||
| 66 | text += decoder.decode(); | |
| 67 | return text; | |
| 68 | }); | |
| 69 | ||
| 70 | const makeGitService = Effect.gen(function* () { | |
| 71 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 72 | ||
| 73 | const execute: GitServiceShape["execute"] = Effect.fnUntraced(function* (input) { | |
| 74 | const normalizedCwd = input.cwd.trim(); | |
| 75 | const commandInput = { | |
| 76 | ...input, | |
| 77 | cwd: normalizedCwd, | |
| 78 | args: [...input.args], | |
| 79 | } as const; | |
| 80 | const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; | |
| 81 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | |
| 82 | ||
| 83 | const commandEffect = Effect.gen(function* () { | |
| 84 | const child = yield* commandSpawner | |
| 85 | .spawn( | |
| 86 | ChildProcess.make("git", commandInput.args, { | |
| 87 | cwd: commandInput.cwd, | |
| 88 | ...(input.env ? { env: input.env } : {}), | |
| 89 | }), | |
| 90 | ) | |
| 91 | .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); | |
| 92 | ||
| 93 | const [stdout, stderr, exitCode] = yield* Effect.all( | |
| 94 | [ | |
| 95 | collectOutput(commandInput, child.stdout, maxOutputBytes), | |
| 96 | collectOutput(commandInput, child.stderr, maxOutputBytes), | |
| 97 | child.exitCode.pipe( | |
| 98 | Effect.map((value) => Number(value)), | |
| 99 | Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), | |
| 100 | ), | |
| 101 | ], | |
| 102 | { concurrency: "unbounded" }, | |
| 103 | ); | |
| 104 | ||
| 105 | if (!input.allowNonZeroExit && exitCode !== 0) { | |
| 106 | const trimmedStderr = stderr.trim(); | |
| 107 | return yield* new GitCommandError({ | |
| 108 | operation: commandInput.operation, | |
| 109 | command: quoteGitCommand(commandInput.args), | |
| 110 | cwd: commandInput.cwd, | |
| 111 | detail: | |
| 112 | trimmedStderr.length > 0 | |
| 113 | ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` | |
| 114 | : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, | |
| 115 | }); | |
| 116 | } | |
| 117 | ||
| 118 | return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult; | |
| 119 | }); | |
| 120 | ||
| 121 | return yield* commandEffect.pipe( | |
| 122 | Effect.scoped, | |
| 123 | Effect.timeoutOption(timeoutMs), | |
| 124 | Effect.flatMap((result) => | |
| 125 | Option.match(result, { | |
| 126 | onNone: () => | |
| 127 | Effect.fail( | |
| 128 | new GitCommandError({ | |
| 129 | operation: commandInput.operation, | |
| 130 | command: quoteGitCommand(commandInput.args), | |
| 131 | cwd: commandInput.cwd, | |
| 132 | detail: `${quoteGitCommand(commandInput.args)} timed out.`, | |
| 133 | }), | |
| 134 | ), | |
| 135 | onSome: Effect.succeed, | |
| 136 | }), | |
| 137 | ), | |
| 138 | ); | |
| 139 | }); | |
| 140 | ||
| 141 | return { | |
| 142 | execute, | |
| 143 | } satisfies GitServiceShape; | |
| 144 | }); | |
| 145 | ||
| 146 | export const GitServiceLive = Layer.effect(GitService, makeGitService); |
apps/server/src/git/Service.ts deleted-42| ... | ... | @@ -1,42 +0,0 @@ |
| 1 | /** | |
| 2 | * GitService - Service for Git command execution. | |
| 3 | * | |
| 4 | * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed | |
| 5 | * domain errors for Git command execution. | |
| 6 | * | |
| 7 | * @module GitService | |
| 8 | */ | |
| 9 | import { ServiceMap } from "effect"; | |
| 10 | import type { Effect } from "effect"; | |
| 11 | ||
| 12 | import type { GitCommandError } from "./Errors.ts"; | |
| 13 | ||
| 14 | export interface ExecuteGitInput { | |
| 15 | readonly operation: string; | |
| 16 | readonly cwd: string; | |
| 17 | readonly args: ReadonlyArray<string>; | |
| 18 | readonly env?: NodeJS.ProcessEnv; | |
| 19 | readonly allowNonZeroExit?: boolean; | |
| 20 | readonly timeoutMs?: number; | |
| 21 | readonly maxOutputBytes?: number; | |
| 22 | } | |
| 23 | ||
| 24 | export interface ExecuteGitResult { | |
| 25 | readonly code: number; | |
| 26 | readonly stdout: string; | |
| 27 | readonly stderr: string; | |
| 28 | } | |
| 29 | ||
| 30 | export interface GitServiceShape { | |
| 31 | /** | |
| 32 | * Execute a Git command. | |
| 33 | */ | |
| 34 | readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>; | |
| 35 | } | |
| 36 | ||
| 37 | /** | |
| 38 | * GitService - Service for Git command execution. | |
| 39 | */ | |
| 40 | export class GitService extends ServiceMap.Service<GitService, GitServiceShape>()( | |
| 41 | "git/GitService", | |
| 42 | ) {} |
apps/server/src/git/Services/GitCore.ts created+146| ... | ... | @@ -0,0 +1,146 @@ |
| 1 | /** | |
| 2 | * GitCore - Effect service contract for low-level Git operations. | |
| 3 | * | |
| 4 | * Wraps core repository primitives used by higher-level orchestration | |
| 5 | * services and WebSocket routes. | |
| 6 | * | |
| 7 | * @module GitCore | |
| 8 | */ | |
| 9 | import { ServiceMap } from "effect"; | |
| 10 | import type { Effect } from "effect"; | |
| 11 | import type { | |
| 12 | GitCheckoutInput, | |
| 13 | GitCreateBranchInput, | |
| 14 | GitCreateWorktreeInput, | |
| 15 | GitCreateWorktreeResult, | |
| 16 | GitInitInput, | |
| 17 | GitListBranchesInput, | |
| 18 | GitListBranchesResult, | |
| 19 | GitPullResult, | |
| 20 | GitRemoveWorktreeInput, | |
| 21 | GitStatusInput, | |
| 22 | GitStatusResult, | |
| 23 | } from "@t3tools/contracts"; | |
| 24 | ||
| 25 | import type { GitCommandError } from "../Errors.ts"; | |
| 26 | ||
| 27 | export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> { | |
| 28 | upstreamRef: string | null; | |
| 29 | } | |
| 30 | ||
| 31 | export interface GitPreparedCommitContext { | |
| 32 | stagedSummary: string; | |
| 33 | stagedPatch: string; | |
| 34 | } | |
| 35 | ||
| 36 | export interface GitPushResult { | |
| 37 | status: "pushed" | "skipped_up_to_date"; | |
| 38 | branch: string; | |
| 39 | upstreamBranch?: string | undefined; | |
| 40 | setUpstream?: boolean | undefined; | |
| 41 | } | |
| 42 | ||
| 43 | export interface GitRangeContext { | |
| 44 | commitSummary: string; | |
| 45 | diffSummary: string; | |
| 46 | diffPatch: string; | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * GitCoreShape - Service API for low-level Git repository interactions. | |
| 51 | */ | |
| 52 | export interface GitCoreShape { | |
| 53 | /** | |
| 54 | * Read Git status for a repository. | |
| 55 | */ | |
| 56 | readonly status: (input: GitStatusInput) => Effect.Effect<GitStatusResult, GitCommandError>; | |
| 57 | ||
| 58 | /** | |
| 59 | * Read detailed working tree / branch status for a repository. | |
| 60 | */ | |
| 61 | readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>; | |
| 62 | ||
| 63 | /** | |
| 64 | * Build staged change context for commit generation. | |
| 65 | */ | |
| 66 | readonly prepareCommitContext: ( | |
| 67 | cwd: string, | |
| 68 | ) => Effect.Effect<GitPreparedCommitContext | null, GitCommandError>; | |
| 69 | ||
| 70 | /** | |
| 71 | * Create a commit with provided subject/body. | |
| 72 | */ | |
| 73 | readonly commit: ( | |
| 74 | cwd: string, | |
| 75 | subject: string, | |
| 76 | body: string, | |
| 77 | ) => Effect.Effect<{ commitSha: string }, GitCommandError>; | |
| 78 | ||
| 79 | /** | |
| 80 | * Push current branch, setting upstream if needed. | |
| 81 | */ | |
| 82 | readonly pushCurrentBranch: ( | |
| 83 | cwd: string, | |
| 84 | fallbackBranch: string | null, | |
| 85 | ) => Effect.Effect<GitPushResult, GitCommandError>; | |
| 86 | ||
| 87 | /** | |
| 88 | * Collect commit/diff context between base branch and current HEAD. | |
| 89 | */ | |
| 90 | readonly readRangeContext: ( | |
| 91 | cwd: string, | |
| 92 | baseBranch: string, | |
| 93 | ) => Effect.Effect<GitRangeContext, GitCommandError>; | |
| 94 | ||
| 95 | /** | |
| 96 | * Read a Git config value from the local repository. | |
| 97 | */ | |
| 98 | readonly readConfigValue: ( | |
| 99 | cwd: string, | |
| 100 | key: string, | |
| 101 | ) => Effect.Effect<string | null, GitCommandError>; | |
| 102 | ||
| 103 | /** | |
| 104 | * List local branches and branch metadata. | |
| 105 | */ | |
| 106 | readonly listBranches: ( | |
| 107 | input: GitListBranchesInput, | |
| 108 | ) => Effect.Effect<GitListBranchesResult, GitCommandError>; | |
| 109 | ||
| 110 | /** | |
| 111 | * Pull current branch from upstream using fast-forward only. | |
| 112 | */ | |
| 113 | readonly pullCurrentBranch: (cwd: string) => Effect.Effect<GitPullResult, GitCommandError>; | |
| 114 | ||
| 115 | /** | |
| 116 | * Create a worktree and branch from a base branch. | |
| 117 | */ | |
| 118 | readonly createWorktree: ( | |
| 119 | input: GitCreateWorktreeInput, | |
| 120 | ) => Effect.Effect<GitCreateWorktreeResult, GitCommandError>; | |
| 121 | ||
| 122 | /** | |
| 123 | * Remove an existing worktree. | |
| 124 | */ | |
| 125 | readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>; | |
| 126 | ||
| 127 | /** | |
| 128 | * Create a local branch. | |
| 129 | */ | |
| 130 | readonly createBranch: (input: GitCreateBranchInput) => Effect.Effect<void, GitCommandError>; | |
| 131 | ||
| 132 | /** | |
| 133 | * Checkout an existing branch and refresh its upstream metadata in background. | |
| 134 | */ | |
| 135 | readonly checkoutBranch: (input: GitCheckoutInput) => Effect.Effect<void, GitCommandError>; | |
| 136 | ||
| 137 | /** | |
| 138 | * Initialize a repository in the provided directory. | |
| 139 | */ | |
| 140 | readonly initRepo: (input: GitInitInput) => Effect.Effect<void, GitCommandError>; | |
| 141 | } | |
| 142 | ||
| 143 | /** | |
| 144 | * GitCore - Service tag for low-level Git repository operations. | |
| 145 | */ | |
| 146 | export class GitCore extends ServiceMap.Service<GitCore, GitCoreShape>()("git/GitCore") {} |
apps/server/src/git/Services/GitHubCli.ts created+66| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | /** | |
| 2 | * GitHubCli - Effect service contract for `gh` process interactions. | |
| 3 | * | |
| 4 | * Provides thin command execution helpers used by Git workflow orchestration. | |
| 5 | * | |
| 6 | * @module GitHubCli | |
| 7 | */ | |
| 8 | import { ServiceMap } from "effect"; | |
| 9 | import type { Effect } from "effect"; | |
| 10 | ||
| 11 | import type { ProcessRunResult } from "../../processRunner"; | |
| 12 | import type { GitHubCliError } from "../Errors.ts"; | |
| 13 | ||
| 14 | export interface GitHubPullRequestSummary { | |
| 15 | readonly number: number; | |
| 16 | readonly title: string; | |
| 17 | readonly url: string; | |
| 18 | readonly baseRefName: string; | |
| 19 | readonly headRefName: string; | |
| 20 | } | |
| 21 | ||
| 22 | /** | |
| 23 | * GitHubCliShape - Service API for executing GitHub CLI commands. | |
| 24 | */ | |
| 25 | export interface GitHubCliShape { | |
| 26 | /** | |
| 27 | * Execute a GitHub CLI command and return full process output. | |
| 28 | */ | |
| 29 | readonly execute: (input: { | |
| 30 | readonly cwd: string; | |
| 31 | readonly args: ReadonlyArray<string>; | |
| 32 | readonly timeoutMs?: number; | |
| 33 | }) => Effect.Effect<ProcessRunResult, GitHubCliError>; | |
| 34 | ||
| 35 | /** | |
| 36 | * List open pull requests for a head branch. | |
| 37 | */ | |
| 38 | readonly listOpenPullRequests: (input: { | |
| 39 | readonly cwd: string; | |
| 40 | readonly headBranch: string; | |
| 41 | readonly limit?: number; | |
| 42 | }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>; | |
| 43 | ||
| 44 | /** | |
| 45 | * Create a pull request from branch context and body file. | |
| 46 | */ | |
| 47 | readonly createPullRequest: (input: { | |
| 48 | readonly cwd: string; | |
| 49 | readonly baseBranch: string; | |
| 50 | readonly headBranch: string; | |
| 51 | readonly title: string; | |
| 52 | readonly bodyFile: string; | |
| 53 | }) => Effect.Effect<void, GitHubCliError>; | |
| 54 | ||
| 55 | /** | |
| 56 | * Resolve repository default branch through GitHub metadata. | |
| 57 | */ | |
| 58 | readonly getDefaultBranch: (input: { | |
| 59 | readonly cwd: string; | |
| 60 | }) => Effect.Effect<string | null, GitHubCliError>; | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * GitHubCli - Service tag for GitHub CLI process execution. | |
| 65 | */ | |
| 66 | export class GitHubCli extends ServiceMap.Service<GitHubCli, GitHubCliShape>()("git/GitHubCli") {} |
apps/server/src/git/Services/GitManager.ts created+41| ... | ... | @@ -0,0 +1,41 @@ |
| 1 | /** | |
| 2 | * GitManager - Effect service contract for stacked Git workflows. | |
| 3 | * | |
| 4 | * Orchestrates status inspection and commit/push/PR flows by composing | |
| 5 | * lower-level Git and external tool services. | |
| 6 | * | |
| 7 | * @module GitManager | |
| 8 | */ | |
| 9 | import { | |
| 10 | GitRunStackedActionInput, | |
| 11 | GitRunStackedActionResult, | |
| 12 | GitStatusInput, | |
| 13 | GitStatusResult, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { ServiceMap } from "effect"; | |
| 16 | import type { Effect } from "effect"; | |
| 17 | import type { GitManagerServiceError } from "../Errors.ts"; | |
| 18 | ||
| 19 | /** | |
| 20 | * GitManagerShape - Service API for high-level Git workflow actions. | |
| 21 | */ | |
| 22 | export interface GitManagerShape { | |
| 23 | /** | |
| 24 | * Read current repository Git status plus open PR metadata when available. | |
| 25 | */ | |
| 26 | readonly status: (input: GitStatusInput) => Effect.Effect<GitStatusResult, GitManagerServiceError>; | |
| 27 | ||
| 28 | /** | |
| 29 | * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`). | |
| 30 | */ | |
| 31 | readonly runStackedAction: ( | |
| 32 | input: GitRunStackedActionInput, | |
| 33 | ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>; | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * GitManager - Service tag for stacked Git workflow orchestration. | |
| 38 | */ | |
| 39 | export class GitManager extends ServiceMap.Service<GitManager, GitManagerShape>()( | |
| 40 | "git/GitManager", | |
| 41 | ) {} |
apps/server/src/git/Services/GitService.ts created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | /** | |
| 2 | * GitService - Service for Git command execution. | |
| 3 | * | |
| 4 | * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed | |
| 5 | * domain errors for Git command execution. | |
| 6 | * | |
| 7 | * @module GitService | |
| 8 | */ | |
| 9 | import { ServiceMap } from "effect"; | |
| 10 | import type { Effect } from "effect"; | |
| 11 | ||
| 12 | import type { GitCommandError } from "../Errors.ts"; | |
| 13 | ||
| 14 | export interface ExecuteGitInput { | |
| 15 | readonly operation: string; | |
| 16 | readonly cwd: string; | |
| 17 | readonly args: ReadonlyArray<string>; | |
| 18 | readonly env?: NodeJS.ProcessEnv; | |
| 19 | readonly allowNonZeroExit?: boolean; | |
| 20 | readonly timeoutMs?: number; | |
| 21 | readonly maxOutputBytes?: number; | |
| 22 | } | |
| 23 | ||
| 24 | export interface ExecuteGitResult { | |
| 25 | readonly code: number; | |
| 26 | readonly stdout: string; | |
| 27 | readonly stderr: string; | |
| 28 | } | |
| 29 | ||
| 30 | export interface GitServiceShape { | |
| 31 | /** | |
| 32 | * Execute a Git command. | |
| 33 | */ | |
| 34 | readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>; | |
| 35 | } | |
| 36 | ||
| 37 | /** | |
| 38 | * GitService - Service for Git command execution. | |
| 39 | */ | |
| 40 | export class GitService extends ServiceMap.Service<GitService, GitServiceShape>()( | |
| 41 | "git/GitService", | |
| 42 | ) {} |
apps/server/src/git/Services/TextGeneration.ts created+71| ... | ... | @@ -0,0 +1,71 @@ |
| 1 | /** | |
| 2 | * TextGeneration - Effect service contract for AI-generated Git content. | |
| 3 | * | |
| 4 | * Generates commit messages and pull request titles/bodies from repository | |
| 5 | * context prepared by Git services. | |
| 6 | * | |
| 7 | * @module TextGeneration | |
| 8 | */ | |
| 9 | import { ServiceMap } from "effect"; | |
| 10 | import type { Effect } from "effect"; | |
| 11 | ||
| 12 | import type { TextGenerationError } from "../Errors.ts"; | |
| 13 | ||
| 14 | export interface CommitMessageGenerationInput { | |
| 15 | cwd: string; | |
| 16 | branch: string | null; | |
| 17 | stagedSummary: string; | |
| 18 | stagedPatch: string; | |
| 19 | } | |
| 20 | ||
| 21 | export interface CommitMessageGenerationResult { | |
| 22 | subject: string; | |
| 23 | body: string; | |
| 24 | } | |
| 25 | ||
| 26 | export interface PrContentGenerationInput { | |
| 27 | cwd: string; | |
| 28 | baseBranch: string; | |
| 29 | headBranch: string; | |
| 30 | commitSummary: string; | |
| 31 | diffSummary: string; | |
| 32 | diffPatch: string; | |
| 33 | } | |
| 34 | ||
| 35 | export interface PrContentGenerationResult { | |
| 36 | title: string; | |
| 37 | body: string; | |
| 38 | } | |
| 39 | ||
| 40 | export interface TextGenerationService { | |
| 41 | generateCommitMessage( | |
| 42 | input: CommitMessageGenerationInput, | |
| 43 | ): Promise<CommitMessageGenerationResult>; | |
| 44 | generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult>; | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * TextGenerationShape - Service API for commit/PR text generation. | |
| 49 | */ | |
| 50 | export interface TextGenerationShape { | |
| 51 | /** | |
| 52 | * Generate a commit message from staged change context. | |
| 53 | */ | |
| 54 | readonly generateCommitMessage: ( | |
| 55 | input: CommitMessageGenerationInput, | |
| 56 | ) => Effect.Effect<CommitMessageGenerationResult, TextGenerationError>; | |
| 57 | ||
| 58 | /** | |
| 59 | * Generate pull request title/body from branch and diff context. | |
| 60 | */ | |
| 61 | readonly generatePrContent: ( | |
| 62 | input: PrContentGenerationInput, | |
| 63 | ) => Effect.Effect<PrContentGenerationResult, TextGenerationError>; | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * TextGeneration - Service tag for commit and PR text generation. | |
| 68 | */ | |
| 69 | export class TextGeneration extends ServiceMap.Service<TextGeneration, TextGenerationShape>()( | |
| 70 | "git/TextGeneration", | |
| 71 | ) {} |
apps/server/src/gitManager.test.ts deleted-482| ... | ... | @@ -1,482 +0,0 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { afterEach, describe, expect, it } from "vitest"; | |
| 6 | ||
| 7 | import { GitManager } from "./gitManager"; | |
| 8 | import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner"; | |
| 9 | ||
| 10 | interface FakeGhScenario { | |
| 11 | prListSequence?: string[]; | |
| 12 | createdPrUrl?: string; | |
| 13 | defaultBranch?: string; | |
| 14 | failWith?: Error; | |
| 15 | } | |
| 16 | ||
| 17 | interface FakeGitTextGenerator { | |
| 18 | generateCommitMessage: (input: { | |
| 19 | cwd: string; | |
| 20 | branch: string | null; | |
| 21 | stagedSummary: string; | |
| 22 | stagedPatch: string; | |
| 23 | }) => Promise<{ subject: string; body: string }>; | |
| 24 | generatePrContent: (input: { | |
| 25 | cwd: string; | |
| 26 | baseBranch: string; | |
| 27 | headBranch: string; | |
| 28 | commitSummary: string; | |
| 29 | diffSummary: string; | |
| 30 | diffPatch: string; | |
| 31 | }) => Promise<{ title: string; body: string }>; | |
| 32 | } | |
| 33 | ||
| 34 | const tempDirs: string[] = []; | |
| 35 | ||
| 36 | function makeTempDir(prefix: string): string { | |
| 37 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 38 | tempDirs.push(dir); | |
| 39 | return dir; | |
| 40 | } | |
| 41 | ||
| 42 | async function runGit( | |
| 43 | cwd: string, | |
| 44 | args: readonly string[], | |
| 45 | allowNonZeroExit = false, | |
| 46 | ): Promise<ProcessRunResult> { | |
| 47 | return runProcess("git", args, { cwd, allowNonZeroExit }); | |
| 48 | } | |
| 49 | ||
| 50 | async function runGitStdout(cwd: string, args: readonly string[]): Promise<string> { | |
| 51 | const result = await runGit(cwd, args); | |
| 52 | return result.stdout.trim(); | |
| 53 | } | |
| 54 | ||
| 55 | async function initRepo(cwd: string): Promise<void> { | |
| 56 | await runGit(cwd, ["init", "--initial-branch=main"]); | |
| 57 | await runGit(cwd, ["config", "user.email", "test@example.com"]); | |
| 58 | await runGit(cwd, ["config", "user.name", "Test User"]); | |
| 59 | fs.writeFileSync(path.join(cwd, "README.md"), "hello\n"); | |
| 60 | await runGit(cwd, ["add", "README.md"]); | |
| 61 | await runGit(cwd, ["commit", "-m", "Initial commit"]); | |
| 62 | } | |
| 63 | ||
| 64 | async function createBareRemote(): Promise<string> { | |
| 65 | const remoteDir = makeTempDir("t3code-git-remote-"); | |
| 66 | await runProcess("git", ["init", "--bare"], { cwd: remoteDir }); | |
| 67 | return remoteDir; | |
| 68 | } | |
| 69 | ||
| 70 | function createTextGenerator(overrides: Partial<FakeGitTextGenerator> = {}): FakeGitTextGenerator { | |
| 71 | return { | |
| 72 | generateCommitMessage: async () => ({ | |
| 73 | subject: "Implement stacked git actions", | |
| 74 | body: "", | |
| 75 | }), | |
| 76 | generatePrContent: async () => ({ | |
| 77 | title: "Add stacked git actions", | |
| 78 | body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", | |
| 79 | }), | |
| 80 | ...overrides, | |
| 81 | }; | |
| 82 | } | |
| 83 | ||
| 84 | function createRunnerWithFakeGh(scenario: FakeGhScenario = {}): { | |
| 85 | runner: ( | |
| 86 | command: string, | |
| 87 | args: readonly string[], | |
| 88 | options?: ProcessRunOptions, | |
| 89 | ) => Promise<ProcessRunResult>; | |
| 90 | ghCalls: string[]; | |
| 91 | } { | |
| 92 | const prListQueue = [...(scenario.prListSequence ?? [])]; | |
| 93 | const ghCalls: string[] = []; | |
| 94 | ||
| 95 | const runner = async ( | |
| 96 | command: string, | |
| 97 | args: readonly string[], | |
| 98 | options?: ProcessRunOptions, | |
| 99 | ): Promise<ProcessRunResult> => { | |
| 100 | if (command !== "gh") { | |
| 101 | return runProcess(command, args, options); | |
| 102 | } | |
| 103 | ||
| 104 | ghCalls.push(args.join(" ")); | |
| 105 | if (scenario.failWith) { | |
| 106 | throw scenario.failWith; | |
| 107 | } | |
| 108 | ||
| 109 | if (args[0] === "pr" && args[1] === "list") { | |
| 110 | const stdout = (prListQueue.shift() ?? "[]") + "\n"; | |
| 111 | return { | |
| 112 | stdout, | |
| 113 | stderr: "", | |
| 114 | code: 0, | |
| 115 | signal: null, | |
| 116 | timedOut: false, | |
| 117 | }; | |
| 118 | } | |
| 119 | ||
| 120 | if (args[0] === "pr" && args[1] === "create") { | |
| 121 | return { | |
| 122 | stdout: | |
| 123 | (scenario.createdPrUrl ?? "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n", | |
| 124 | stderr: "", | |
| 125 | code: 0, | |
| 126 | signal: null, | |
| 127 | timedOut: false, | |
| 128 | }; | |
| 129 | } | |
| 130 | ||
| 131 | if (args[0] === "pr" && args[1] === "view") { | |
| 132 | return { | |
| 133 | stdout: "", | |
| 134 | stderr: "", | |
| 135 | code: 0, | |
| 136 | signal: null, | |
| 137 | timedOut: false, | |
| 138 | }; | |
| 139 | } | |
| 140 | ||
| 141 | if (args[0] === "repo" && args[1] === "view") { | |
| 142 | return { | |
| 143 | stdout: `${scenario.defaultBranch ?? "main"}\n`, | |
| 144 | stderr: "", | |
| 145 | code: 0, | |
| 146 | signal: null, | |
| 147 | timedOut: false, | |
| 148 | }; | |
| 149 | } | |
| 150 | ||
| 151 | throw new Error(`Unexpected gh command: ${args.join(" ")}`); | |
| 152 | }; | |
| 153 | ||
| 154 | return { runner, ghCalls }; | |
| 155 | } | |
| 156 | ||
| 157 | afterEach(() => { | |
| 158 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 159 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 160 | } | |
| 161 | }); | |
| 162 | ||
| 163 | describe("GitManager", () => { | |
| 164 | it("status includes open PR metadata when branch already has an open PR", async () => { | |
| 165 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 166 | await initRepo(repoDir); | |
| 167 | await runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]); | |
| 168 | const remoteDir = await createBareRemote(); | |
| 169 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 170 | await runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); | |
| 171 | ||
| 172 | const { runner } = createRunnerWithFakeGh({ | |
| 173 | prListSequence: [ | |
| 174 | JSON.stringify([ | |
| 175 | { | |
| 176 | number: 13, | |
| 177 | title: "Existing PR", | |
| 178 | url: "https://github.com/pingdotgg/codething-mvp/pull/13", | |
| 179 | baseRefName: "main", | |
| 180 | headRefName: "feature/status-open-pr", | |
| 181 | }, | |
| 182 | ]), | |
| 183 | ], | |
| 184 | }); | |
| 185 | ||
| 186 | const manager = new GitManager({ | |
| 187 | runProcess: runner, | |
| 188 | textGenerator: createTextGenerator(), | |
| 189 | }); | |
| 190 | ||
| 191 | const status = await manager.status({ cwd: repoDir }); | |
| 192 | expect(status.branch).toBe("feature/status-open-pr"); | |
| 193 | expect(status.openPr).toEqual({ | |
| 194 | number: 13, | |
| 195 | title: "Existing PR", | |
| 196 | url: "https://github.com/pingdotgg/codething-mvp/pull/13", | |
| 197 | baseBranch: "main", | |
| 198 | headBranch: "feature/status-open-pr", | |
| 199 | }); | |
| 200 | }); | |
| 201 | ||
| 202 | it("status is resilient to gh lookup failures and returns openPr null", async () => { | |
| 203 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 204 | await initRepo(repoDir); | |
| 205 | await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); | |
| 206 | const remoteDir = await createBareRemote(); | |
| 207 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 208 | await runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]); | |
| 209 | ||
| 210 | const { runner } = createRunnerWithFakeGh({ | |
| 211 | failWith: new Error("Command not found: gh"), | |
| 212 | }); | |
| 213 | const manager = new GitManager({ | |
| 214 | runProcess: runner, | |
| 215 | textGenerator: createTextGenerator(), | |
| 216 | }); | |
| 217 | ||
| 218 | const status = await manager.status({ cwd: repoDir }); | |
| 219 | expect(status.branch).toBe("feature/status-no-gh"); | |
| 220 | expect(status.openPr).toBeNull(); | |
| 221 | }); | |
| 222 | ||
| 223 | it("creates a commit when working tree is dirty", async () => { | |
| 224 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 225 | await initRepo(repoDir); | |
| 226 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n"); | |
| 227 | ||
| 228 | const manager = new GitManager({ | |
| 229 | textGenerator: createTextGenerator(), | |
| 230 | }); | |
| 231 | const result = await manager.runStackedAction({ | |
| 232 | cwd: repoDir, | |
| 233 | action: "commit", | |
| 234 | }); | |
| 235 | ||
| 236 | expect(result.commit.status).toBe("created"); | |
| 237 | expect(result.push.status).toBe("skipped_not_requested"); | |
| 238 | expect(result.pr.status).toBe("skipped_not_requested"); | |
| 239 | expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%s"])).toBe( | |
| 240 | "Implement stacked git actions", | |
| 241 | ); | |
| 242 | }); | |
| 243 | ||
| 244 | it("uses custom commit message when provided", async () => { | |
| 245 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 246 | await initRepo(repoDir); | |
| 247 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n"); | |
| 248 | let generatedCount = 0; | |
| 249 | ||
| 250 | const manager = new GitManager({ | |
| 251 | textGenerator: createTextGenerator({ | |
| 252 | generateCommitMessage: async () => { | |
| 253 | generatedCount += 1; | |
| 254 | return { | |
| 255 | subject: "this should not be used", | |
| 256 | body: "", | |
| 257 | }; | |
| 258 | }, | |
| 259 | }), | |
| 260 | }); | |
| 261 | const result = await manager.runStackedAction({ | |
| 262 | cwd: repoDir, | |
| 263 | action: "commit", | |
| 264 | commitMessage: "feat: custom summary line\n\n- details from user", | |
| 265 | }); | |
| 266 | ||
| 267 | expect(result.commit.status).toBe("created"); | |
| 268 | expect(result.commit.subject).toBe("feat: custom summary line"); | |
| 269 | expect(generatedCount).toBe(0); | |
| 270 | expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%s"])).toBe( | |
| 271 | "feat: custom summary line", | |
| 272 | ); | |
| 273 | expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%b"])).toContain( | |
| 274 | "- details from user", | |
| 275 | ); | |
| 276 | }); | |
| 277 | ||
| 278 | it("skips commit when there are no uncommitted changes", async () => { | |
| 279 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 280 | await initRepo(repoDir); | |
| 281 | ||
| 282 | const manager = new GitManager({ | |
| 283 | textGenerator: createTextGenerator(), | |
| 284 | }); | |
| 285 | const result = await manager.runStackedAction({ | |
| 286 | cwd: repoDir, | |
| 287 | action: "commit", | |
| 288 | }); | |
| 289 | ||
| 290 | expect(result.commit.status).toBe("skipped_no_changes"); | |
| 291 | expect(result.push.status).toBe("skipped_not_requested"); | |
| 292 | expect(result.pr.status).toBe("skipped_not_requested"); | |
| 293 | }); | |
| 294 | ||
| 295 | it("commits and pushes with upstream auto-setup when needed", async () => { | |
| 296 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 297 | await initRepo(repoDir); | |
| 298 | await runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]); | |
| 299 | const remoteDir = await createBareRemote(); | |
| 300 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 301 | fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); | |
| 302 | ||
| 303 | const manager = new GitManager({ | |
| 304 | textGenerator: createTextGenerator(), | |
| 305 | }); | |
| 306 | const result = await manager.runStackedAction({ | |
| 307 | cwd: repoDir, | |
| 308 | action: "commit_push", | |
| 309 | }); | |
| 310 | ||
| 311 | expect(result.commit.status).toBe("created"); | |
| 312 | expect(result.push.status).toBe("pushed"); | |
| 313 | expect(result.push.setUpstream).toBe(true); | |
| 314 | expect(await runGitStdout(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( | |
| 315 | "origin/feature/stacked-flow", | |
| 316 | ); | |
| 317 | }); | |
| 318 | ||
| 319 | it("skips push when branch is already up to date", async () => { | |
| 320 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 321 | await initRepo(repoDir); | |
| 322 | await runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]); | |
| 323 | const remoteDir = await createBareRemote(); | |
| 324 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 325 | await runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]); | |
| 326 | ||
| 327 | const manager = new GitManager({ | |
| 328 | textGenerator: createTextGenerator(), | |
| 329 | }); | |
| 330 | const result = await manager.runStackedAction({ | |
| 331 | cwd: repoDir, | |
| 332 | action: "commit_push", | |
| 333 | }); | |
| 334 | ||
| 335 | expect(result.commit.status).toBe("skipped_no_changes"); | |
| 336 | expect(result.push.status).toBe("skipped_up_to_date"); | |
| 337 | }); | |
| 338 | ||
| 339 | it("returns existing PR metadata for commit/push/pr action", async () => { | |
| 340 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 341 | await initRepo(repoDir); | |
| 342 | await runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]); | |
| 343 | const remoteDir = await createBareRemote(); | |
| 344 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 345 | await runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]); | |
| 346 | ||
| 347 | const { runner, ghCalls } = createRunnerWithFakeGh({ | |
| 348 | prListSequence: [ | |
| 349 | JSON.stringify([ | |
| 350 | { | |
| 351 | number: 42, | |
| 352 | title: "Existing PR", | |
| 353 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | |
| 354 | baseRefName: "main", | |
| 355 | headRefName: "feature/existing-pr", | |
| 356 | }, | |
| 357 | ]), | |
| 358 | ], | |
| 359 | }); | |
| 360 | ||
| 361 | const manager = new GitManager({ | |
| 362 | runProcess: runner, | |
| 363 | textGenerator: createTextGenerator(), | |
| 364 | }); | |
| 365 | const result = await manager.runStackedAction({ | |
| 366 | cwd: repoDir, | |
| 367 | action: "commit_push_pr", | |
| 368 | }); | |
| 369 | ||
| 370 | expect(result.pr.status).toBe("opened_existing"); | |
| 371 | expect(result.pr.number).toBe(42); | |
| 372 | expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); | |
| 373 | }); | |
| 374 | ||
| 375 | it("creates PR when one does not already exist", async () => { | |
| 376 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 377 | await initRepo(repoDir); | |
| 378 | await runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); | |
| 379 | const remoteDir = await createBareRemote(); | |
| 380 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 381 | fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); | |
| 382 | await runGit(repoDir, ["add", "changes.txt"]); | |
| 383 | await runGit(repoDir, ["commit", "-m", "Feature commit"]); | |
| 384 | await runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); | |
| 385 | await runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]); | |
| 386 | ||
| 387 | const { runner, ghCalls } = createRunnerWithFakeGh({ | |
| 388 | prListSequence: [ | |
| 389 | "[]", | |
| 390 | JSON.stringify([ | |
| 391 | { | |
| 392 | number: 88, | |
| 393 | title: "Add stacked git actions", | |
| 394 | url: "https://github.com/pingdotgg/codething-mvp/pull/88", | |
| 395 | baseRefName: "main", | |
| 396 | headRefName: "feature-create-pr", | |
| 397 | }, | |
| 398 | ]), | |
| 399 | ], | |
| 400 | }); | |
| 401 | ||
| 402 | const manager = new GitManager({ | |
| 403 | runProcess: runner, | |
| 404 | textGenerator: createTextGenerator(), | |
| 405 | }); | |
| 406 | const result = await manager.runStackedAction({ | |
| 407 | cwd: repoDir, | |
| 408 | action: "commit_push_pr", | |
| 409 | }); | |
| 410 | ||
| 411 | expect(result.pr.status).toBe("created"); | |
| 412 | expect(result.pr.number).toBe(88); | |
| 413 | expect( | |
| 414 | ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), | |
| 415 | ).toBe(true); | |
| 416 | expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); | |
| 417 | }); | |
| 418 | ||
| 419 | it("rejects push/pr actions from detached HEAD", async () => { | |
| 420 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 421 | await initRepo(repoDir); | |
| 422 | await runGit(repoDir, ["checkout", "--detach", "HEAD"]); | |
| 423 | ||
| 424 | const manager = new GitManager({ | |
| 425 | textGenerator: createTextGenerator(), | |
| 426 | }); | |
| 427 | await expect( | |
| 428 | manager.runStackedAction({ | |
| 429 | cwd: repoDir, | |
| 430 | action: "commit_push", | |
| 431 | }), | |
| 432 | ).rejects.toThrow("detached HEAD"); | |
| 433 | }); | |
| 434 | ||
| 435 | it("surfaces missing gh binary errors", async () => { | |
| 436 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 437 | await initRepo(repoDir); | |
| 438 | await runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]); | |
| 439 | const remoteDir = await createBareRemote(); | |
| 440 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 441 | await runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]); | |
| 442 | ||
| 443 | const { runner } = createRunnerWithFakeGh({ | |
| 444 | failWith: new Error("Command not found: gh"), | |
| 445 | }); | |
| 446 | const manager = new GitManager({ | |
| 447 | runProcess: runner, | |
| 448 | textGenerator: createTextGenerator(), | |
| 449 | }); | |
| 450 | ||
| 451 | await expect( | |
| 452 | manager.runStackedAction({ | |
| 453 | cwd: repoDir, | |
| 454 | action: "commit_push_pr", | |
| 455 | }), | |
| 456 | ).rejects.toThrow("GitHub CLI (`gh`) is required"); | |
| 457 | }); | |
| 458 | ||
| 459 | it("surfaces gh auth errors with guidance", async () => { | |
| 460 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 461 | await initRepo(repoDir); | |
| 462 | await runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]); | |
| 463 | const remoteDir = await createBareRemote(); | |
| 464 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 465 | await runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]); | |
| 466 | ||
| 467 | const { runner } = createRunnerWithFakeGh({ | |
| 468 | failWith: new Error("authentication failed"), | |
| 469 | }); | |
| 470 | const manager = new GitManager({ | |
| 471 | runProcess: runner, | |
| 472 | textGenerator: createTextGenerator(), | |
| 473 | }); | |
| 474 | ||
| 475 | await expect( | |
| 476 | manager.runStackedAction({ | |
| 477 | cwd: repoDir, | |
| 478 | action: "commit_push_pr", | |
| 479 | }), | |
| 480 | ).rejects.toThrow("gh auth login"); | |
| 481 | }); | |
| 482 | }); |
apps/server/src/gitManager.ts deleted-462| ... | ... | @@ -1,462 +0,0 @@ |
| 1 | import { randomUUID } from "node:crypto"; | |
| 2 | import fs from "node:fs/promises"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | ||
| 6 | import { | |
| 7 | GitRunStackedActionInput, | |
| 8 | GitRunStackedActionResult, | |
| 9 | GitStatusInput, | |
| 10 | GitStatusResult, | |
| 11 | } from "@t3tools/contracts"; | |
| 12 | ||
| 13 | import type { | |
| 14 | CommitMessageGenerationResult, | |
| 15 | PrContentGenerationResult, | |
| 16 | TextGenerationService, | |
| 17 | } from "./coreServices"; | |
| 18 | import { CodexTextGenerator } from "./codexTextGenerator"; | |
| 19 | import { GitCoreService } from "./git"; | |
| 20 | import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner"; | |
| 21 | ||
| 22 | const GH_CLI_TIMEOUT_MS = 30_000; | |
| 23 | ||
| 24 | interface GitManagerDeps { | |
| 25 | runProcess?: ( | |
| 26 | command: string, | |
| 27 | args: readonly string[], | |
| 28 | options?: ProcessRunOptions, | |
| 29 | ) => Promise<ProcessRunResult>; | |
| 30 | textGenerator?: TextGenerationService; | |
| 31 | gitCore?: GitCoreService; | |
| 32 | } | |
| 33 | ||
| 34 | interface OpenPrInfo { | |
| 35 | number: number; | |
| 36 | title: string; | |
| 37 | url: string; | |
| 38 | baseRefName: string; | |
| 39 | headRefName: string; | |
| 40 | } | |
| 41 | ||
| 42 | function parseOpenPrList(raw: unknown): OpenPrInfo[] { | |
| 43 | if (!Array.isArray(raw)) return []; | |
| 44 | ||
| 45 | const parsed: OpenPrInfo[] = []; | |
| 46 | for (const entry of raw) { | |
| 47 | if (!entry || typeof entry !== "object") continue; | |
| 48 | const record = entry as Record<string, unknown>; | |
| 49 | const number = record.number; | |
| 50 | const title = record.title; | |
| 51 | const url = record.url; | |
| 52 | const baseRefName = record.baseRefName; | |
| 53 | const headRefName = record.headRefName; | |
| 54 | if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) { | |
| 55 | continue; | |
| 56 | } | |
| 57 | if ( | |
| 58 | typeof title !== "string" || | |
| 59 | typeof url !== "string" || | |
| 60 | typeof baseRefName !== "string" || | |
| 61 | typeof headRefName !== "string" | |
| 62 | ) { | |
| 63 | continue; | |
| 64 | } | |
| 65 | parsed.push({ | |
| 66 | number, | |
| 67 | title, | |
| 68 | url, | |
| 69 | baseRefName, | |
| 70 | headRefName, | |
| 71 | }); | |
| 72 | } | |
| 73 | return parsed; | |
| 74 | } | |
| 75 | ||
| 76 | function trimStdout(value: string): string { | |
| 77 | return value.trim(); | |
| 78 | } | |
| 79 | ||
| 80 | function limitContext(value: string, maxChars: number): string { | |
| 81 | if (value.length <= maxChars) return value; | |
| 82 | return `${value.slice(0, maxChars)}\n\n[truncated]`; | |
| 83 | } | |
| 84 | ||
| 85 | function sanitizeCommitMessage( | |
| 86 | generated: CommitMessageGenerationResult, | |
| 87 | ): CommitMessageGenerationResult { | |
| 88 | const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | |
| 89 | const subject = rawSubject.replace(/[.]+$/g, "").trim(); | |
| 90 | const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files"; | |
| 91 | return { | |
| 92 | subject: safeSubject, | |
| 93 | body: generated.body.trim(), | |
| 94 | }; | |
| 95 | } | |
| 96 | ||
| 97 | function parseCustomCommitMessage(raw: string): CommitMessageGenerationResult | null { | |
| 98 | const normalized = raw.replace(/\r\n/g, "\n").trim(); | |
| 99 | if (normalized.length === 0) { | |
| 100 | return null; | |
| 101 | } | |
| 102 | ||
| 103 | const [firstLine, ...rest] = normalized.split("\n"); | |
| 104 | const subject = firstLine?.trim() ?? ""; | |
| 105 | if (subject.length === 0) { | |
| 106 | return null; | |
| 107 | } | |
| 108 | ||
| 109 | return { | |
| 110 | subject, | |
| 111 | body: rest.join("\n").trim(), | |
| 112 | }; | |
| 113 | } | |
| 114 | ||
| 115 | function extractBranchFromRef(ref: string): string { | |
| 116 | const normalized = ref.trim(); | |
| 117 | ||
| 118 | if (normalized.startsWith("refs/remotes/")) { | |
| 119 | const withoutPrefix = normalized.slice("refs/remotes/".length); | |
| 120 | const firstSlash = withoutPrefix.indexOf("/"); | |
| 121 | if (firstSlash === -1) { | |
| 122 | return withoutPrefix.trim(); | |
| 123 | } | |
| 124 | return withoutPrefix.slice(firstSlash + 1).trim(); | |
| 125 | } | |
| 126 | ||
| 127 | const firstSlash = normalized.indexOf("/"); | |
| 128 | if (firstSlash === -1) { | |
| 129 | return normalized; | |
| 130 | } | |
| 131 | return normalized.slice(firstSlash + 1).trim(); | |
| 132 | } | |
| 133 | ||
| 134 | function asCommandNotFound(command: string, error: unknown): Error | undefined { | |
| 135 | if (!(error instanceof Error)) return undefined; | |
| 136 | if (!error.message.includes(`Command not found: ${command}`)) return undefined; | |
| 137 | if (command === "gh") { | |
| 138 | return new Error("GitHub CLI (`gh`) is required but not available on PATH."); | |
| 139 | } | |
| 140 | if (command === "codex") { | |
| 141 | return new Error("Codex CLI (`codex`) is required but not available on PATH."); | |
| 142 | } | |
| 143 | return new Error(`${command} is required but not available on PATH.`); | |
| 144 | } | |
| 145 | ||
| 146 | function normalizeGitHubAuthError(error: unknown): Error | undefined { | |
| 147 | if (!(error instanceof Error)) return undefined; | |
| 148 | const lower = error.message.toLowerCase(); | |
| 149 | if ( | |
| 150 | lower.includes("authentication failed") || | |
| 151 | lower.includes("not logged in") || | |
| 152 | lower.includes("gh auth login") || | |
| 153 | lower.includes("no oauth token") | |
| 154 | ) { | |
| 155 | return new Error("GitHub CLI is not authenticated. Run `gh auth login` and retry."); | |
| 156 | } | |
| 157 | return undefined; | |
| 158 | } | |
| 159 | ||
| 160 | function toStatusOpenPr(pr: OpenPrInfo): { | |
| 161 | number: number; | |
| 162 | title: string; | |
| 163 | url: string; | |
| 164 | baseBranch: string; | |
| 165 | headBranch: string; | |
| 166 | } { | |
| 167 | return { | |
| 168 | number: pr.number, | |
| 169 | title: pr.title, | |
| 170 | url: pr.url, | |
| 171 | baseBranch: pr.baseRefName, | |
| 172 | headBranch: pr.headRefName, | |
| 173 | }; | |
| 174 | } | |
| 175 | ||
| 176 | export class GitManager { | |
| 177 | private readonly run: NonNullable<GitManagerDeps["runProcess"]>; | |
| 178 | private readonly textGenerator: TextGenerationService; | |
| 179 | private readonly gitCore: GitCoreService; | |
| 180 | ||
| 181 | constructor(deps: GitManagerDeps = {}) { | |
| 182 | this.run = deps.runProcess ?? runProcess; | |
| 183 | this.textGenerator = deps.textGenerator ?? new CodexTextGenerator(); | |
| 184 | this.gitCore = deps.gitCore ?? new GitCoreService(); | |
| 185 | } | |
| 186 | ||
| 187 | async status(input: GitStatusInput): Promise<GitStatusResult> { | |
| 188 | const details = await this.gitCore.statusDetails(input.cwd); | |
| 189 | ||
| 190 | let openPr: ReturnType<typeof toStatusOpenPr> | null = null; | |
| 191 | if (details.branch && details.hasUpstream) { | |
| 192 | try { | |
| 193 | const existing = await this.findOpenPr(input.cwd, details.branch); | |
| 194 | if (existing) { | |
| 195 | openPr = toStatusOpenPr(existing); | |
| 196 | } | |
| 197 | } catch { | |
| 198 | // PR lookup is best-effort for status rendering. | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 202 | return { | |
| 203 | branch: details.branch, | |
| 204 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | |
| 205 | workingTree: details.workingTree, | |
| 206 | hasUpstream: details.hasUpstream, | |
| 207 | aheadCount: details.aheadCount, | |
| 208 | behindCount: details.behindCount, | |
| 209 | openPr, | |
| 210 | }; | |
| 211 | } | |
| 212 | ||
| 213 | async runStackedAction(input: GitRunStackedActionInput): Promise<GitRunStackedActionResult> { | |
| 214 | const wantsPush = input.action !== "commit"; | |
| 215 | const wantsPr = input.action === "commit_push_pr"; | |
| 216 | ||
| 217 | const initialStatus = await this.gitCore.statusDetails(input.cwd); | |
| 218 | if (wantsPush && !initialStatus.branch) { | |
| 219 | throw new Error("Cannot push from detached HEAD."); | |
| 220 | } | |
| 221 | if (wantsPr && !initialStatus.branch) { | |
| 222 | throw new Error("Cannot create a pull request from detached HEAD."); | |
| 223 | } | |
| 224 | ||
| 225 | const commit = await this.runCommitStep(input.cwd, initialStatus.branch, input.commitMessage); | |
| 226 | ||
| 227 | const push = wantsPush | |
| 228 | ? await this.gitCore.pushCurrentBranch(input.cwd, initialStatus.branch) | |
| 229 | : { status: "skipped_not_requested" as const }; | |
| 230 | ||
| 231 | const pr = wantsPr | |
| 232 | ? await this.runPrStep(input.cwd, initialStatus.branch) | |
| 233 | : { status: "skipped_not_requested" as const }; | |
| 234 | ||
| 235 | return { | |
| 236 | action: input.action, | |
| 237 | commit, | |
| 238 | push, | |
| 239 | pr, | |
| 240 | }; | |
| 241 | } | |
| 242 | ||
| 243 | private async runCommitStep( | |
| 244 | cwd: string, | |
| 245 | branch: string | null, | |
| 246 | commitMessage?: string, | |
| 247 | ): Promise<{ | |
| 248 | status: "created" | "skipped_no_changes"; | |
| 249 | commitSha?: string | undefined; | |
| 250 | subject?: string | undefined; | |
| 251 | }> { | |
| 252 | const context = await this.gitCore.prepareCommitContext(cwd); | |
| 253 | if (!context) { | |
| 254 | return { status: "skipped_no_changes" }; | |
| 255 | } | |
| 256 | ||
| 257 | let generated: CommitMessageGenerationResult | null = parseCustomCommitMessage( | |
| 258 | commitMessage ?? "", | |
| 259 | ); | |
| 260 | ||
| 261 | if (!generated) { | |
| 262 | try { | |
| 263 | generated = sanitizeCommitMessage( | |
| 264 | await this.textGenerator.generateCommitMessage({ | |
| 265 | cwd, | |
| 266 | branch, | |
| 267 | stagedSummary: limitContext(context.stagedSummary, 8_000), | |
| 268 | stagedPatch: limitContext(context.stagedPatch, 50_000), | |
| 269 | }), | |
| 270 | ); | |
| 271 | } catch (error) { | |
| 272 | throw asCommandNotFound("codex", error) ?? error; | |
| 273 | } | |
| 274 | } | |
| 275 | ||
| 276 | const { commitSha } = await this.gitCore.commit(cwd, generated.subject, generated.body); | |
| 277 | return { | |
| 278 | status: "created", | |
| 279 | commitSha, | |
| 280 | subject: generated.subject, | |
| 281 | }; | |
| 282 | } | |
| 283 | ||
| 284 | private async runPrStep( | |
| 285 | cwd: string, | |
| 286 | fallbackBranch: string | null, | |
| 287 | ): Promise<{ | |
| 288 | status: "created" | "opened_existing" | "skipped_not_requested"; | |
| 289 | url?: string | undefined; | |
| 290 | number?: number | undefined; | |
| 291 | baseBranch?: string | undefined; | |
| 292 | headBranch?: string | undefined; | |
| 293 | title?: string | undefined; | |
| 294 | }> { | |
| 295 | const details = await this.gitCore.statusDetails(cwd); | |
| 296 | const branch = details.branch ?? fallbackBranch; | |
| 297 | if (!branch) { | |
| 298 | throw new Error("Cannot create a pull request from detached HEAD."); | |
| 299 | } | |
| 300 | if (!details.hasUpstream) { | |
| 301 | throw new Error("Current branch has not been pushed. Push before creating a PR."); | |
| 302 | } | |
| 303 | ||
| 304 | const existing = await this.findOpenPr(cwd, branch); | |
| 305 | if (existing) { | |
| 306 | return { | |
| 307 | status: "opened_existing", | |
| 308 | url: existing.url, | |
| 309 | number: existing.number, | |
| 310 | baseBranch: existing.baseRefName, | |
| 311 | headBranch: existing.headRefName, | |
| 312 | title: existing.title, | |
| 313 | }; | |
| 314 | } | |
| 315 | ||
| 316 | const baseBranch = await this.resolveBaseBranch(cwd, branch, details.upstreamRef); | |
| 317 | const rangeContext = await this.gitCore.readRangeContext(cwd, baseBranch); | |
| 318 | ||
| 319 | let generated: PrContentGenerationResult; | |
| 320 | try { | |
| 321 | generated = await this.textGenerator.generatePrContent({ | |
| 322 | cwd, | |
| 323 | baseBranch, | |
| 324 | headBranch: branch, | |
| 325 | commitSummary: limitContext(rangeContext.commitSummary, 20_000), | |
| 326 | diffSummary: limitContext(rangeContext.diffSummary, 20_000), | |
| 327 | diffPatch: limitContext(rangeContext.diffPatch, 60_000), | |
| 328 | }); | |
| 329 | } catch (error) { | |
| 330 | throw asCommandNotFound("codex", error) ?? error; | |
| 331 | } | |
| 332 | ||
| 333 | const bodyFile = path.join(os.tmpdir(), `t3code-pr-body-${process.pid}-${randomUUID()}.md`); | |
| 334 | await fs.writeFile(bodyFile, generated.body, "utf8"); | |
| 335 | ||
| 336 | try { | |
| 337 | await this.runGh(cwd, [ | |
| 338 | "pr", | |
| 339 | "create", | |
| 340 | "--base", | |
| 341 | baseBranch, | |
| 342 | "--head", | |
| 343 | branch, | |
| 344 | "--title", | |
| 345 | generated.title, | |
| 346 | "--body-file", | |
| 347 | bodyFile, | |
| 348 | ]); | |
| 349 | } finally { | |
| 350 | try { | |
| 351 | await fs.unlink(bodyFile); | |
| 352 | } catch { | |
| 353 | // Best-effort cleanup. | |
| 354 | } | |
| 355 | } | |
| 356 | ||
| 357 | const created = await this.findOpenPr(cwd, branch); | |
| 358 | ||
| 359 | if (!created) { | |
| 360 | return { | |
| 361 | status: "created", | |
| 362 | baseBranch, | |
| 363 | headBranch: branch, | |
| 364 | title: generated.title, | |
| 365 | }; | |
| 366 | } | |
| 367 | ||
| 368 | return { | |
| 369 | status: "created", | |
| 370 | url: created.url, | |
| 371 | number: created.number, | |
| 372 | baseBranch: created.baseRefName, | |
| 373 | headBranch: created.headRefName, | |
| 374 | title: created.title, | |
| 375 | }; | |
| 376 | } | |
| 377 | ||
| 378 | private async findOpenPr(cwd: string, branch: string): Promise<OpenPrInfo | null> { | |
| 379 | const stdout = await this.runGhStdout(cwd, [ | |
| 380 | "pr", | |
| 381 | "list", | |
| 382 | "--head", | |
| 383 | branch, | |
| 384 | "--state", | |
| 385 | "open", | |
| 386 | "--limit", | |
| 387 | "1", | |
| 388 | "--json", | |
| 389 | "number,title,url,baseRefName,headRefName", | |
| 390 | ]); | |
| 391 | ||
| 392 | const raw = trimStdout(stdout); | |
| 393 | if (raw.length === 0) return null; | |
| 394 | ||
| 395 | let parsedJson: unknown; | |
| 396 | try { | |
| 397 | parsedJson = JSON.parse(raw); | |
| 398 | } catch { | |
| 399 | throw new Error("GitHub CLI returned invalid PR list JSON."); | |
| 400 | } | |
| 401 | ||
| 402 | const parsed = parseOpenPrList(parsedJson); | |
| 403 | return parsed[0] ?? null; | |
| 404 | } | |
| 405 | ||
| 406 | private async resolveBaseBranch( | |
| 407 | cwd: string, | |
| 408 | branch: string, | |
| 409 | upstreamRef: string | null, | |
| 410 | ): Promise<string> { | |
| 411 | const configured = await this.gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); | |
| 412 | if (configured) return configured; | |
| 413 | ||
| 414 | if (upstreamRef) { | |
| 415 | const upstreamBranch = extractBranchFromRef(upstreamRef); | |
| 416 | if (upstreamBranch.length > 0 && upstreamBranch !== branch) { | |
| 417 | return upstreamBranch; | |
| 418 | } | |
| 419 | } | |
| 420 | ||
| 421 | try { | |
| 422 | const ghDefault = await this.runGhStdout(cwd, [ | |
| 423 | "repo", | |
| 424 | "view", | |
| 425 | "--json", | |
| 426 | "defaultBranchRef", | |
| 427 | "--jq", | |
| 428 | ".defaultBranchRef.name", | |
| 429 | ]); | |
| 430 | const defaultBranch = trimStdout(ghDefault); | |
| 431 | if (defaultBranch.length > 0) return defaultBranch; | |
| 432 | } catch { | |
| 433 | // Fall through to deterministic fallback. | |
| 434 | } | |
| 435 | ||
| 436 | return "main"; | |
| 437 | } | |
| 438 | ||
| 439 | private async runGh(cwd: string, args: readonly string[]): Promise<void> { | |
| 440 | await this.runGhCommand(cwd, args); | |
| 441 | } | |
| 442 | ||
| 443 | private async runGhStdout(cwd: string, args: readonly string[]): Promise<string> { | |
| 444 | const result = await this.runGhCommand(cwd, args); | |
| 445 | return result.stdout; | |
| 446 | } | |
| 447 | ||
| 448 | private async runGhCommand(cwd: string, args: readonly string[]): Promise<ProcessRunResult> { | |
| 449 | try { | |
| 450 | return await this.run("gh", args, { | |
| 451 | cwd, | |
| 452 | timeoutMs: GH_CLI_TIMEOUT_MS, | |
| 453 | }); | |
| 454 | } catch (error) { | |
| 455 | throw ( | |
| 456 | asCommandNotFound("gh", error) ?? | |
| 457 | normalizeGitHubAuthError(error) ?? | |
| 458 | (error instanceof Error ? error : new Error("GitHub CLI command failed.")) | |
| 459 | ); | |
| 460 | } | |
| 461 | } | |
| 462 | } |
apps/server/src/ptyAdapter.test.ts deleted-65| ... | ... | @@ -1,65 +0,0 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { assert, it, vi } from "@effect/vitest"; | |
| 6 | import { Effect, Layer } from "effect"; | |
| 7 | import { afterEach } from "vitest"; | |
| 8 | ||
| 9 | import { ensureNodePtySpawnHelperExecutable } from "./ptyAdapter"; | |
| 10 | ||
| 11 | function fileMode(filePath: string): number { | |
| 12 | return fs.statSync(filePath).mode & 0o777; | |
| 13 | } | |
| 14 | ||
| 15 | const tempDirs: string[] = []; | |
| 16 | ||
| 17 | afterEach(() => { | |
| 18 | vi.unstubAllGlobals(); | |
| 19 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 20 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 21 | } | |
| 22 | }); | |
| 23 | ||
| 24 | const helperLayer = it.layer(Layer.empty); | |
| 25 | ||
| 26 | helperLayer("ensureNodePtySpawnHelperExecutable", (it) => { | |
| 27 | it.effect("adds executable bits when helper exists but is not executable", () => | |
| 28 | Effect.sync(() => { | |
| 29 | if (process.platform === "win32") return; | |
| 30 | ||
| 31 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); | |
| 32 | tempDirs.push(dir); | |
| 33 | const helperPath = path.join(dir, "spawn-helper"); | |
| 34 | fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); | |
| 35 | fs.chmodSync(helperPath, 0o644); | |
| 36 | ||
| 37 | ensureNodePtySpawnHelperExecutable(helperPath); | |
| 38 | ||
| 39 | assert.equal(fileMode(helperPath) & 0o111, 0o111); | |
| 40 | }), | |
| 41 | ); | |
| 42 | ||
| 43 | it.effect("keeps executable helper as executable", () => | |
| 44 | Effect.sync(() => { | |
| 45 | if (process.platform === "win32") return; | |
| 46 | ||
| 47 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); | |
| 48 | tempDirs.push(dir); | |
| 49 | const helperPath = path.join(dir, "spawn-helper"); | |
| 50 | fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); | |
| 51 | fs.chmodSync(helperPath, 0o755); | |
| 52 | ||
| 53 | ensureNodePtySpawnHelperExecutable(helperPath); | |
| 54 | ||
| 55 | assert.equal(fileMode(helperPath) & 0o111, 0o111); | |
| 56 | }), | |
| 57 | ); | |
| 58 | ||
| 59 | it.effect("does not throw when helper path is missing", () => | |
| 60 | Effect.sync(() => { | |
| 61 | const missing = path.join(os.tmpdir(), `does-not-exist-${Date.now()}`); | |
| 62 | ensureNodePtySpawnHelperExecutable(missing); | |
| 63 | }), | |
| 64 | ); | |
| 65 | }); |
apps/server/src/ptyAdapter.ts deleted-256| ... | ... | @@ -1,256 +0,0 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import { createRequire } from "node:module"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { Effect, Layer, ServiceMap } from "effect"; | |
| 6 | ||
| 7 | export interface PtyExitEvent { | |
| 8 | exitCode: number; | |
| 9 | signal: number | null; | |
| 10 | } | |
| 11 | ||
| 12 | export interface PtyProcess { | |
| 13 | readonly pid: number; | |
| 14 | write(data: string): void; | |
| 15 | resize(cols: number, rows: number): void; | |
| 16 | kill(signal?: string): void; | |
| 17 | onData(callback: (data: string) => void): () => void; | |
| 18 | onExit(callback: (event: PtyExitEvent) => void): () => void; | |
| 19 | } | |
| 20 | ||
| 21 | export interface PtySpawnInput { | |
| 22 | shell: string; | |
| 23 | args?: string[]; | |
| 24 | cwd: string; | |
| 25 | cols: number; | |
| 26 | rows: number; | |
| 27 | env: NodeJS.ProcessEnv; | |
| 28 | } | |
| 29 | ||
| 30 | export interface PtyAdapterShape { | |
| 31 | spawn(input: PtySpawnInput): PtyProcess; | |
| 32 | } | |
| 33 | ||
| 34 | export class PtyAdapter extends ServiceMap.Service<PtyAdapter, PtyAdapterShape>()( | |
| 35 | "terminal/PtyAdapter", | |
| 36 | ) { | |
| 37 | static readonly layer = () => | |
| 38 | typeof Bun !== "undefined" ? BunPtyAdapterLive : NodePtyAdapterLive; | |
| 39 | } | |
| 40 | ||
| 41 | const requireForNodePty = createRequire(import.meta.url); | |
| 42 | let didEnsureSpawnHelperExecutable = false; | |
| 43 | ||
| 44 | function resolveNodePtySpawnHelperPath(): string | null { | |
| 45 | try { | |
| 46 | const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); | |
| 47 | const packageDir = path.dirname(packageJsonPath); | |
| 48 | const candidates = [ | |
| 49 | path.join(packageDir, "build", "Release", "spawn-helper"), | |
| 50 | path.join(packageDir, "build", "Debug", "spawn-helper"), | |
| 51 | path.join(packageDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), | |
| 52 | ]; | |
| 53 | ||
| 54 | for (const candidate of candidates) { | |
| 55 | if (fs.existsSync(candidate)) { | |
| 56 | return candidate; | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | return null; | |
| 61 | } catch { | |
| 62 | return null; | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | export function ensureNodePtySpawnHelperExecutable(explicitPath?: string): void { | |
| 67 | if (process.platform === "win32") return; | |
| 68 | if (!explicitPath && didEnsureSpawnHelperExecutable) return; | |
| 69 | ||
| 70 | const helperPath = explicitPath ?? resolveNodePtySpawnHelperPath(); | |
| 71 | if (!helperPath) return; | |
| 72 | if (!explicitPath) { | |
| 73 | didEnsureSpawnHelperExecutable = true; | |
| 74 | } | |
| 75 | ||
| 76 | try { | |
| 77 | const stat = fs.statSync(helperPath); | |
| 78 | const mode = stat.mode & 0o777; | |
| 79 | if ((mode & 0o111) === 0) { | |
| 80 | fs.chmodSync(helperPath, mode | 0o111); | |
| 81 | } | |
| 82 | } catch { | |
| 83 | // Best effort only. If chmod fails, node-pty spawn will surface the real error. | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | class NodePtyProcess implements PtyProcess { | |
| 88 | constructor(private readonly process: import("node-pty").IPty) {} | |
| 89 | ||
| 90 | get pid(): number { | |
| 91 | return this.process.pid; | |
| 92 | } | |
| 93 | ||
| 94 | write(data: string): void { | |
| 95 | this.process.write(data); | |
| 96 | } | |
| 97 | ||
| 98 | resize(cols: number, rows: number): void { | |
| 99 | this.process.resize(cols, rows); | |
| 100 | } | |
| 101 | ||
| 102 | kill(signal?: string): void { | |
| 103 | this.process.kill(signal); | |
| 104 | } | |
| 105 | ||
| 106 | onData(callback: (data: string) => void): () => void { | |
| 107 | const disposable = this.process.onData(callback); | |
| 108 | return () => { | |
| 109 | disposable.dispose(); | |
| 110 | }; | |
| 111 | } | |
| 112 | ||
| 113 | onExit(callback: (event: PtyExitEvent) => void): () => void { | |
| 114 | const disposable = this.process.onExit((event) => { | |
| 115 | callback({ | |
| 116 | exitCode: event.exitCode, | |
| 117 | signal: event.signal ?? null, | |
| 118 | }); | |
| 119 | }); | |
| 120 | return () => { | |
| 121 | disposable.dispose(); | |
| 122 | }; | |
| 123 | } | |
| 124 | } | |
| 125 | ||
| 126 | class BunPtyProcess implements PtyProcess { | |
| 127 | private readonly dataListeners = new Set<(data: string) => void>(); | |
| 128 | private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); | |
| 129 | private readonly decoder = new TextDecoder(); | |
| 130 | private didExit = false; | |
| 131 | ||
| 132 | constructor(private readonly process: Bun.Subprocess) { | |
| 133 | void this.process.exited | |
| 134 | .then((exitCode) => { | |
| 135 | this.emitExit({ | |
| 136 | exitCode: Number.isInteger(exitCode) ? exitCode : 0, | |
| 137 | signal: typeof this.process.signalCode === "number" ? this.process.signalCode : null, | |
| 138 | }); | |
| 139 | }) | |
| 140 | .catch(() => { | |
| 141 | this.emitExit({ exitCode: 1, signal: null }); | |
| 142 | }); | |
| 143 | } | |
| 144 | ||
| 145 | get pid(): number { | |
| 146 | return this.process.pid; | |
| 147 | } | |
| 148 | ||
| 149 | write(data: string): void { | |
| 150 | if (!this.process.terminal) { | |
| 151 | throw new Error("Bun PTY terminal handle is unavailable"); | |
| 152 | } | |
| 153 | this.process.terminal.write(data); | |
| 154 | } | |
| 155 | ||
| 156 | resize(cols: number, rows: number): void { | |
| 157 | if (!this.process.terminal?.resize) { | |
| 158 | throw new Error("Bun PTY resize is unavailable"); | |
| 159 | } | |
| 160 | this.process.terminal.resize(cols, rows); | |
| 161 | } | |
| 162 | ||
| 163 | kill(signal?: string): void { | |
| 164 | if (!signal) { | |
| 165 | this.process.kill(); | |
| 166 | return; | |
| 167 | } | |
| 168 | this.process.kill(signal as NodeJS.Signals); | |
| 169 | } | |
| 170 | ||
| 171 | onData(callback: (data: string) => void): () => void { | |
| 172 | this.dataListeners.add(callback); | |
| 173 | return () => { | |
| 174 | this.dataListeners.delete(callback); | |
| 175 | }; | |
| 176 | } | |
| 177 | ||
| 178 | onExit(callback: (event: PtyExitEvent) => void): () => void { | |
| 179 | this.exitListeners.add(callback); | |
| 180 | return () => { | |
| 181 | this.exitListeners.delete(callback); | |
| 182 | }; | |
| 183 | } | |
| 184 | ||
| 185 | emitData(data: Uint8Array): void { | |
| 186 | if (this.didExit) return; | |
| 187 | const text = this.decoder.decode(data, { stream: true }); | |
| 188 | if (text.length === 0) return; | |
| 189 | for (const listener of this.dataListeners) { | |
| 190 | listener(text); | |
| 191 | } | |
| 192 | } | |
| 193 | ||
| 194 | private emitExit(event: PtyExitEvent): void { | |
| 195 | if (this.didExit) return; | |
| 196 | this.didExit = true; | |
| 197 | ||
| 198 | const remainder = this.decoder.decode(); | |
| 199 | if (remainder.length > 0) { | |
| 200 | for (const listener of this.dataListeners) { | |
| 201 | listener(remainder); | |
| 202 | } | |
| 203 | } | |
| 204 | ||
| 205 | for (const listener of this.exitListeners) { | |
| 206 | listener(event); | |
| 207 | } | |
| 208 | } | |
| 209 | } | |
| 210 | export const NodePtyAdapterLive = Layer.effect( | |
| 211 | PtyAdapter, | |
| 212 | Effect.gen(function* () { | |
| 213 | const nodePty = yield* Effect.promise(() => import("node-pty")); | |
| 214 | return { | |
| 215 | spawn(input: PtySpawnInput): PtyProcess { | |
| 216 | ensureNodePtySpawnHelperExecutable(); | |
| 217 | const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { | |
| 218 | cwd: input.cwd, | |
| 219 | cols: input.cols, | |
| 220 | rows: input.rows, | |
| 221 | env: input.env, | |
| 222 | name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", | |
| 223 | }); | |
| 224 | return new NodePtyProcess(ptyProcess); | |
| 225 | }, | |
| 226 | }; | |
| 227 | }), | |
| 228 | ); | |
| 229 | ||
| 230 | export const BunPtyAdapterLive = Layer.effect( | |
| 231 | PtyAdapter, | |
| 232 | Effect.gen(function* () { | |
| 233 | if (process.platform === "win32") { | |
| 234 | return yield* Effect.die("Bun PTY terminal support is unavailable on Windows."); | |
| 235 | } | |
| 236 | return { | |
| 237 | spawn(input: PtySpawnInput): PtyProcess { | |
| 238 | let processHandle: BunPtyProcess | null = null; | |
| 239 | const command = [input.shell, ...(input.args ?? [])]; | |
| 240 | const subprocess = Bun.spawn(command, { | |
| 241 | cwd: input.cwd, | |
| 242 | env: input.env, | |
| 243 | terminal: { | |
| 244 | cols: input.cols, | |
| 245 | rows: input.rows, | |
| 246 | data: (_terminal, data) => { | |
| 247 | processHandle?.emitData(data); | |
| 248 | }, | |
| 249 | }, | |
| 250 | }); | |
| 251 | processHandle = new BunPtyProcess(subprocess); | |
| 252 | return processHandle; | |
| 253 | }, | |
| 254 | }; | |
| 255 | }), | |
| 256 | ); |
apps/server/src/serverLayers.ts+27-27| ... | ... | @@ -7,8 +7,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 7 | 7 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; |
| 8 | 8 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; |
| 9 | 9 | import { ServerConfig } from "./config"; |
| 10 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; | |
| 11 | import { CheckpointStore } from "./checkpointing/Services/CheckpointStore"; | |
| 12 | 10 | import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; |
| 13 | 11 | import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; |
| 14 | 12 | import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; |
| ... | ... | @@ -19,18 +17,21 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma |
| 19 | 17 | import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; |
| 20 | 18 | import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; |
| 21 | 19 | import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; |
| 22 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; | |
| 23 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; | |
| 24 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; | |
| 25 | 20 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; |
| 26 | 21 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; |
| 27 | 22 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; |
| 28 | 23 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; |
| 29 | 24 | import { ProviderService } from "./provider/Services/ProviderService"; |
| 30 | 25 | |
| 31 | import { makeTerminalManagerLive, TerminalManager } from "./terminalManager"; | |
| 32 | import { PtyAdapter } from "./ptyAdapter"; | |
| 33 | import { Keybindings, KeybindingsLive } from "./keybindings"; | |
| 26 | import { TerminalManagerLive } from "./terminal/Layers/Manager"; | |
| 27 | import { KeybindingsLive } from "./keybindings"; | |
| 28 | import { GitManagerLive } from "./git/Layers/GitManager"; | |
| 29 | import { GitCoreLive } from "./git/Layers/GitCore"; | |
| 30 | import { GitHubCliLive } from "./git/Layers/GitHubCli"; | |
| 31 | import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration"; | |
| 32 | import { GitServiceLive } from "./git/Layers/GitService"; | |
| 33 | import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY"; | |
| 34 | import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY"; | |
| 34 | 35 | |
| 35 | 36 | export function makeServerProviderLayer(): Layer.Layer< |
| 36 | 37 | ProviderService, |
| ... | ... | @@ -53,17 +54,7 @@ export function makeServerProviderLayer(): Layer.Layer< |
| 53 | 54 | }).pipe(Layer.unwrap); |
| 54 | 55 | } |
| 55 | 56 | |
| 56 | export function makeServerRuntimeServicesLayer(): Layer.Layer< | |
| 57 | | OrchestrationEngineService | |
| 58 | | ProjectionSnapshotQuery | |
| 59 | | CheckpointStore | |
| 60 | | CheckpointDiffQuery | |
| 61 | | OrchestrationReactor | |
| 62 | | TerminalManager | |
| 63 | | Keybindings, | |
| 64 | unknown, | |
| 65 | SqlClient.SqlClient | ProviderService | ServerConfig | |
| 66 | > { | |
| 57 | export function makeServerRuntimeServicesLayer() { | |
| 67 | 58 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 68 | 59 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 69 | 60 | Layer.provide(OrchestrationEventStoreLive), |
| ... | ... | @@ -96,14 +87,23 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer< |
| 96 | 87 | Layer.provideMerge(checkpointReactorLayer), |
| 97 | 88 | ); |
| 98 | 89 | |
| 99 | const terminalLayer = Effect.gen(function* () { | |
| 100 | const { stateDir } = yield* ServerConfig; | |
| 101 | return makeTerminalManagerLive({ | |
| 102 | logsDir: path.join(stateDir, "logs", "terminals"), | |
| 103 | }).pipe(Layer.provide(PtyAdapter.layer())); | |
| 104 | }).pipe(Layer.unwrap); | |
| 90 | const terminalLayer = TerminalManagerLive.pipe( | |
| 91 | Layer.provide(typeof Bun !== "undefined" ? BunPtyAdapterLive : NodePtyAdapterLive), | |
| 92 | ); | |
| 93 | ||
| 94 | const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive)); | |
| 105 | 95 | |
| 106 | const keybindingsLayer = KeybindingsLive.pipe(Layer.provide(NodeServices.layer)); | |
| 96 | const gitManagerLayer = GitManagerLive.pipe( | |
| 97 | Layer.provideMerge(gitCoreLayer), | |
| 98 | Layer.provideMerge(GitHubCliLive), | |
| 99 | Layer.provideMerge(CodexTextGenerationLive), | |
| 100 | ); | |
| 107 | 101 | |
| 108 | return Layer.mergeAll(orchestrationReactorLayer, terminalLayer, keybindingsLayer); | |
| 102 | return Layer.mergeAll( | |
| 103 | orchestrationReactorLayer, | |
| 104 | gitCoreLayer, | |
| 105 | gitManagerLayer, | |
| 106 | terminalLayer, | |
| 107 | KeybindingsLive, | |
| 108 | ).pipe(Layer.provideMerge(NodeServices.layer)); | |
| 109 | 109 | } |
apps/server/src/terminal/Layers/BunPTY.ts created+115| ... | ... | @@ -0,0 +1,115 @@ |
| 1 | import { Effect, Layer } from "effect"; | |
| 2 | import { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY"; | |
| 3 | ||
| 4 | class BunPtyProcess implements PtyProcess { | |
| 5 | private readonly dataListeners = new Set<(data: string) => void>(); | |
| 6 | private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); | |
| 7 | private readonly decoder = new TextDecoder(); | |
| 8 | private didExit = false; | |
| 9 | ||
| 10 | constructor(private readonly process: Bun.Subprocess) { | |
| 11 | void this.process.exited | |
| 12 | .then((exitCode) => { | |
| 13 | this.emitExit({ | |
| 14 | exitCode: Number.isInteger(exitCode) ? exitCode : 0, | |
| 15 | signal: typeof this.process.signalCode === "number" ? this.process.signalCode : null, | |
| 16 | }); | |
| 17 | }) | |
| 18 | .catch(() => { | |
| 19 | this.emitExit({ exitCode: 1, signal: null }); | |
| 20 | }); | |
| 21 | } | |
| 22 | ||
| 23 | get pid(): number { | |
| 24 | return this.process.pid; | |
| 25 | } | |
| 26 | ||
| 27 | write(data: string): void { | |
| 28 | if (!this.process.terminal) { | |
| 29 | throw new Error("Bun PTY terminal handle is unavailable"); | |
| 30 | } | |
| 31 | this.process.terminal.write(data); | |
| 32 | } | |
| 33 | ||
| 34 | resize(cols: number, rows: number): void { | |
| 35 | if (!this.process.terminal?.resize) { | |
| 36 | throw new Error("Bun PTY resize is unavailable"); | |
| 37 | } | |
| 38 | this.process.terminal.resize(cols, rows); | |
| 39 | } | |
| 40 | ||
| 41 | kill(signal?: string): void { | |
| 42 | if (!signal) { | |
| 43 | this.process.kill(); | |
| 44 | return; | |
| 45 | } | |
| 46 | this.process.kill(signal as NodeJS.Signals); | |
| 47 | } | |
| 48 | ||
| 49 | onData(callback: (data: string) => void): () => void { | |
| 50 | this.dataListeners.add(callback); | |
| 51 | return () => { | |
| 52 | this.dataListeners.delete(callback); | |
| 53 | }; | |
| 54 | } | |
| 55 | ||
| 56 | onExit(callback: (event: PtyExitEvent) => void): () => void { | |
| 57 | this.exitListeners.add(callback); | |
| 58 | return () => { | |
| 59 | this.exitListeners.delete(callback); | |
| 60 | }; | |
| 61 | } | |
| 62 | ||
| 63 | emitData(data: Uint8Array): void { | |
| 64 | if (this.didExit) return; | |
| 65 | const text = this.decoder.decode(data, { stream: true }); | |
| 66 | if (text.length === 0) return; | |
| 67 | for (const listener of this.dataListeners) { | |
| 68 | listener(text); | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | private emitExit(event: PtyExitEvent): void { | |
| 73 | if (this.didExit) return; | |
| 74 | this.didExit = true; | |
| 75 | ||
| 76 | const remainder = this.decoder.decode(); | |
| 77 | if (remainder.length > 0) { | |
| 78 | for (const listener of this.dataListeners) { | |
| 79 | listener(remainder); | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | for (const listener of this.exitListeners) { | |
| 84 | listener(event); | |
| 85 | } | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | export const BunPtyAdapterLive = Layer.effect( | |
| 90 | PtyAdapter, | |
| 91 | Effect.gen(function* () { | |
| 92 | if (process.platform === "win32") { | |
| 93 | return yield* Effect.die("Bun PTY terminal support is unavailable on Windows."); | |
| 94 | } | |
| 95 | return { | |
| 96 | spawn(input: PtySpawnInput): PtyProcess { | |
| 97 | let processHandle: BunPtyProcess | null = null; | |
| 98 | const command = [input.shell, ...(input.args ?? [])]; | |
| 99 | const subprocess = Bun.spawn(command, { | |
| 100 | cwd: input.cwd, | |
| 101 | env: input.env, | |
| 102 | terminal: { | |
| 103 | cols: input.cols, | |
| 104 | rows: input.rows, | |
| 105 | data: (_terminal, data) => { | |
| 106 | processHandle?.emitData(data); | |
| 107 | }, | |
| 108 | }, | |
| 109 | }); | |
| 110 | processHandle = new BunPtyProcess(subprocess); | |
| 111 | return processHandle; | |
| 112 | }, | |
| 113 | }; | |
| 114 | }), | |
| 115 | ); |
apps/server/src/terminal/Layers/Manager.test.ts created+567| ... | ... | @@ -0,0 +1,567 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { | |
| 6 | DEFAULT_TERMINAL_ID, | |
| 7 | type TerminalEvent, | |
| 8 | type TerminalOpenInput, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { afterEach, describe, expect, it } from "vitest"; | |
| 11 | ||
| 12 | import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY"; | |
| 13 | import { TerminalManagerRuntime } from "./Manager"; | |
| 14 | import { Encoding } from "effect"; | |
| 15 | ||
| 16 | class FakePtyProcess implements PtyProcess { | |
| 17 | readonly writes: string[] = []; | |
| 18 | readonly resizeCalls: Array<{ cols: number; rows: number }> = []; | |
| 19 | private readonly dataListeners = new Set<(data: string) => void>(); | |
| 20 | private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); | |
| 21 | killed = false; | |
| 22 | ||
| 23 | constructor(readonly pid: number) {} | |
| 24 | ||
| 25 | write(data: string): void { | |
| 26 | this.writes.push(data); | |
| 27 | } | |
| 28 | ||
| 29 | resize(cols: number, rows: number): void { | |
| 30 | this.resizeCalls.push({ cols, rows }); | |
| 31 | } | |
| 32 | ||
| 33 | kill(): void { | |
| 34 | this.killed = true; | |
| 35 | } | |
| 36 | ||
| 37 | onData(callback: (data: string) => void): () => void { | |
| 38 | this.dataListeners.add(callback); | |
| 39 | return () => { | |
| 40 | this.dataListeners.delete(callback); | |
| 41 | }; | |
| 42 | } | |
| 43 | ||
| 44 | onExit(callback: (event: PtyExitEvent) => void): () => void { | |
| 45 | this.exitListeners.add(callback); | |
| 46 | return () => { | |
| 47 | this.exitListeners.delete(callback); | |
| 48 | }; | |
| 49 | } | |
| 50 | ||
| 51 | emitData(data: string): void { | |
| 52 | for (const listener of this.dataListeners) { | |
| 53 | listener(data); | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | emitExit(event: PtyExitEvent): void { | |
| 58 | for (const listener of this.exitListeners) { | |
| 59 | listener(event); | |
| 60 | } | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | class FakePtyAdapter implements PtyAdapterShape { | |
| 65 | readonly spawnInputs: PtySpawnInput[] = []; | |
| 66 | readonly processes: FakePtyProcess[] = []; | |
| 67 | readonly spawnFailures: Error[] = []; | |
| 68 | private nextPid = 9000; | |
| 69 | ||
| 70 | spawn(input: PtySpawnInput): PtyProcess { | |
| 71 | this.spawnInputs.push(input); | |
| 72 | const failure = this.spawnFailures.shift(); | |
| 73 | if (failure) { | |
| 74 | throw failure; | |
| 75 | } | |
| 76 | const process = new FakePtyProcess(this.nextPid++); | |
| 77 | this.processes.push(process); | |
| 78 | return process; | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | function waitFor(predicate: () => boolean, timeoutMs = 800): Promise<void> { | |
| 83 | const started = Date.now(); | |
| 84 | return new Promise((resolve, reject) => { | |
| 85 | const poll = () => { | |
| 86 | if (predicate()) { | |
| 87 | resolve(); | |
| 88 | return; | |
| 89 | } | |
| 90 | if (Date.now() - started > timeoutMs) { | |
| 91 | reject(new Error("Timed out waiting for condition")); | |
| 92 | return; | |
| 93 | } | |
| 94 | setTimeout(poll, 15); | |
| 95 | }; | |
| 96 | poll(); | |
| 97 | }); | |
| 98 | } | |
| 99 | ||
| 100 | function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput { | |
| 101 | return { | |
| 102 | threadId: "thread-1", | |
| 103 | cwd: process.cwd(), | |
| 104 | cols: 100, | |
| 105 | rows: 24, | |
| 106 | ...overrides, | |
| 107 | }; | |
| 108 | } | |
| 109 | ||
| 110 | function historyLogName(threadId: string): string { | |
| 111 | return `terminal_${Encoding.encodeBase64Url(threadId)}.log`; | |
| 112 | } | |
| 113 | ||
| 114 | function multiTerminalHistoryLogName(threadId: string, terminalId: string): string { | |
| 115 | const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; | |
| 116 | if (terminalId === DEFAULT_TERMINAL_ID) { | |
| 117 | return `${threadPart}.log`; | |
| 118 | } | |
| 119 | return `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`; | |
| 120 | } | |
| 121 | ||
| 122 | function historyLogPath(logsDir: string, threadId = "thread-1"): string { | |
| 123 | return path.join(logsDir, historyLogName(threadId)); | |
| 124 | } | |
| 125 | ||
| 126 | function multiTerminalHistoryLogPath( | |
| 127 | logsDir: string, | |
| 128 | threadId = "thread-1", | |
| 129 | terminalId = "default", | |
| 130 | ): string { | |
| 131 | return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId)); | |
| 132 | } | |
| 133 | ||
| 134 | describe("TerminalManager", () => { | |
| 135 | const tempDirs: string[] = []; | |
| 136 | ||
| 137 | afterEach(() => { | |
| 138 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 139 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 140 | } | |
| 141 | }); | |
| 142 | ||
| 143 | function makeManager( | |
| 144 | historyLineLimit = 5, | |
| 145 | options: { | |
| 146 | shellResolver?: () => string; | |
| 147 | subprocessChecker?: (terminalPid: number) => Promise<boolean>; | |
| 148 | subprocessPollIntervalMs?: number; | |
| 149 | } = {}, | |
| 150 | ) { | |
| 151 | const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-")); | |
| 152 | tempDirs.push(logsDir); | |
| 153 | const ptyAdapter = new FakePtyAdapter(); | |
| 154 | const manager = new TerminalManagerRuntime({ | |
| 155 | logsDir, | |
| 156 | ptyAdapter, | |
| 157 | historyLineLimit, | |
| 158 | shellResolver: options.shellResolver ?? (() => "/bin/bash"), | |
| 159 | ...(options.subprocessChecker ? { subprocessChecker: options.subprocessChecker } : {}), | |
| 160 | ...(options.subprocessPollIntervalMs | |
| 161 | ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } | |
| 162 | : {}), | |
| 163 | }); | |
| 164 | return { logsDir, ptyAdapter, manager }; | |
| 165 | } | |
| 166 | ||
| 167 | it("spawns lazily and reuses running terminal per thread", async () => { | |
| 168 | const { manager, ptyAdapter } = makeManager(); | |
| 169 | const [first, second] = await Promise.all([ | |
| 170 | manager.open(openInput()), | |
| 171 | manager.open(openInput()), | |
| 172 | ]); | |
| 173 | const third = await manager.open(openInput()); | |
| 174 | ||
| 175 | expect(first.threadId).toBe("thread-1"); | |
| 176 | expect(first.terminalId).toBe("default"); | |
| 177 | expect(second.threadId).toBe("thread-1"); | |
| 178 | expect(third.threadId).toBe("thread-1"); | |
| 179 | expect(ptyAdapter.spawnInputs).toHaveLength(1); | |
| 180 | ||
| 181 | manager.dispose(); | |
| 182 | }); | |
| 183 | ||
| 184 | it("forwards write and resize to active pty process", async () => { | |
| 185 | const { manager, ptyAdapter } = makeManager(); | |
| 186 | await manager.open(openInput()); | |
| 187 | const process = ptyAdapter.processes[0]; | |
| 188 | expect(process).toBeDefined(); | |
| 189 | if (!process) return; | |
| 190 | ||
| 191 | await manager.write({ threadId: "thread-1", data: "ls\n" }); | |
| 192 | await manager.resize({ threadId: "thread-1", cols: 120, rows: 30 }); | |
| 193 | ||
| 194 | expect(process.writes).toEqual(["ls\n"]); | |
| 195 | expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); | |
| 196 | ||
| 197 | manager.dispose(); | |
| 198 | }); | |
| 199 | ||
| 200 | it("resizes running terminal on open when a different size is requested", async () => { | |
| 201 | const { manager, ptyAdapter } = makeManager(); | |
| 202 | await manager.open(openInput({ cols: 100, rows: 24 })); | |
| 203 | const process = ptyAdapter.processes[0]; | |
| 204 | expect(process).toBeDefined(); | |
| 205 | if (!process) return; | |
| 206 | ||
| 207 | await manager.open(openInput({ cols: 140, rows: 40 })); | |
| 208 | ||
| 209 | expect(process.resizeCalls).toEqual([{ cols: 140, rows: 40 }]); | |
| 210 | ||
| 211 | manager.dispose(); | |
| 212 | }); | |
| 213 | ||
| 214 | it("preserves existing terminal size on open when size is omitted", async () => { | |
| 215 | const { manager, ptyAdapter } = makeManager(); | |
| 216 | await manager.open(openInput({ cols: 100, rows: 24 })); | |
| 217 | const ptyProcess = ptyAdapter.processes[0]; | |
| 218 | expect(ptyProcess).toBeDefined(); | |
| 219 | if (!ptyProcess) return; | |
| 220 | ||
| 221 | await manager.open({ | |
| 222 | threadId: "thread-1", | |
| 223 | cwd: globalThis.process.cwd(), | |
| 224 | }); | |
| 225 | ||
| 226 | expect(ptyProcess.resizeCalls).toEqual([]); | |
| 227 | ||
| 228 | ptyProcess.emitExit({ exitCode: 0, signal: 0 }); | |
| 229 | await manager.open({ | |
| 230 | threadId: "thread-1", | |
| 231 | cwd: globalThis.process.cwd(), | |
| 232 | }); | |
| 233 | ||
| 234 | const resumedSpawn = ptyAdapter.spawnInputs[1]; | |
| 235 | expect(resumedSpawn).toBeDefined(); | |
| 236 | if (!resumedSpawn) return; | |
| 237 | expect(resumedSpawn.cols).toBe(100); | |
| 238 | expect(resumedSpawn.rows).toBe(24); | |
| 239 | ||
| 240 | manager.dispose(); | |
| 241 | }); | |
| 242 | ||
| 243 | it("uses default dimensions when opening a new terminal without size hints", async () => { | |
| 244 | const { manager, ptyAdapter } = makeManager(); | |
| 245 | await manager.open({ | |
| 246 | threadId: "thread-1", | |
| 247 | cwd: process.cwd(), | |
| 248 | }); | |
| 249 | ||
| 250 | const spawned = ptyAdapter.spawnInputs[0]; | |
| 251 | expect(spawned).toBeDefined(); | |
| 252 | if (!spawned) return; | |
| 253 | expect(spawned.cols).toBe(120); | |
| 254 | expect(spawned.rows).toBe(30); | |
| 255 | ||
| 256 | manager.dispose(); | |
| 257 | }); | |
| 258 | ||
| 259 | it("supports multiple terminals per thread with isolated sessions", async () => { | |
| 260 | const { manager, ptyAdapter } = makeManager(); | |
| 261 | await manager.open(openInput({ terminalId: "default" })); | |
| 262 | await manager.open(openInput({ terminalId: "term-2" })); | |
| 263 | ||
| 264 | const first = ptyAdapter.processes[0]; | |
| 265 | const second = ptyAdapter.processes[1]; | |
| 266 | expect(first).toBeDefined(); | |
| 267 | expect(second).toBeDefined(); | |
| 268 | if (!first || !second) return; | |
| 269 | ||
| 270 | await manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); | |
| 271 | await manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); | |
| 272 | ||
| 273 | expect(first.writes).toEqual(["pwd\n"]); | |
| 274 | expect(second.writes).toEqual(["ls\n"]); | |
| 275 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | |
| 276 | ||
| 277 | manager.dispose(); | |
| 278 | }); | |
| 279 | ||
| 280 | it("clears transcript and emits cleared event", async () => { | |
| 281 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 282 | const events: TerminalEvent[] = []; | |
| 283 | manager.on("event", (event) => { | |
| 284 | events.push(event); | |
| 285 | }); | |
| 286 | await manager.open(openInput()); | |
| 287 | const process = ptyAdapter.processes[0]; | |
| 288 | expect(process).toBeDefined(); | |
| 289 | if (!process) return; | |
| 290 | ||
| 291 | process.emitData("hello\n"); | |
| 292 | await waitFor(() => fs.existsSync(historyLogPath(logsDir))); | |
| 293 | await manager.clear({ threadId: "thread-1" }); | |
| 294 | await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); | |
| 295 | ||
| 296 | expect(events.some((event) => event.type === "cleared")).toBe(true); | |
| 297 | expect( | |
| 298 | events.some( | |
| 299 | (event) => | |
| 300 | event.type === "cleared" && | |
| 301 | event.threadId === "thread-1" && | |
| 302 | event.terminalId === "default", | |
| 303 | ), | |
| 304 | ).toBe(true); | |
| 305 | ||
| 306 | manager.dispose(); | |
| 307 | }); | |
| 308 | ||
| 309 | it("restarts terminal with empty transcript and respawns pty", async () => { | |
| 310 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 311 | await manager.open(openInput()); | |
| 312 | const firstProcess = ptyAdapter.processes[0]; | |
| 313 | expect(firstProcess).toBeDefined(); | |
| 314 | if (!firstProcess) return; | |
| 315 | firstProcess.emitData("before restart\n"); | |
| 316 | await waitFor(() => fs.existsSync(historyLogPath(logsDir))); | |
| 317 | ||
| 318 | const snapshot = await manager.restart(openInput()); | |
| 319 | expect(snapshot.history).toBe(""); | |
| 320 | expect(snapshot.status).toBe("running"); | |
| 321 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | |
| 322 | await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); | |
| 323 | ||
| 324 | manager.dispose(); | |
| 325 | }); | |
| 326 | ||
| 327 | it("emits exited event and reopens with clean transcript after exit", async () => { | |
| 328 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 329 | const events: TerminalEvent[] = []; | |
| 330 | manager.on("event", (event) => { | |
| 331 | events.push(event); | |
| 332 | }); | |
| 333 | await manager.open(openInput()); | |
| 334 | const process = ptyAdapter.processes[0]; | |
| 335 | expect(process).toBeDefined(); | |
| 336 | if (!process) return; | |
| 337 | process.emitData("old data\n"); | |
| 338 | await waitFor(() => fs.existsSync(historyLogPath(logsDir))); | |
| 339 | process.emitExit({ exitCode: 0, signal: 0 }); | |
| 340 | ||
| 341 | await waitFor(() => events.some((event) => event.type === "exited")); | |
| 342 | const reopened = await manager.open(openInput()); | |
| 343 | ||
| 344 | expect(reopened.history).toBe(""); | |
| 345 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | |
| 346 | expect(fs.readFileSync(historyLogPath(logsDir), "utf8")).toBe(""); | |
| 347 | ||
| 348 | manager.dispose(); | |
| 349 | }); | |
| 350 | ||
| 351 | it("emits subprocess activity events when child-process state changes", async () => { | |
| 352 | let hasRunningSubprocess = false; | |
| 353 | const { manager } = makeManager(5, { | |
| 354 | subprocessChecker: async () => hasRunningSubprocess, | |
| 355 | subprocessPollIntervalMs: 20, | |
| 356 | }); | |
| 357 | const events: TerminalEvent[] = []; | |
| 358 | manager.on("event", (event) => { | |
| 359 | events.push(event); | |
| 360 | }); | |
| 361 | ||
| 362 | await manager.open(openInput()); | |
| 363 | await waitFor(() => events.some((event) => event.type === "started")); | |
| 364 | expect(events.some((event) => event.type === "activity")).toBe(false); | |
| 365 | ||
| 366 | hasRunningSubprocess = true; | |
| 367 | await waitFor( | |
| 368 | () => | |
| 369 | events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true), | |
| 370 | 1_200, | |
| 371 | ); | |
| 372 | ||
| 373 | hasRunningSubprocess = false; | |
| 374 | await waitFor( | |
| 375 | () => | |
| 376 | events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false), | |
| 377 | 1_200, | |
| 378 | ); | |
| 379 | ||
| 380 | manager.dispose(); | |
| 381 | }); | |
| 382 | ||
| 383 | it("caps persisted history to configured line limit", async () => { | |
| 384 | const { manager, ptyAdapter } = makeManager(3); | |
| 385 | await manager.open(openInput()); | |
| 386 | const process = ptyAdapter.processes[0]; | |
| 387 | expect(process).toBeDefined(); | |
| 388 | if (!process) return; | |
| 389 | ||
| 390 | process.emitData("line1\nline2\nline3\nline4\n"); | |
| 391 | await manager.close({ threadId: "thread-1" }); | |
| 392 | ||
| 393 | const reopened = await manager.open(openInput()); | |
| 394 | const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0); | |
| 395 | expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]); | |
| 396 | ||
| 397 | manager.dispose(); | |
| 398 | }); | |
| 399 | ||
| 400 | it("deletes history file when close(deleteHistory=true)", async () => { | |
| 401 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 402 | await manager.open(openInput()); | |
| 403 | const process = ptyAdapter.processes[0]; | |
| 404 | expect(process).toBeDefined(); | |
| 405 | if (!process) return; | |
| 406 | process.emitData("bye\n"); | |
| 407 | await waitFor(() => fs.existsSync(historyLogPath(logsDir))); | |
| 408 | ||
| 409 | await manager.close({ threadId: "thread-1", deleteHistory: true }); | |
| 410 | expect(fs.existsSync(historyLogPath(logsDir))).toBe(false); | |
| 411 | ||
| 412 | manager.dispose(); | |
| 413 | }); | |
| 414 | ||
| 415 | it("closes all terminals for a thread when close omits terminalId", async () => { | |
| 416 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 417 | await manager.open(openInput({ terminalId: "default" })); | |
| 418 | await manager.open(openInput({ terminalId: "sidecar" })); | |
| 419 | const defaultProcess = ptyAdapter.processes[0]; | |
| 420 | const sidecarProcess = ptyAdapter.processes[1]; | |
| 421 | expect(defaultProcess).toBeDefined(); | |
| 422 | expect(sidecarProcess).toBeDefined(); | |
| 423 | if (!defaultProcess || !sidecarProcess) return; | |
| 424 | ||
| 425 | defaultProcess.emitData("default\n"); | |
| 426 | sidecarProcess.emitData("sidecar\n"); | |
| 427 | await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))); | |
| 428 | await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))); | |
| 429 | ||
| 430 | await manager.close({ threadId: "thread-1", deleteHistory: true }); | |
| 431 | ||
| 432 | expect(defaultProcess.killed).toBe(true); | |
| 433 | expect(sidecarProcess.killed).toBe(true); | |
| 434 | expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))).toBe(false); | |
| 435 | expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))).toBe(false); | |
| 436 | ||
| 437 | manager.dispose(); | |
| 438 | }); | |
| 439 | ||
| 440 | it("migrates legacy transcript filenames to terminal-scoped history path on open", async () => { | |
| 441 | const { manager, logsDir } = makeManager(); | |
| 442 | const legacyPath = path.join(logsDir, "thread-1.log"); | |
| 443 | const nextPath = historyLogPath(logsDir); | |
| 444 | fs.writeFileSync(legacyPath, "legacy-line\n", "utf8"); | |
| 445 | ||
| 446 | const snapshot = await manager.open(openInput()); | |
| 447 | ||
| 448 | expect(snapshot.history).toBe("legacy-line\n"); | |
| 449 | expect(fs.existsSync(nextPath)).toBe(true); | |
| 450 | expect(fs.readFileSync(nextPath, "utf8")).toBe("legacy-line\n"); | |
| 451 | expect(fs.existsSync(legacyPath)).toBe(false); | |
| 452 | ||
| 453 | manager.dispose(); | |
| 454 | }); | |
| 455 | ||
| 456 | it("retries with fallback shells when preferred shell spawn fails", async () => { | |
| 457 | const { manager, ptyAdapter } = makeManager(5, { | |
| 458 | shellResolver: () => "/definitely/missing-shell -l", | |
| 459 | }); | |
| 460 | ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed.")); | |
| 461 | ||
| 462 | const snapshot = await manager.open(openInput()); | |
| 463 | ||
| 464 | expect(snapshot.status).toBe("running"); | |
| 465 | expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); | |
| 466 | expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell"); | |
| 467 | ||
| 468 | if (process.platform === "win32") { | |
| 469 | expect( | |
| 470 | ptyAdapter.spawnInputs.some( | |
| 471 | (input) => input.shell === "cmd.exe" || input.shell === "powershell.exe", | |
| 472 | ), | |
| 473 | ).toBe(true); | |
| 474 | } else { | |
| 475 | expect( | |
| 476 | ptyAdapter.spawnInputs.some((input) => | |
| 477 | ["/bin/zsh", "/bin/bash", "/bin/sh", "zsh", "bash", "sh"].includes(input.shell), | |
| 478 | ), | |
| 479 | ).toBe(true); | |
| 480 | } | |
| 481 | ||
| 482 | manager.dispose(); | |
| 483 | }); | |
| 484 | ||
| 485 | it("filters app runtime env variables from terminal sessions", async () => { | |
| 486 | const originalValues = new Map<string, string | undefined>(); | |
| 487 | const setEnv = (key: string, value: string | undefined) => { | |
| 488 | if (!originalValues.has(key)) { | |
| 489 | originalValues.set(key, process.env[key]); | |
| 490 | } | |
| 491 | if (value === undefined) { | |
| 492 | delete process.env[key]; | |
| 493 | return; | |
| 494 | } | |
| 495 | process.env[key] = value; | |
| 496 | }; | |
| 497 | const restoreEnv = () => { | |
| 498 | for (const [key, value] of originalValues) { | |
| 499 | if (value === undefined) { | |
| 500 | delete process.env[key]; | |
| 501 | } else { | |
| 502 | process.env[key] = value; | |
| 503 | } | |
| 504 | } | |
| 505 | }; | |
| 506 | ||
| 507 | setEnv("PORT", "5173"); | |
| 508 | setEnv("T3CODE_PORT", "3773"); | |
| 509 | setEnv("VITE_DEV_SERVER_URL", "http://localhost:5173"); | |
| 510 | setEnv("TEST_TERMINAL_KEEP", "keep-me"); | |
| 511 | ||
| 512 | try { | |
| 513 | const { manager, ptyAdapter } = makeManager(); | |
| 514 | await manager.open(openInput()); | |
| 515 | const spawnInput = ptyAdapter.spawnInputs[0]; | |
| 516 | expect(spawnInput).toBeDefined(); | |
| 517 | if (!spawnInput) return; | |
| 518 | ||
| 519 | expect(spawnInput.env.PORT).toBeUndefined(); | |
| 520 | expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); | |
| 521 | expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); | |
| 522 | expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); | |
| 523 | ||
| 524 | manager.dispose(); | |
| 525 | } finally { | |
| 526 | restoreEnv(); | |
| 527 | } | |
| 528 | }); | |
| 529 | ||
| 530 | it("injects runtime env overrides into spawned terminals", async () => { | |
| 531 | const { manager, ptyAdapter } = makeManager(); | |
| 532 | await manager.open( | |
| 533 | openInput({ | |
| 534 | env: { | |
| 535 | T3CODE_PROJECT_ROOT: "/repo", | |
| 536 | T3CODE_WORKTREE_PATH: "/repo/worktree-a", | |
| 537 | CUSTOM_FLAG: "1", | |
| 538 | }, | |
| 539 | }), | |
| 540 | ); | |
| 541 | const spawnInput = ptyAdapter.spawnInputs[0]; | |
| 542 | expect(spawnInput).toBeDefined(); | |
| 543 | if (!spawnInput) return; | |
| 544 | ||
| 545 | expect(spawnInput.env.T3CODE_PROJECT_ROOT).toBe("/repo"); | |
| 546 | expect(spawnInput.env.T3CODE_WORKTREE_PATH).toBe("/repo/worktree-a"); | |
| 547 | expect(spawnInput.env.CUSTOM_FLAG).toBe("1"); | |
| 548 | ||
| 549 | manager.dispose(); | |
| 550 | }); | |
| 551 | ||
| 552 | it("starts zsh with prompt spacer disabled to avoid `%` end markers", async () => { | |
| 553 | if (process.platform === "win32") return; | |
| 554 | const { manager, ptyAdapter } = makeManager(5, { | |
| 555 | shellResolver: () => "/bin/zsh", | |
| 556 | }); | |
| 557 | await manager.open(openInput()); | |
| 558 | const spawnInput = ptyAdapter.spawnInputs[0]; | |
| 559 | expect(spawnInput).toBeDefined(); | |
| 560 | if (!spawnInput) return; | |
| 561 | ||
| 562 | expect(spawnInput.shell).toBe("/bin/zsh"); | |
| 563 | expect(spawnInput.args).toEqual(["-o", "nopromptsp"]); | |
| 564 | ||
| 565 | manager.dispose(); | |
| 566 | }); | |
| 567 | }); |
apps/server/src/terminal/Layers/Manager.ts created+1047| ... | ... | @@ -0,0 +1,1047 @@ |
| 1 | import { EventEmitter } from "node:events"; | |
| 2 | import fs from "node:fs"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { | |
| 6 | DEFAULT_TERMINAL_ID, | |
| 7 | TerminalClearInput, | |
| 8 | TerminalCloseInput, | |
| 9 | TerminalOpenInput, | |
| 10 | TerminalResizeInput, | |
| 11 | TerminalWriteInput, | |
| 12 | type TerminalEvent, | |
| 13 | type TerminalSessionSnapshot, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { Effect, Encoding, Layer, Path, Schema } from "effect"; | |
| 16 | ||
| 17 | import { createLogger } from "../../logger"; | |
| 18 | import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "../Services/PTY"; | |
| 19 | import { runProcess } from "../../processRunner"; | |
| 20 | import { ServerConfig } from "../../config"; | |
| 21 | import { | |
| 22 | ShellCandidate, | |
| 23 | TerminalManager, | |
| 24 | TerminalManagerEvents, | |
| 25 | TerminalManagerOptions, | |
| 26 | TerminalManagerShape, | |
| 27 | TerminalSessionState, | |
| 28 | TerminalStartInput, | |
| 29 | } from "../Services/Manager"; | |
| 30 | ||
| 31 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; | |
| 32 | const DEFAULT_PERSIST_DEBOUNCE_MS = 40; | |
| 33 | const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; | |
| 34 | const DEFAULT_OPEN_COLS = 120; | |
| 35 | const DEFAULT_OPEN_ROWS = 30; | |
| 36 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); | |
| 37 | ||
| 38 | const decodeTerminalOpenInput = Schema.decodeUnknownSync(TerminalOpenInput); | |
| 39 | const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput); | |
| 40 | const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput); | |
| 41 | const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput); | |
| 42 | const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput); | |
| 43 | ||
| 44 | type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>; | |
| 45 | ||
| 46 | function defaultShellResolver(): string { | |
| 47 | if (process.platform === "win32") { | |
| 48 | return process.env.ComSpec ?? "cmd.exe"; | |
| 49 | } | |
| 50 | return process.env.SHELL ?? "bash"; | |
| 51 | } | |
| 52 | ||
| 53 | function normalizeShellCommand(value: string | undefined): string | null { | |
| 54 | if (!value) return null; | |
| 55 | const trimmed = value.trim(); | |
| 56 | if (trimmed.length === 0) return null; | |
| 57 | ||
| 58 | if (process.platform === "win32") { | |
| 59 | return trimmed; | |
| 60 | } | |
| 61 | ||
| 62 | const firstToken = trimmed.split(/\s+/g)[0]?.trim(); | |
| 63 | if (!firstToken) return null; | |
| 64 | return firstToken.replace(/^['"]|['"]$/g, ""); | |
| 65 | } | |
| 66 | ||
| 67 | function shellCandidateFromCommand(command: string | null): ShellCandidate | null { | |
| 68 | if (!command || command.length === 0) return null; | |
| 69 | const shellName = path.basename(command).toLowerCase(); | |
| 70 | if (process.platform !== "win32" && shellName === "zsh") { | |
| 71 | return { shell: command, args: ["-o", "nopromptsp"] }; | |
| 72 | } | |
| 73 | return { shell: command }; | |
| 74 | } | |
| 75 | ||
| 76 | function formatShellCandidate(candidate: ShellCandidate): string { | |
| 77 | if (!candidate.args || candidate.args.length === 0) return candidate.shell; | |
| 78 | return `${candidate.shell} ${candidate.args.join(" ")}`; | |
| 79 | } | |
| 80 | ||
| 81 | function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellCandidate[] { | |
| 82 | const seen = new Set<string>(); | |
| 83 | const ordered: ShellCandidate[] = []; | |
| 84 | for (const candidate of candidates) { | |
| 85 | if (!candidate) continue; | |
| 86 | const key = formatShellCandidate(candidate); | |
| 87 | if (seen.has(key)) continue; | |
| 88 | seen.add(key); | |
| 89 | ordered.push(candidate); | |
| 90 | } | |
| 91 | return ordered; | |
| 92 | } | |
| 93 | ||
| 94 | function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] { | |
| 95 | const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver())); | |
| 96 | ||
| 97 | if (process.platform === "win32") { | |
| 98 | return uniqueShellCandidates([ | |
| 99 | requested, | |
| 100 | shellCandidateFromCommand(process.env.ComSpec ?? null), | |
| 101 | shellCandidateFromCommand("powershell.exe"), | |
| 102 | shellCandidateFromCommand("cmd.exe"), | |
| 103 | ]); | |
| 104 | } | |
| 105 | ||
| 106 | return uniqueShellCandidates([ | |
| 107 | requested, | |
| 108 | shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)), | |
| 109 | shellCandidateFromCommand("/bin/zsh"), | |
| 110 | shellCandidateFromCommand("/bin/bash"), | |
| 111 | shellCandidateFromCommand("/bin/sh"), | |
| 112 | shellCandidateFromCommand("zsh"), | |
| 113 | shellCandidateFromCommand("bash"), | |
| 114 | shellCandidateFromCommand("sh"), | |
| 115 | ]); | |
| 116 | } | |
| 117 | ||
| 118 | function isRetryableShellSpawnError(error: unknown): boolean { | |
| 119 | if (!(error instanceof Error)) return false; | |
| 120 | const message = error.message.toLowerCase(); | |
| 121 | return ( | |
| 122 | message.includes("posix_spawnp failed") || | |
| 123 | message.includes("enoent") || | |
| 124 | message.includes("not found") || | |
| 125 | message.includes("file not found") || | |
| 126 | message.includes("no such file") | |
| 127 | ); | |
| 128 | } | |
| 129 | ||
| 130 | async function checkWindowsSubprocessActivity(terminalPid: number): Promise<boolean> { | |
| 131 | const command = [ | |
| 132 | `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, | |
| 133 | "if ($children) { exit 0 }", | |
| 134 | "exit 1", | |
| 135 | ].join("; "); | |
| 136 | try { | |
| 137 | const result = await runProcess( | |
| 138 | "powershell.exe", | |
| 139 | ["-NoProfile", "-NonInteractive", "-Command", command], | |
| 140 | { | |
| 141 | timeoutMs: 1_500, | |
| 142 | allowNonZeroExit: true, | |
| 143 | maxBufferBytes: 32_768, | |
| 144 | outputMode: "truncate", | |
| 145 | }, | |
| 146 | ); | |
| 147 | return result.code === 0; | |
| 148 | } catch { | |
| 149 | return false; | |
| 150 | } | |
| 151 | } | |
| 152 | ||
| 153 | async function checkPosixSubprocessActivity(terminalPid: number): Promise<boolean> { | |
| 154 | try { | |
| 155 | const pgrepResult = await runProcess("pgrep", ["-P", String(terminalPid)], { | |
| 156 | timeoutMs: 1_000, | |
| 157 | allowNonZeroExit: true, | |
| 158 | maxBufferBytes: 32_768, | |
| 159 | outputMode: "truncate", | |
| 160 | }); | |
| 161 | if (pgrepResult.code === 0) { | |
| 162 | return pgrepResult.stdout.trim().length > 0; | |
| 163 | } | |
| 164 | if (pgrepResult.code === 1) { | |
| 165 | return false; | |
| 166 | } | |
| 167 | } catch { | |
| 168 | // Fall back to ps when pgrep is unavailable. | |
| 169 | } | |
| 170 | ||
| 171 | try { | |
| 172 | const psResult = await runProcess("ps", ["-eo", "pid=,ppid="], { | |
| 173 | timeoutMs: 1_000, | |
| 174 | allowNonZeroExit: true, | |
| 175 | maxBufferBytes: 262_144, | |
| 176 | outputMode: "truncate", | |
| 177 | }); | |
| 178 | if (psResult.code !== 0) { | |
| 179 | return false; | |
| 180 | } | |
| 181 | ||
| 182 | for (const line of psResult.stdout.split(/\r?\n/g)) { | |
| 183 | const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); | |
| 184 | const pid = Number(pidRaw); | |
| 185 | const ppid = Number(ppidRaw); | |
| 186 | if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; | |
| 187 | if (ppid === terminalPid) { | |
| 188 | return true; | |
| 189 | } | |
| 190 | } | |
| 191 | return false; | |
| 192 | } catch { | |
| 193 | return false; | |
| 194 | } | |
| 195 | } | |
| 196 | ||
| 197 | async function defaultSubprocessChecker(terminalPid: number): Promise<boolean> { | |
| 198 | if (!Number.isInteger(terminalPid) || terminalPid <= 0) { | |
| 199 | return false; | |
| 200 | } | |
| 201 | if (process.platform === "win32") { | |
| 202 | return checkWindowsSubprocessActivity(terminalPid); | |
| 203 | } | |
| 204 | return checkPosixSubprocessActivity(terminalPid); | |
| 205 | } | |
| 206 | ||
| 207 | function capHistory(history: string, maxLines: number): string { | |
| 208 | if (history.length === 0) return history; | |
| 209 | const hasTrailingNewline = history.endsWith("\n"); | |
| 210 | const lines = history.split("\n"); | |
| 211 | if (hasTrailingNewline) { | |
| 212 | lines.pop(); | |
| 213 | } | |
| 214 | if (lines.length <= maxLines) return history; | |
| 215 | const capped = lines.slice(lines.length - maxLines).join("\n"); | |
| 216 | return hasTrailingNewline ? `${capped}\n` : capped; | |
| 217 | } | |
| 218 | ||
| 219 | function legacySafeThreadId(threadId: string): string { | |
| 220 | return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); | |
| 221 | } | |
| 222 | ||
| 223 | function toSafeThreadId(threadId: string): string { | |
| 224 | return `terminal_${Encoding.encodeBase64Url(threadId)}`; | |
| 225 | } | |
| 226 | ||
| 227 | function toSafeTerminalId(terminalId: string): string { | |
| 228 | return Encoding.encodeBase64Url(terminalId); | |
| 229 | } | |
| 230 | ||
| 231 | function toSessionKey(threadId: string, terminalId: string): string { | |
| 232 | return `${threadId}\u0000${terminalId}`; | |
| 233 | } | |
| 234 | ||
| 235 | function shouldExcludeTerminalEnvKey(key: string): boolean { | |
| 236 | const normalizedKey = key.toUpperCase(); | |
| 237 | if (normalizedKey.startsWith("T3CODE_")) { | |
| 238 | return true; | |
| 239 | } | |
| 240 | if (normalizedKey.startsWith("VITE_")) { | |
| 241 | return true; | |
| 242 | } | |
| 243 | return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); | |
| 244 | } | |
| 245 | ||
| 246 | function createTerminalSpawnEnv( | |
| 247 | baseEnv: NodeJS.ProcessEnv, | |
| 248 | runtimeEnv?: Record<string, string> | null, | |
| 249 | ): NodeJS.ProcessEnv { | |
| 250 | const spawnEnv: NodeJS.ProcessEnv = {}; | |
| 251 | for (const [key, value] of Object.entries(baseEnv)) { | |
| 252 | if (value === undefined) continue; | |
| 253 | if (shouldExcludeTerminalEnvKey(key)) continue; | |
| 254 | spawnEnv[key] = value; | |
| 255 | } | |
| 256 | if (runtimeEnv) { | |
| 257 | for (const [key, value] of Object.entries(runtimeEnv)) { | |
| 258 | spawnEnv[key] = value; | |
| 259 | } | |
| 260 | } | |
| 261 | return spawnEnv; | |
| 262 | } | |
| 263 | ||
| 264 | function normalizedRuntimeEnv( | |
| 265 | env: Record<string, string> | undefined, | |
| 266 | ): Record<string, string> | null { | |
| 267 | if (!env) return null; | |
| 268 | const entries = Object.entries(env); | |
| 269 | if (entries.length === 0) return null; | |
| 270 | return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right))); | |
| 271 | } | |
| 272 | ||
| 273 | export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> { | |
| 274 | private readonly sessions = new Map<string, TerminalSessionState>(); | |
| 275 | private readonly logsDir: string; | |
| 276 | private readonly historyLineLimit: number; | |
| 277 | private readonly ptyAdapter: PtyAdapterShape; | |
| 278 | private readonly shellResolver: () => string; | |
| 279 | private readonly persistQueues = new Map<string, Promise<void>>(); | |
| 280 | private readonly persistTimers = new Map<string, ReturnType<typeof setTimeout>>(); | |
| 281 | private readonly pendingPersistHistory = new Map<string, string>(); | |
| 282 | private readonly threadLocks = new Map<string, Promise<void>>(); | |
| 283 | private readonly persistDebounceMs: number; | |
| 284 | private readonly subprocessChecker: TerminalSubprocessChecker; | |
| 285 | private readonly subprocessPollIntervalMs: number; | |
| 286 | private subprocessPollTimer: ReturnType<typeof setInterval> | null = null; | |
| 287 | private subprocessPollInFlight = false; | |
| 288 | private readonly logger = createLogger("terminal"); | |
| 289 | ||
| 290 | constructor(options: TerminalManagerOptions) { | |
| 291 | super(); | |
| 292 | this.logsDir = options.logsDir ?? path.resolve(process.cwd(), ".logs", "terminals"); | |
| 293 | this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; | |
| 294 | this.ptyAdapter = options.ptyAdapter; | |
| 295 | this.shellResolver = options.shellResolver ?? defaultShellResolver; | |
| 296 | this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; | |
| 297 | this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; | |
| 298 | this.subprocessPollIntervalMs = | |
| 299 | options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; | |
| 300 | fs.mkdirSync(this.logsDir, { recursive: true }); | |
| 301 | } | |
| 302 | ||
| 303 | async open(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> { | |
| 304 | const input = decodeTerminalOpenInput(raw); | |
| 305 | return this.runWithThreadLock(input.threadId, async () => { | |
| 306 | await this.assertValidCwd(input.cwd); | |
| 307 | ||
| 308 | const sessionKey = toSessionKey(input.threadId, input.terminalId); | |
| 309 | const existing = this.sessions.get(sessionKey); | |
| 310 | if (!existing) { | |
| 311 | await this.flushPersistQueue(input.threadId, input.terminalId); | |
| 312 | const history = await this.readHistory(input.threadId, input.terminalId); | |
| 313 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | |
| 314 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | |
| 315 | const session: TerminalSessionState = { | |
| 316 | threadId: input.threadId, | |
| 317 | terminalId: input.terminalId, | |
| 318 | cwd: input.cwd, | |
| 319 | status: "starting", | |
| 320 | pid: null, | |
| 321 | history, | |
| 322 | exitCode: null, | |
| 323 | exitSignal: null, | |
| 324 | updatedAt: new Date().toISOString(), | |
| 325 | cols, | |
| 326 | rows, | |
| 327 | process: null, | |
| 328 | unsubscribeData: null, | |
| 329 | unsubscribeExit: null, | |
| 330 | hasRunningSubprocess: false, | |
| 331 | runtimeEnv: normalizedRuntimeEnv(input.env), | |
| 332 | }; | |
| 333 | this.sessions.set(sessionKey, session); | |
| 334 | this.startSession(session, { ...input, cols, rows }, "started"); | |
| 335 | return this.snapshot(session); | |
| 336 | } | |
| 337 | ||
| 338 | const nextRuntimeEnv = normalizedRuntimeEnv(input.env); | |
| 339 | const currentRuntimeEnv = existing.runtimeEnv; | |
| 340 | const targetCols = input.cols ?? existing.cols; | |
| 341 | const targetRows = input.rows ?? existing.rows; | |
| 342 | const runtimeEnvChanged = | |
| 343 | JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv); | |
| 344 | ||
| 345 | if (existing.cwd !== input.cwd || runtimeEnvChanged) { | |
| 346 | this.stopProcess(existing); | |
| 347 | existing.cwd = input.cwd; | |
| 348 | existing.runtimeEnv = nextRuntimeEnv; | |
| 349 | existing.history = ""; | |
| 350 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); | |
| 351 | } else if (existing.status === "exited" || existing.status === "error") { | |
| 352 | existing.runtimeEnv = nextRuntimeEnv; | |
| 353 | existing.history = ""; | |
| 354 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); | |
| 355 | } else if (currentRuntimeEnv !== nextRuntimeEnv) { | |
| 356 | existing.runtimeEnv = nextRuntimeEnv; | |
| 357 | } | |
| 358 | ||
| 359 | if (!existing.process) { | |
| 360 | this.startSession(existing, { ...input, cols: targetCols, rows: targetRows }, "started"); | |
| 361 | return this.snapshot(existing); | |
| 362 | } | |
| 363 | ||
| 364 | if (existing.cols !== targetCols || existing.rows !== targetRows) { | |
| 365 | existing.cols = targetCols; | |
| 366 | existing.rows = targetRows; | |
| 367 | existing.process.resize(targetCols, targetRows); | |
| 368 | existing.updatedAt = new Date().toISOString(); | |
| 369 | } | |
| 370 | ||
| 371 | return this.snapshot(existing); | |
| 372 | }); | |
| 373 | } | |
| 374 | ||
| 375 | async write(raw: TerminalWriteInput): Promise<void> { | |
| 376 | const input = decodeTerminalWriteInput(raw); | |
| 377 | const session = this.requireSession(input.threadId, input.terminalId); | |
| 378 | if (!session.process || session.status !== "running") { | |
| 379 | throw new Error( | |
| 380 | `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, | |
| 381 | ); | |
| 382 | } | |
| 383 | session.process.write(input.data); | |
| 384 | } | |
| 385 | ||
| 386 | async resize(raw: TerminalResizeInput): Promise<void> { | |
| 387 | const input = decodeTerminalResizeInput(raw); | |
| 388 | const session = this.requireSession(input.threadId, input.terminalId); | |
| 389 | if (!session.process || session.status !== "running") { | |
| 390 | throw new Error( | |
| 391 | `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, | |
| 392 | ); | |
| 393 | } | |
| 394 | session.cols = input.cols; | |
| 395 | session.rows = input.rows; | |
| 396 | session.updatedAt = new Date().toISOString(); | |
| 397 | session.process.resize(input.cols, input.rows); | |
| 398 | } | |
| 399 | ||
| 400 | async clear(raw: TerminalClearInput): Promise<void> { | |
| 401 | const input = decodeTerminalClearInput(raw); | |
| 402 | await this.runWithThreadLock(input.threadId, async () => { | |
| 403 | const session = this.requireSession(input.threadId, input.terminalId); | |
| 404 | session.history = ""; | |
| 405 | session.updatedAt = new Date().toISOString(); | |
| 406 | await this.persistHistory(input.threadId, input.terminalId, session.history); | |
| 407 | this.emitEvent({ | |
| 408 | type: "cleared", | |
| 409 | threadId: input.threadId, | |
| 410 | terminalId: input.terminalId, | |
| 411 | createdAt: new Date().toISOString(), | |
| 412 | }); | |
| 413 | }); | |
| 414 | } | |
| 415 | ||
| 416 | async restart(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> { | |
| 417 | const input = decodeTerminalOpenInput(raw); | |
| 418 | return this.runWithThreadLock(input.threadId, async () => { | |
| 419 | await this.assertValidCwd(input.cwd); | |
| 420 | ||
| 421 | const sessionKey = toSessionKey(input.threadId, input.terminalId); | |
| 422 | let session = this.sessions.get(sessionKey); | |
| 423 | if (!session) { | |
| 424 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | |
| 425 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | |
| 426 | session = { | |
| 427 | threadId: input.threadId, | |
| 428 | terminalId: input.terminalId, | |
| 429 | cwd: input.cwd, | |
| 430 | status: "starting", | |
| 431 | pid: null, | |
| 432 | history: "", | |
| 433 | exitCode: null, | |
| 434 | exitSignal: null, | |
| 435 | updatedAt: new Date().toISOString(), | |
| 436 | cols, | |
| 437 | rows, | |
| 438 | process: null, | |
| 439 | unsubscribeData: null, | |
| 440 | unsubscribeExit: null, | |
| 441 | hasRunningSubprocess: false, | |
| 442 | runtimeEnv: normalizedRuntimeEnv(input.env), | |
| 443 | }; | |
| 444 | this.sessions.set(sessionKey, session); | |
| 445 | } else { | |
| 446 | this.stopProcess(session); | |
| 447 | session.cwd = input.cwd; | |
| 448 | session.runtimeEnv = normalizedRuntimeEnv(input.env); | |
| 449 | } | |
| 450 | ||
| 451 | const cols = input.cols ?? session.cols; | |
| 452 | const rows = input.rows ?? session.rows; | |
| 453 | ||
| 454 | session.history = ""; | |
| 455 | await this.persistHistory(input.threadId, input.terminalId, session.history); | |
| 456 | this.startSession(session, { ...input, cols, rows }, "restarted"); | |
| 457 | return this.snapshot(session); | |
| 458 | }); | |
| 459 | } | |
| 460 | ||
| 461 | async close(raw: TerminalCloseInput): Promise<void> { | |
| 462 | const input = decodeTerminalCloseInput(raw); | |
| 463 | await this.runWithThreadLock(input.threadId, async () => { | |
| 464 | if (input.terminalId) { | |
| 465 | await this.closeSession(input.threadId, input.terminalId, input.deleteHistory === true); | |
| 466 | return; | |
| 467 | } | |
| 468 | ||
| 469 | const threadSessions = this.sessionsForThread(input.threadId); | |
| 470 | for (const session of threadSessions) { | |
| 471 | this.stopProcess(session); | |
| 472 | this.sessions.delete(toSessionKey(session.threadId, session.terminalId)); | |
| 473 | } | |
| 474 | await Promise.all( | |
| 475 | threadSessions.map((session) => | |
| 476 | this.flushPersistQueue(session.threadId, session.terminalId), | |
| 477 | ), | |
| 478 | ); | |
| 479 | ||
| 480 | if (input.deleteHistory) { | |
| 481 | await this.deleteAllHistoryForThread(input.threadId); | |
| 482 | } | |
| 483 | this.updateSubprocessPollingState(); | |
| 484 | }); | |
| 485 | } | |
| 486 | ||
| 487 | dispose(): void { | |
| 488 | this.stopSubprocessPolling(); | |
| 489 | const sessions = [...this.sessions.values()]; | |
| 490 | this.sessions.clear(); | |
| 491 | for (const session of sessions) { | |
| 492 | this.stopProcess(session); | |
| 493 | } | |
| 494 | for (const timer of this.persistTimers.values()) { | |
| 495 | clearTimeout(timer); | |
| 496 | } | |
| 497 | this.persistTimers.clear(); | |
| 498 | this.pendingPersistHistory.clear(); | |
| 499 | this.threadLocks.clear(); | |
| 500 | this.persistQueues.clear(); | |
| 501 | } | |
| 502 | ||
| 503 | private startSession( | |
| 504 | session: TerminalSessionState, | |
| 505 | input: TerminalStartInput, | |
| 506 | eventType: "started" | "restarted", | |
| 507 | ): void { | |
| 508 | this.stopProcess(session); | |
| 509 | ||
| 510 | session.status = "starting"; | |
| 511 | session.cwd = input.cwd; | |
| 512 | session.cols = input.cols; | |
| 513 | session.rows = input.rows; | |
| 514 | session.exitCode = null; | |
| 515 | session.exitSignal = null; | |
| 516 | session.hasRunningSubprocess = false; | |
| 517 | session.updatedAt = new Date().toISOString(); | |
| 518 | ||
| 519 | let ptyProcess: PtyProcess | null = null; | |
| 520 | let startedShell: string | null = null; | |
| 521 | try { | |
| 522 | const shellCandidates = resolveShellCandidates(this.shellResolver); | |
| 523 | const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); | |
| 524 | let lastSpawnError: unknown = null; | |
| 525 | ||
| 526 | for (const candidate of shellCandidates) { | |
| 527 | try { | |
| 528 | ptyProcess = this.ptyAdapter.spawn({ | |
| 529 | shell: candidate.shell, | |
| 530 | ...(candidate.args ? { args: candidate.args } : {}), | |
| 531 | cwd: session.cwd, | |
| 532 | cols: session.cols, | |
| 533 | rows: session.rows, | |
| 534 | env: terminalEnv, | |
| 535 | }); | |
| 536 | startedShell = formatShellCandidate(candidate); | |
| 537 | break; | |
| 538 | } catch (error) { | |
| 539 | lastSpawnError = error; | |
| 540 | if (!isRetryableShellSpawnError(error)) { | |
| 541 | throw error; | |
| 542 | } | |
| 543 | } | |
| 544 | } | |
| 545 | ||
| 546 | if (!ptyProcess) { | |
| 547 | const detail = | |
| 548 | lastSpawnError instanceof Error ? lastSpawnError.message : "Terminal start failed"; | |
| 549 | const tried = | |
| 550 | shellCandidates.length > 0 | |
| 551 | ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.` | |
| 552 | : ""; | |
| 553 | throw new Error(`${detail}.${tried}`.trim()); | |
| 554 | } | |
| 555 | ||
| 556 | session.process = ptyProcess; | |
| 557 | session.pid = ptyProcess.pid; | |
| 558 | session.status = "running"; | |
| 559 | session.updatedAt = new Date().toISOString(); | |
| 560 | session.unsubscribeData = ptyProcess.onData((data) => { | |
| 561 | this.onProcessData(session, data); | |
| 562 | }); | |
| 563 | session.unsubscribeExit = ptyProcess.onExit((event) => { | |
| 564 | this.onProcessExit(session, event); | |
| 565 | }); | |
| 566 | this.updateSubprocessPollingState(); | |
| 567 | this.emitEvent({ | |
| 568 | type: eventType, | |
| 569 | threadId: session.threadId, | |
| 570 | terminalId: session.terminalId, | |
| 571 | createdAt: new Date().toISOString(), | |
| 572 | snapshot: this.snapshot(session), | |
| 573 | }); | |
| 574 | } catch (error) { | |
| 575 | if (ptyProcess) { | |
| 576 | try { | |
| 577 | ptyProcess.kill(); | |
| 578 | } catch { | |
| 579 | // Ignore kill errors during failed startup cleanup. | |
| 580 | } | |
| 581 | } | |
| 582 | session.status = "error"; | |
| 583 | session.pid = null; | |
| 584 | session.process = null; | |
| 585 | session.hasRunningSubprocess = false; | |
| 586 | session.updatedAt = new Date().toISOString(); | |
| 587 | this.updateSubprocessPollingState(); | |
| 588 | const message = error instanceof Error ? error.message : "Terminal start failed"; | |
| 589 | this.emitEvent({ | |
| 590 | type: "error", | |
| 591 | threadId: session.threadId, | |
| 592 | terminalId: session.terminalId, | |
| 593 | createdAt: new Date().toISOString(), | |
| 594 | message, | |
| 595 | }); | |
| 596 | this.logger.error("failed to start terminal", { | |
| 597 | threadId: session.threadId, | |
| 598 | terminalId: session.terminalId, | |
| 599 | error: message, | |
| 600 | ...(startedShell ? { shell: startedShell } : {}), | |
| 601 | }); | |
| 602 | } | |
| 603 | } | |
| 604 | ||
| 605 | private onProcessData(session: TerminalSessionState, data: string): void { | |
| 606 | session.history = capHistory(`${session.history}${data}`, this.historyLineLimit); | |
| 607 | session.updatedAt = new Date().toISOString(); | |
| 608 | this.queuePersist(session.threadId, session.terminalId, session.history); | |
| 609 | this.emitEvent({ | |
| 610 | type: "output", | |
| 611 | threadId: session.threadId, | |
| 612 | terminalId: session.terminalId, | |
| 613 | createdAt: new Date().toISOString(), | |
| 614 | data, | |
| 615 | }); | |
| 616 | } | |
| 617 | ||
| 618 | private onProcessExit(session: TerminalSessionState, event: PtyExitEvent): void { | |
| 619 | this.cleanupProcessHandles(session); | |
| 620 | session.process = null; | |
| 621 | session.pid = null; | |
| 622 | session.hasRunningSubprocess = false; | |
| 623 | session.status = "exited"; | |
| 624 | session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null; | |
| 625 | session.exitSignal = Number.isInteger(event.signal) ? event.signal : null; | |
| 626 | session.updatedAt = new Date().toISOString(); | |
| 627 | this.emitEvent({ | |
| 628 | type: "exited", | |
| 629 | threadId: session.threadId, | |
| 630 | terminalId: session.terminalId, | |
| 631 | createdAt: new Date().toISOString(), | |
| 632 | exitCode: session.exitCode, | |
| 633 | exitSignal: session.exitSignal, | |
| 634 | }); | |
| 635 | this.updateSubprocessPollingState(); | |
| 636 | } | |
| 637 | ||
| 638 | private stopProcess(session: TerminalSessionState): void { | |
| 639 | const process = session.process; | |
| 640 | if (!process) return; | |
| 641 | this.cleanupProcessHandles(session); | |
| 642 | session.process = null; | |
| 643 | session.pid = null; | |
| 644 | session.hasRunningSubprocess = false; | |
| 645 | session.status = "exited"; | |
| 646 | session.updatedAt = new Date().toISOString(); | |
| 647 | try { | |
| 648 | process.kill(); | |
| 649 | } catch (error) { | |
| 650 | const message = error instanceof Error ? error.message : String(error); | |
| 651 | this.logger.warn("failed to kill terminal process", { | |
| 652 | threadId: session.threadId, | |
| 653 | terminalId: session.terminalId, | |
| 654 | error: message, | |
| 655 | }); | |
| 656 | } | |
| 657 | this.updateSubprocessPollingState(); | |
| 658 | } | |
| 659 | ||
| 660 | private cleanupProcessHandles(session: TerminalSessionState): void { | |
| 661 | session.unsubscribeData?.(); | |
| 662 | session.unsubscribeData = null; | |
| 663 | session.unsubscribeExit?.(); | |
| 664 | session.unsubscribeExit = null; | |
| 665 | } | |
| 666 | ||
| 667 | private queuePersist(threadId: string, terminalId: string, history: string): void { | |
| 668 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 669 | this.pendingPersistHistory.set(persistenceKey, history); | |
| 670 | this.schedulePersist(threadId, terminalId); | |
| 671 | } | |
| 672 | ||
| 673 | private async persistHistory( | |
| 674 | threadId: string, | |
| 675 | terminalId: string, | |
| 676 | history: string, | |
| 677 | ): Promise<void> { | |
| 678 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 679 | this.clearPersistTimer(threadId, terminalId); | |
| 680 | this.pendingPersistHistory.delete(persistenceKey); | |
| 681 | await this.enqueuePersistWrite(threadId, terminalId, history); | |
| 682 | } | |
| 683 | ||
| 684 | private enqueuePersistWrite( | |
| 685 | threadId: string, | |
| 686 | terminalId: string, | |
| 687 | history: string, | |
| 688 | ): Promise<void> { | |
| 689 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 690 | const task = async () => { | |
| 691 | await fs.promises.writeFile(this.historyPath(threadId, terminalId), history, "utf8"); | |
| 692 | }; | |
| 693 | const previous = this.persistQueues.get(persistenceKey) ?? Promise.resolve(); | |
| 694 | const next = previous | |
| 695 | .catch(() => undefined) | |
| 696 | .then(task) | |
| 697 | .catch((error) => { | |
| 698 | this.logger.warn("failed to persist terminal history", { | |
| 699 | threadId, | |
| 700 | terminalId, | |
| 701 | error: error instanceof Error ? error.message : String(error), | |
| 702 | }); | |
| 703 | }); | |
| 704 | this.persistQueues.set(persistenceKey, next); | |
| 705 | const finalized = next.finally(() => { | |
| 706 | if (this.persistQueues.get(persistenceKey) === next) { | |
| 707 | this.persistQueues.delete(persistenceKey); | |
| 708 | } | |
| 709 | if ( | |
| 710 | this.pendingPersistHistory.has(persistenceKey) && | |
| 711 | !this.persistTimers.has(persistenceKey) | |
| 712 | ) { | |
| 713 | this.schedulePersist(threadId, terminalId); | |
| 714 | } | |
| 715 | }); | |
| 716 | void finalized.catch(() => undefined); | |
| 717 | return finalized; | |
| 718 | } | |
| 719 | ||
| 720 | private schedulePersist(threadId: string, terminalId: string): void { | |
| 721 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 722 | if (this.persistTimers.has(persistenceKey)) return; | |
| 723 | const timer = setTimeout(() => { | |
| 724 | this.persistTimers.delete(persistenceKey); | |
| 725 | const pendingHistory = this.pendingPersistHistory.get(persistenceKey); | |
| 726 | if (pendingHistory === undefined) return; | |
| 727 | this.pendingPersistHistory.delete(persistenceKey); | |
| 728 | void this.enqueuePersistWrite(threadId, terminalId, pendingHistory); | |
| 729 | }, this.persistDebounceMs); | |
| 730 | this.persistTimers.set(persistenceKey, timer); | |
| 731 | } | |
| 732 | ||
| 733 | private clearPersistTimer(threadId: string, terminalId: string): void { | |
| 734 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 735 | const timer = this.persistTimers.get(persistenceKey); | |
| 736 | if (!timer) return; | |
| 737 | clearTimeout(timer); | |
| 738 | this.persistTimers.delete(persistenceKey); | |
| 739 | } | |
| 740 | ||
| 741 | private async readHistory(threadId: string, terminalId: string): Promise<string> { | |
| 742 | const nextPath = this.historyPath(threadId, terminalId); | |
| 743 | try { | |
| 744 | const raw = await fs.promises.readFile(nextPath, "utf8"); | |
| 745 | const capped = capHistory(raw, this.historyLineLimit); | |
| 746 | if (capped !== raw) { | |
| 747 | await fs.promises.writeFile(nextPath, capped, "utf8"); | |
| 748 | } | |
| 749 | return capped; | |
| 750 | } catch (error) { | |
| 751 | if ((error as NodeJS.ErrnoException).code !== "ENOENT") { | |
| 752 | throw error; | |
| 753 | } | |
| 754 | } | |
| 755 | ||
| 756 | if (terminalId !== DEFAULT_TERMINAL_ID) { | |
| 757 | return ""; | |
| 758 | } | |
| 759 | ||
| 760 | const legacyPath = this.legacyHistoryPath(threadId); | |
| 761 | try { | |
| 762 | const raw = await fs.promises.readFile(legacyPath, "utf8"); | |
| 763 | const capped = capHistory(raw, this.historyLineLimit); | |
| 764 | ||
| 765 | // Migrate legacy transcript filename to the terminal-scoped path. | |
| 766 | await fs.promises.writeFile(nextPath, capped, "utf8"); | |
| 767 | try { | |
| 768 | await fs.promises.rm(legacyPath, { force: true }); | |
| 769 | } catch (cleanupError) { | |
| 770 | this.logger.warn("failed to remove legacy terminal history", { | |
| 771 | threadId, | |
| 772 | error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), | |
| 773 | }); | |
| 774 | } | |
| 775 | ||
| 776 | return capped; | |
| 777 | } catch (error) { | |
| 778 | if ((error as NodeJS.ErrnoException).code === "ENOENT") { | |
| 779 | return ""; | |
| 780 | } | |
| 781 | throw error; | |
| 782 | } | |
| 783 | } | |
| 784 | ||
| 785 | private async deleteHistory(threadId: string, terminalId: string): Promise<void> { | |
| 786 | const deletions = [fs.promises.rm(this.historyPath(threadId, terminalId), { force: true })]; | |
| 787 | if (terminalId === DEFAULT_TERMINAL_ID) { | |
| 788 | deletions.push(fs.promises.rm(this.legacyHistoryPath(threadId), { force: true })); | |
| 789 | } | |
| 790 | try { | |
| 791 | await Promise.all(deletions); | |
| 792 | } catch (error) { | |
| 793 | this.logger.warn("failed to delete terminal history", { | |
| 794 | threadId, | |
| 795 | terminalId, | |
| 796 | error: error instanceof Error ? error.message : String(error), | |
| 797 | }); | |
| 798 | } | |
| 799 | } | |
| 800 | ||
| 801 | private async flushPersistQueue(threadId: string, terminalId: string): Promise<void> { | |
| 802 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 803 | this.clearPersistTimer(threadId, terminalId); | |
| 804 | ||
| 805 | while (true) { | |
| 806 | const pendingHistory = this.pendingPersistHistory.get(persistenceKey); | |
| 807 | if (pendingHistory !== undefined) { | |
| 808 | this.pendingPersistHistory.delete(persistenceKey); | |
| 809 | await this.enqueuePersistWrite(threadId, terminalId, pendingHistory); | |
| 810 | } | |
| 811 | ||
| 812 | const pending = this.persistQueues.get(persistenceKey); | |
| 813 | if (!pending) { | |
| 814 | return; | |
| 815 | } | |
| 816 | await pending.catch(() => undefined); | |
| 817 | } | |
| 818 | } | |
| 819 | ||
| 820 | private updateSubprocessPollingState(): void { | |
| 821 | const hasRunningSessions = [...this.sessions.values()].some( | |
| 822 | (session) => session.status === "running" && session.pid !== null, | |
| 823 | ); | |
| 824 | if (hasRunningSessions) { | |
| 825 | this.ensureSubprocessPolling(); | |
| 826 | return; | |
| 827 | } | |
| 828 | this.stopSubprocessPolling(); | |
| 829 | } | |
| 830 | ||
| 831 | private ensureSubprocessPolling(): void { | |
| 832 | if (this.subprocessPollTimer) return; | |
| 833 | this.subprocessPollTimer = setInterval(() => { | |
| 834 | void this.pollSubprocessActivity(); | |
| 835 | }, this.subprocessPollIntervalMs); | |
| 836 | this.subprocessPollTimer.unref?.(); | |
| 837 | void this.pollSubprocessActivity(); | |
| 838 | } | |
| 839 | ||
| 840 | private stopSubprocessPolling(): void { | |
| 841 | if (!this.subprocessPollTimer) return; | |
| 842 | clearInterval(this.subprocessPollTimer); | |
| 843 | this.subprocessPollTimer = null; | |
| 844 | } | |
| 845 | ||
| 846 | private async pollSubprocessActivity(): Promise<void> { | |
| 847 | if (this.subprocessPollInFlight) return; | |
| 848 | ||
| 849 | const runningSessions = [...this.sessions.values()].filter( | |
| 850 | (session): session is TerminalSessionState & { pid: number } => | |
| 851 | session.status === "running" && Number.isInteger(session.pid), | |
| 852 | ); | |
| 853 | if (runningSessions.length === 0) { | |
| 854 | this.stopSubprocessPolling(); | |
| 855 | return; | |
| 856 | } | |
| 857 | ||
| 858 | this.subprocessPollInFlight = true; | |
| 859 | try { | |
| 860 | await Promise.all( | |
| 861 | runningSessions.map(async (session) => { | |
| 862 | const terminalPid = session.pid; | |
| 863 | let hasRunningSubprocess = false; | |
| 864 | try { | |
| 865 | hasRunningSubprocess = await this.subprocessChecker(terminalPid); | |
| 866 | } catch (error) { | |
| 867 | this.logger.warn("failed to check terminal subprocess activity", { | |
| 868 | threadId: session.threadId, | |
| 869 | terminalId: session.terminalId, | |
| 870 | terminalPid, | |
| 871 | error: error instanceof Error ? error.message : String(error), | |
| 872 | }); | |
| 873 | return; | |
| 874 | } | |
| 875 | ||
| 876 | const liveSession = this.sessions.get(toSessionKey(session.threadId, session.terminalId)); | |
| 877 | if (!liveSession || liveSession.status !== "running" || liveSession.pid !== terminalPid) { | |
| 878 | return; | |
| 879 | } | |
| 880 | if (liveSession.hasRunningSubprocess === hasRunningSubprocess) { | |
| 881 | return; | |
| 882 | } | |
| 883 | ||
| 884 | liveSession.hasRunningSubprocess = hasRunningSubprocess; | |
| 885 | liveSession.updatedAt = new Date().toISOString(); | |
| 886 | this.emitEvent({ | |
| 887 | type: "activity", | |
| 888 | threadId: liveSession.threadId, | |
| 889 | terminalId: liveSession.terminalId, | |
| 890 | createdAt: new Date().toISOString(), | |
| 891 | hasRunningSubprocess, | |
| 892 | }); | |
| 893 | }), | |
| 894 | ); | |
| 895 | } finally { | |
| 896 | this.subprocessPollInFlight = false; | |
| 897 | } | |
| 898 | } | |
| 899 | ||
| 900 | private async assertValidCwd(cwd: string): Promise<void> { | |
| 901 | let stats: fs.Stats; | |
| 902 | try { | |
| 903 | stats = await fs.promises.stat(cwd); | |
| 904 | } catch (error) { | |
| 905 | if ((error as NodeJS.ErrnoException).code === "ENOENT") { | |
| 906 | throw new Error(`Terminal cwd does not exist: ${cwd}`, { cause: error }); | |
| 907 | } | |
| 908 | throw error; | |
| 909 | } | |
| 910 | if (!stats.isDirectory()) { | |
| 911 | throw new Error(`Terminal cwd is not a directory: ${cwd}`); | |
| 912 | } | |
| 913 | } | |
| 914 | ||
| 915 | private async closeSession( | |
| 916 | threadId: string, | |
| 917 | terminalId: string, | |
| 918 | deleteHistory: boolean, | |
| 919 | ): Promise<void> { | |
| 920 | const key = toSessionKey(threadId, terminalId); | |
| 921 | const session = this.sessions.get(key); | |
| 922 | if (session) { | |
| 923 | this.stopProcess(session); | |
| 924 | this.sessions.delete(key); | |
| 925 | } | |
| 926 | this.updateSubprocessPollingState(); | |
| 927 | await this.flushPersistQueue(threadId, terminalId); | |
| 928 | if (deleteHistory) { | |
| 929 | await this.deleteHistory(threadId, terminalId); | |
| 930 | } | |
| 931 | } | |
| 932 | ||
| 933 | private sessionsForThread(threadId: string): TerminalSessionState[] { | |
| 934 | return [...this.sessions.values()].filter((session) => session.threadId === threadId); | |
| 935 | } | |
| 936 | ||
| 937 | private async deleteAllHistoryForThread(threadId: string): Promise<void> { | |
| 938 | const threadPrefix = `${toSafeThreadId(threadId)}_`; | |
| 939 | try { | |
| 940 | const entries = await fs.promises.readdir(this.logsDir, { withFileTypes: true }); | |
| 941 | const removals = entries | |
| 942 | .filter((entry) => entry.isFile()) | |
| 943 | .map((entry) => entry.name) | |
| 944 | .filter( | |
| 945 | (name) => | |
| 946 | name === `${toSafeThreadId(threadId)}.log` || | |
| 947 | name === `${legacySafeThreadId(threadId)}.log` || | |
| 948 | name.startsWith(threadPrefix), | |
| 949 | ) | |
| 950 | .map((name) => fs.promises.rm(path.join(this.logsDir, name), { force: true })); | |
| 951 | await Promise.all(removals); | |
| 952 | } catch (error) { | |
| 953 | this.logger.warn("failed to delete terminal histories for thread", { | |
| 954 | threadId, | |
| 955 | error: error instanceof Error ? error.message : String(error), | |
| 956 | }); | |
| 957 | } | |
| 958 | } | |
| 959 | ||
| 960 | private requireSession(threadId: string, terminalId: string): TerminalSessionState { | |
| 961 | const session = this.sessions.get(toSessionKey(threadId, terminalId)); | |
| 962 | if (!session) { | |
| 963 | throw new Error(`Unknown terminal thread: ${threadId}, terminal: ${terminalId}`); | |
| 964 | } | |
| 965 | return session; | |
| 966 | } | |
| 967 | ||
| 968 | private snapshot(session: TerminalSessionState): TerminalSessionSnapshot { | |
| 969 | return { | |
| 970 | threadId: session.threadId, | |
| 971 | terminalId: session.terminalId, | |
| 972 | cwd: session.cwd, | |
| 973 | status: session.status, | |
| 974 | pid: session.pid, | |
| 975 | history: session.history, | |
| 976 | exitCode: session.exitCode, | |
| 977 | exitSignal: session.exitSignal, | |
| 978 | updatedAt: session.updatedAt, | |
| 979 | }; | |
| 980 | } | |
| 981 | ||
| 982 | private emitEvent(event: TerminalEvent): void { | |
| 983 | this.emit("event", event); | |
| 984 | } | |
| 985 | ||
| 986 | private historyPath(threadId: string, terminalId: string): string { | |
| 987 | const threadPart = toSafeThreadId(threadId); | |
| 988 | if (terminalId === DEFAULT_TERMINAL_ID) { | |
| 989 | return path.join(this.logsDir, `${threadPart}.log`); | |
| 990 | } | |
| 991 | return path.join(this.logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); | |
| 992 | } | |
| 993 | ||
| 994 | private legacyHistoryPath(threadId: string): string { | |
| 995 | return path.join(this.logsDir, `${legacySafeThreadId(threadId)}.log`); | |
| 996 | } | |
| 997 | ||
| 998 | private async runWithThreadLock<T>(threadId: string, task: () => Promise<T>): Promise<T> { | |
| 999 | const previous = this.threadLocks.get(threadId) ?? Promise.resolve(); | |
| 1000 | let release: () => void = () => {}; | |
| 1001 | const current = new Promise<void>((resolve) => { | |
| 1002 | release = resolve; | |
| 1003 | }); | |
| 1004 | this.threadLocks.set(threadId, current); | |
| 1005 | await previous.catch(() => undefined); | |
| 1006 | try { | |
| 1007 | return await task(); | |
| 1008 | } finally { | |
| 1009 | release(); | |
| 1010 | if (this.threadLocks.get(threadId) === current) { | |
| 1011 | this.threadLocks.delete(threadId); | |
| 1012 | } | |
| 1013 | } | |
| 1014 | } | |
| 1015 | } | |
| 1016 | ||
| 1017 | export const TerminalManagerLive = Layer.effect( | |
| 1018 | TerminalManager, | |
| 1019 | Effect.gen(function* () { | |
| 1020 | const { stateDir } = yield* ServerConfig; | |
| 1021 | const { join } = yield* Path.Path; | |
| 1022 | const logsDir = join(stateDir, "logs", "terminals"); | |
| 1023 | ||
| 1024 | const ptyAdapter = yield* PtyAdapter; | |
| 1025 | const runtime = new TerminalManagerRuntime({ logsDir, ptyAdapter }); | |
| 1026 | ||
| 1027 | return { | |
| 1028 | open: (input) => Effect.promise(() => runtime.open(input)), | |
| 1029 | write: (input) => Effect.promise(() => runtime.write(input)), | |
| 1030 | resize: (input) => Effect.promise(() => runtime.resize(input)), | |
| 1031 | clear: (input) => Effect.promise(() => runtime.clear(input)), | |
| 1032 | restart: (input) => Effect.promise(() => runtime.restart(input)), | |
| 1033 | close: (input) => Effect.promise(() => runtime.close(input)), | |
| 1034 | subscribe: (listener) => | |
| 1035 | Effect.sync(() => { | |
| 1036 | runtime.on("event", listener); | |
| 1037 | return () => { | |
| 1038 | runtime.off("event", listener); | |
| 1039 | }; | |
| 1040 | }), | |
| 1041 | dispose: () => | |
| 1042 | Effect.sync(() => { | |
| 1043 | runtime.dispose(); | |
| 1044 | }), | |
| 1045 | } satisfies TerminalManagerShape; | |
| 1046 | }), | |
| 1047 | ); |
apps/server/src/terminal/Layers/NodePTY.test.ts created+65| ... | ... | @@ -0,0 +1,65 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { assert, it, vi } from "@effect/vitest"; | |
| 6 | import { Effect, Layer } from "effect"; | |
| 7 | import { afterEach } from "vitest"; | |
| 8 | ||
| 9 | import { ensureNodePtySpawnHelperExecutable } from "./NodePTY"; | |
| 10 | ||
| 11 | function fileMode(filePath: string): number { | |
| 12 | return fs.statSync(filePath).mode & 0o777; | |
| 13 | } | |
| 14 | ||
| 15 | const tempDirs: string[] = []; | |
| 16 | ||
| 17 | afterEach(() => { | |
| 18 | vi.unstubAllGlobals(); | |
| 19 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 20 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 21 | } | |
| 22 | }); | |
| 23 | ||
| 24 | const helperLayer = it.layer(Layer.empty); | |
| 25 | ||
| 26 | helperLayer("ensureNodePtySpawnHelperExecutable", (it) => { | |
| 27 | it.effect("adds executable bits when helper exists but is not executable", () => | |
| 28 | Effect.sync(() => { | |
| 29 | if (process.platform === "win32") return; | |
| 30 | ||
| 31 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); | |
| 32 | tempDirs.push(dir); | |
| 33 | const helperPath = path.join(dir, "spawn-helper"); | |
| 34 | fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); | |
| 35 | fs.chmodSync(helperPath, 0o644); | |
| 36 | ||
| 37 | ensureNodePtySpawnHelperExecutable(helperPath); | |
| 38 | ||
| 39 | assert.equal(fileMode(helperPath) & 0o111, 0o111); | |
| 40 | }), | |
| 41 | ); | |
| 42 | ||
| 43 | it.effect("keeps executable helper as executable", () => | |
| 44 | Effect.sync(() => { | |
| 45 | if (process.platform === "win32") return; | |
| 46 | ||
| 47 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); | |
| 48 | tempDirs.push(dir); | |
| 49 | const helperPath = path.join(dir, "spawn-helper"); | |
| 50 | fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); | |
| 51 | fs.chmodSync(helperPath, 0o755); | |
| 52 | ||
| 53 | ensureNodePtySpawnHelperExecutable(helperPath); | |
| 54 | ||
| 55 | assert.equal(fileMode(helperPath) & 0o111, 0o111); | |
| 56 | }), | |
| 57 | ); | |
| 58 | ||
| 59 | it.effect("does not throw when helper path is missing", () => | |
| 60 | Effect.sync(() => { | |
| 61 | const missing = path.join(os.tmpdir(), `does-not-exist-${Date.now()}`); | |
| 62 | ensureNodePtySpawnHelperExecutable(missing); | |
| 63 | }), | |
| 64 | ); | |
| 65 | }); |
apps/server/src/terminal/Layers/NodePTY.ts created+111| ... | ... | @@ -0,0 +1,111 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import { createRequire } from "node:module"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { Effect, Layer } from "effect"; | |
| 6 | import { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY"; | |
| 7 | ||
| 8 | const requireForNodePty = createRequire(import.meta.url); | |
| 9 | let didEnsureSpawnHelperExecutable = false; | |
| 10 | ||
| 11 | function resolveNodePtySpawnHelperPath(): string | null { | |
| 12 | try { | |
| 13 | const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); | |
| 14 | const packageDir = path.dirname(packageJsonPath); | |
| 15 | const candidates = [ | |
| 16 | path.join(packageDir, "build", "Release", "spawn-helper"), | |
| 17 | path.join(packageDir, "build", "Debug", "spawn-helper"), | |
| 18 | path.join(packageDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), | |
| 19 | ]; | |
| 20 | ||
| 21 | for (const candidate of candidates) { | |
| 22 | if (fs.existsSync(candidate)) { | |
| 23 | return candidate; | |
| 24 | } | |
| 25 | } | |
| 26 | ||
| 27 | return null; | |
| 28 | } catch { | |
| 29 | return null; | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | export function ensureNodePtySpawnHelperExecutable(explicitPath?: string): void { | |
| 34 | if (process.platform === "win32") return; | |
| 35 | if (!explicitPath && didEnsureSpawnHelperExecutable) return; | |
| 36 | ||
| 37 | const helperPath = explicitPath ?? resolveNodePtySpawnHelperPath(); | |
| 38 | if (!helperPath) return; | |
| 39 | if (!explicitPath) { | |
| 40 | didEnsureSpawnHelperExecutable = true; | |
| 41 | } | |
| 42 | ||
| 43 | try { | |
| 44 | const stat = fs.statSync(helperPath); | |
| 45 | const mode = stat.mode & 0o777; | |
| 46 | if ((mode & 0o111) === 0) { | |
| 47 | fs.chmodSync(helperPath, mode | 0o111); | |
| 48 | } | |
| 49 | } catch { | |
| 50 | // Best effort only. If chmod fails, node-pty spawn will surface the real error. | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | class NodePtyProcess implements PtyProcess { | |
| 55 | constructor(private readonly process: import("node-pty").IPty) {} | |
| 56 | ||
| 57 | get pid(): number { | |
| 58 | return this.process.pid; | |
| 59 | } | |
| 60 | ||
| 61 | write(data: string): void { | |
| 62 | this.process.write(data); | |
| 63 | } | |
| 64 | ||
| 65 | resize(cols: number, rows: number): void { | |
| 66 | this.process.resize(cols, rows); | |
| 67 | } | |
| 68 | ||
| 69 | kill(signal?: string): void { | |
| 70 | this.process.kill(signal); | |
| 71 | } | |
| 72 | ||
| 73 | onData(callback: (data: string) => void): () => void { | |
| 74 | const disposable = this.process.onData(callback); | |
| 75 | return () => { | |
| 76 | disposable.dispose(); | |
| 77 | }; | |
| 78 | } | |
| 79 | ||
| 80 | onExit(callback: (event: PtyExitEvent) => void): () => void { | |
| 81 | const disposable = this.process.onExit((event) => { | |
| 82 | callback({ | |
| 83 | exitCode: event.exitCode, | |
| 84 | signal: event.signal ?? null, | |
| 85 | }); | |
| 86 | }); | |
| 87 | return () => { | |
| 88 | disposable.dispose(); | |
| 89 | }; | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | export const NodePtyAdapterLive = Layer.effect( | |
| 94 | PtyAdapter, | |
| 95 | Effect.gen(function* () { | |
| 96 | const nodePty = yield* Effect.promise(() => import("node-pty")); | |
| 97 | return { | |
| 98 | spawn(input: PtySpawnInput): PtyProcess { | |
| 99 | ensureNodePtySpawnHelperExecutable(); | |
| 100 | const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { | |
| 101 | cwd: input.cwd, | |
| 102 | cols: input.cols, | |
| 103 | rows: input.rows, | |
| 104 | env: input.env, | |
| 105 | name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", | |
| 106 | }); | |
| 107 | return new NodePtyProcess(ptyProcess); | |
| 108 | }, | |
| 109 | }; | |
| 110 | }), | |
| 111 | ); |
apps/server/src/terminal/Services/Manager.ts created+71| ... | ... | @@ -0,0 +1,71 @@ |
| 1 | import { | |
| 2 | TerminalClearInput, | |
| 3 | TerminalCloseInput, | |
| 4 | TerminalEvent, | |
| 5 | TerminalOpenInput, | |
| 6 | TerminalResizeInput, | |
| 7 | TerminalSessionSnapshot, | |
| 8 | TerminalSessionStatus, | |
| 9 | TerminalWriteInput, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import { PtyAdapterShape, PtyProcess } from "./PTY"; | |
| 12 | import { Effect, ServiceMap } from "effect"; | |
| 13 | ||
| 14 | type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>; | |
| 15 | ||
| 16 | export interface TerminalManagerEvents { | |
| 17 | event: [event: TerminalEvent]; | |
| 18 | } | |
| 19 | ||
| 20 | export interface TerminalSessionState { | |
| 21 | threadId: string; | |
| 22 | terminalId: string; | |
| 23 | cwd: string; | |
| 24 | status: TerminalSessionStatus; | |
| 25 | pid: number | null; | |
| 26 | history: string; | |
| 27 | exitCode: number | null; | |
| 28 | exitSignal: number | null; | |
| 29 | updatedAt: string; | |
| 30 | cols: number; | |
| 31 | rows: number; | |
| 32 | process: PtyProcess | null; | |
| 33 | unsubscribeData: (() => void) | null; | |
| 34 | unsubscribeExit: (() => void) | null; | |
| 35 | hasRunningSubprocess: boolean; | |
| 36 | runtimeEnv: Record<string, string> | null; | |
| 37 | } | |
| 38 | ||
| 39 | export interface ShellCandidate { | |
| 40 | shell: string; | |
| 41 | args?: string[]; | |
| 42 | } | |
| 43 | ||
| 44 | export interface TerminalStartInput extends TerminalOpenInput { | |
| 45 | cols: number; | |
| 46 | rows: number; | |
| 47 | } | |
| 48 | ||
| 49 | export interface TerminalManagerOptions { | |
| 50 | logsDir?: string; | |
| 51 | historyLineLimit?: number; | |
| 52 | ptyAdapter: PtyAdapterShape; | |
| 53 | shellResolver?: () => string; | |
| 54 | subprocessChecker?: TerminalSubprocessChecker; | |
| 55 | subprocessPollIntervalMs?: number; | |
| 56 | } | |
| 57 | ||
| 58 | export interface TerminalManagerShape { | |
| 59 | readonly open: (input: TerminalOpenInput) => Effect.Effect<TerminalSessionSnapshot, unknown>; | |
| 60 | readonly write: (input: TerminalWriteInput) => Effect.Effect<void, unknown>; | |
| 61 | readonly resize: (input: TerminalResizeInput) => Effect.Effect<void, unknown>; | |
| 62 | readonly clear: (input: TerminalClearInput) => Effect.Effect<void, unknown>; | |
| 63 | readonly restart: (input: TerminalOpenInput) => Effect.Effect<TerminalSessionSnapshot, unknown>; | |
| 64 | readonly close: (input: TerminalCloseInput) => Effect.Effect<void, unknown>; | |
| 65 | readonly subscribe: (listener: (event: TerminalEvent) => void) => Effect.Effect<() => void>; | |
| 66 | readonly dispose: () => Effect.Effect<void>; | |
| 67 | } | |
| 68 | ||
| 69 | export class TerminalManager extends ServiceMap.Service<TerminalManager, TerminalManagerShape>()( | |
| 70 | "terminal/TerminalManager", | |
| 71 | ) {} |
apps/server/src/terminal/Services/PTY.ts created+32| ... | ... | @@ -0,0 +1,32 @@ |
| 1 | import { ServiceMap } from "effect"; | |
| 2 | ||
| 3 | export interface PtyExitEvent { | |
| 4 | exitCode: number; | |
| 5 | signal: number | null; | |
| 6 | } | |
| 7 | ||
| 8 | export interface PtyProcess { | |
| 9 | readonly pid: number; | |
| 10 | write(data: string): void; | |
| 11 | resize(cols: number, rows: number): void; | |
| 12 | kill(signal?: string): void; | |
| 13 | onData(callback: (data: string) => void): () => void; | |
| 14 | onExit(callback: (event: PtyExitEvent) => void): () => void; | |
| 15 | } | |
| 16 | ||
| 17 | export interface PtySpawnInput { | |
| 18 | shell: string; | |
| 19 | args?: string[]; | |
| 20 | cwd: string; | |
| 21 | cols: number; | |
| 22 | rows: number; | |
| 23 | env: NodeJS.ProcessEnv; | |
| 24 | } | |
| 25 | ||
| 26 | export interface PtyAdapterShape { | |
| 27 | spawn(input: PtySpawnInput): PtyProcess; | |
| 28 | } | |
| 29 | ||
| 30 | export class PtyAdapter extends ServiceMap.Service<PtyAdapter, PtyAdapterShape>()( | |
| 31 | "terminal/PtyAdapter", | |
| 32 | ) {} |
apps/server/src/terminalManager.test.ts deleted-567| ... | ... | @@ -1,567 +0,0 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { | |
| 6 | DEFAULT_TERMINAL_ID, | |
| 7 | type TerminalEvent, | |
| 8 | type TerminalOpenInput, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { afterEach, describe, expect, it } from "vitest"; | |
| 11 | ||
| 12 | import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter"; | |
| 13 | import { TerminalManagerRuntime } from "./terminalManager"; | |
| 14 | import { Encoding } from "effect"; | |
| 15 | ||
| 16 | class FakePtyProcess implements PtyProcess { | |
| 17 | readonly writes: string[] = []; | |
| 18 | readonly resizeCalls: Array<{ cols: number; rows: number }> = []; | |
| 19 | private readonly dataListeners = new Set<(data: string) => void>(); | |
| 20 | private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); | |
| 21 | killed = false; | |
| 22 | ||
| 23 | constructor(readonly pid: number) {} | |
| 24 | ||
| 25 | write(data: string): void { | |
| 26 | this.writes.push(data); | |
| 27 | } | |
| 28 | ||
| 29 | resize(cols: number, rows: number): void { | |
| 30 | this.resizeCalls.push({ cols, rows }); | |
| 31 | } | |
| 32 | ||
| 33 | kill(): void { | |
| 34 | this.killed = true; | |
| 35 | } | |
| 36 | ||
| 37 | onData(callback: (data: string) => void): () => void { | |
| 38 | this.dataListeners.add(callback); | |
| 39 | return () => { | |
| 40 | this.dataListeners.delete(callback); | |
| 41 | }; | |
| 42 | } | |
| 43 | ||
| 44 | onExit(callback: (event: PtyExitEvent) => void): () => void { | |
| 45 | this.exitListeners.add(callback); | |
| 46 | return () => { | |
| 47 | this.exitListeners.delete(callback); | |
| 48 | }; | |
| 49 | } | |
| 50 | ||
| 51 | emitData(data: string): void { | |
| 52 | for (const listener of this.dataListeners) { | |
| 53 | listener(data); | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | emitExit(event: PtyExitEvent): void { | |
| 58 | for (const listener of this.exitListeners) { | |
| 59 | listener(event); | |
| 60 | } | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | class FakePtyAdapter implements PtyAdapterShape { | |
| 65 | readonly spawnInputs: PtySpawnInput[] = []; | |
| 66 | readonly processes: FakePtyProcess[] = []; | |
| 67 | readonly spawnFailures: Error[] = []; | |
| 68 | private nextPid = 9000; | |
| 69 | ||
| 70 | spawn(input: PtySpawnInput): PtyProcess { | |
| 71 | this.spawnInputs.push(input); | |
| 72 | const failure = this.spawnFailures.shift(); | |
| 73 | if (failure) { | |
| 74 | throw failure; | |
| 75 | } | |
| 76 | const process = new FakePtyProcess(this.nextPid++); | |
| 77 | this.processes.push(process); | |
| 78 | return process; | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | function waitFor(predicate: () => boolean, timeoutMs = 800): Promise<void> { | |
| 83 | const started = Date.now(); | |
| 84 | return new Promise((resolve, reject) => { | |
| 85 | const poll = () => { | |
| 86 | if (predicate()) { | |
| 87 | resolve(); | |
| 88 | return; | |
| 89 | } | |
| 90 | if (Date.now() - started > timeoutMs) { | |
| 91 | reject(new Error("Timed out waiting for condition")); | |
| 92 | return; | |
| 93 | } | |
| 94 | setTimeout(poll, 15); | |
| 95 | }; | |
| 96 | poll(); | |
| 97 | }); | |
| 98 | } | |
| 99 | ||
| 100 | function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput { | |
| 101 | return { | |
| 102 | threadId: "thread-1", | |
| 103 | cwd: process.cwd(), | |
| 104 | cols: 100, | |
| 105 | rows: 24, | |
| 106 | ...overrides, | |
| 107 | }; | |
| 108 | } | |
| 109 | ||
| 110 | function historyLogName(threadId: string): string { | |
| 111 | return `terminal_${Encoding.encodeBase64Url(threadId)}.log`; | |
| 112 | } | |
| 113 | ||
| 114 | function multiTerminalHistoryLogName(threadId: string, terminalId: string): string { | |
| 115 | const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; | |
| 116 | if (terminalId === DEFAULT_TERMINAL_ID) { | |
| 117 | return `${threadPart}.log`; | |
| 118 | } | |
| 119 | return `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`; | |
| 120 | } | |
| 121 | ||
| 122 | function historyLogPath(logsDir: string, threadId = "thread-1"): string { | |
| 123 | return path.join(logsDir, historyLogName(threadId)); | |
| 124 | } | |
| 125 | ||
| 126 | function multiTerminalHistoryLogPath( | |
| 127 | logsDir: string, | |
| 128 | threadId = "thread-1", | |
| 129 | terminalId = "default", | |
| 130 | ): string { | |
| 131 | return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId)); | |
| 132 | } | |
| 133 | ||
| 134 | describe("TerminalManager", () => { | |
| 135 | const tempDirs: string[] = []; | |
| 136 | ||
| 137 | afterEach(() => { | |
| 138 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 139 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 140 | } | |
| 141 | }); | |
| 142 | ||
| 143 | function makeManager( | |
| 144 | historyLineLimit = 5, | |
| 145 | options: { | |
| 146 | shellResolver?: () => string; | |
| 147 | subprocessChecker?: (terminalPid: number) => Promise<boolean>; | |
| 148 | subprocessPollIntervalMs?: number; | |
| 149 | } = {}, | |
| 150 | ) { | |
| 151 | const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-")); | |
| 152 | tempDirs.push(logsDir); | |
| 153 | const ptyAdapter = new FakePtyAdapter(); | |
| 154 | const manager = new TerminalManagerRuntime({ | |
| 155 | logsDir, | |
| 156 | ptyAdapter, | |
| 157 | historyLineLimit, | |
| 158 | shellResolver: options.shellResolver ?? (() => "/bin/bash"), | |
| 159 | ...(options.subprocessChecker ? { subprocessChecker: options.subprocessChecker } : {}), | |
| 160 | ...(options.subprocessPollIntervalMs | |
| 161 | ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } | |
| 162 | : {}), | |
| 163 | }); | |
| 164 | return { logsDir, ptyAdapter, manager }; | |
| 165 | } | |
| 166 | ||
| 167 | it("spawns lazily and reuses running terminal per thread", async () => { | |
| 168 | const { manager, ptyAdapter } = makeManager(); | |
| 169 | const [first, second] = await Promise.all([ | |
| 170 | manager.open(openInput()), | |
| 171 | manager.open(openInput()), | |
| 172 | ]); | |
| 173 | const third = await manager.open(openInput()); | |
| 174 | ||
| 175 | expect(first.threadId).toBe("thread-1"); | |
| 176 | expect(first.terminalId).toBe("default"); | |
| 177 | expect(second.threadId).toBe("thread-1"); | |
| 178 | expect(third.threadId).toBe("thread-1"); | |
| 179 | expect(ptyAdapter.spawnInputs).toHaveLength(1); | |
| 180 | ||
| 181 | manager.dispose(); | |
| 182 | }); | |
| 183 | ||
| 184 | it("forwards write and resize to active pty process", async () => { | |
| 185 | const { manager, ptyAdapter } = makeManager(); | |
| 186 | await manager.open(openInput()); | |
| 187 | const process = ptyAdapter.processes[0]; | |
| 188 | expect(process).toBeDefined(); | |
| 189 | if (!process) return; | |
| 190 | ||
| 191 | await manager.write({ threadId: "thread-1", data: "ls\n" }); | |
| 192 | await manager.resize({ threadId: "thread-1", cols: 120, rows: 30 }); | |
| 193 | ||
| 194 | expect(process.writes).toEqual(["ls\n"]); | |
| 195 | expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); | |
| 196 | ||
| 197 | manager.dispose(); | |
| 198 | }); | |
| 199 | ||
| 200 | it("resizes running terminal on open when a different size is requested", async () => { | |
| 201 | const { manager, ptyAdapter } = makeManager(); | |
| 202 | await manager.open(openInput({ cols: 100, rows: 24 })); | |
| 203 | const process = ptyAdapter.processes[0]; | |
| 204 | expect(process).toBeDefined(); | |
| 205 | if (!process) return; | |
| 206 | ||
| 207 | await manager.open(openInput({ cols: 140, rows: 40 })); | |
| 208 | ||
| 209 | expect(process.resizeCalls).toEqual([{ cols: 140, rows: 40 }]); | |
| 210 | ||
| 211 | manager.dispose(); | |
| 212 | }); | |
| 213 | ||
| 214 | it("preserves existing terminal size on open when size is omitted", async () => { | |
| 215 | const { manager, ptyAdapter } = makeManager(); | |
| 216 | await manager.open(openInput({ cols: 100, rows: 24 })); | |
| 217 | const ptyProcess = ptyAdapter.processes[0]; | |
| 218 | expect(ptyProcess).toBeDefined(); | |
| 219 | if (!ptyProcess) return; | |
| 220 | ||
| 221 | await manager.open({ | |
| 222 | threadId: "thread-1", | |
| 223 | cwd: globalThis.process.cwd(), | |
| 224 | }); | |
| 225 | ||
| 226 | expect(ptyProcess.resizeCalls).toEqual([]); | |
| 227 | ||
| 228 | ptyProcess.emitExit({ exitCode: 0, signal: 0 }); | |
| 229 | await manager.open({ | |
| 230 | threadId: "thread-1", | |
| 231 | cwd: globalThis.process.cwd(), | |
| 232 | }); | |
| 233 | ||
| 234 | const resumedSpawn = ptyAdapter.spawnInputs[1]; | |
| 235 | expect(resumedSpawn).toBeDefined(); | |
| 236 | if (!resumedSpawn) return; | |
| 237 | expect(resumedSpawn.cols).toBe(100); | |
| 238 | expect(resumedSpawn.rows).toBe(24); | |
| 239 | ||
| 240 | manager.dispose(); | |
| 241 | }); | |
| 242 | ||
| 243 | it("uses default dimensions when opening a new terminal without size hints", async () => { | |
| 244 | const { manager, ptyAdapter } = makeManager(); | |
| 245 | await manager.open({ | |
| 246 | threadId: "thread-1", | |
| 247 | cwd: process.cwd(), | |
| 248 | }); | |
| 249 | ||
| 250 | const spawned = ptyAdapter.spawnInputs[0]; | |
| 251 | expect(spawned).toBeDefined(); | |
| 252 | if (!spawned) return; | |
| 253 | expect(spawned.cols).toBe(120); | |
| 254 | expect(spawned.rows).toBe(30); | |
| 255 | ||
| 256 | manager.dispose(); | |
| 257 | }); | |
| 258 | ||
| 259 | it("supports multiple terminals per thread with isolated sessions", async () => { | |
| 260 | const { manager, ptyAdapter } = makeManager(); | |
| 261 | await manager.open(openInput({ terminalId: "default" })); | |
| 262 | await manager.open(openInput({ terminalId: "term-2" })); | |
| 263 | ||
| 264 | const first = ptyAdapter.processes[0]; | |
| 265 | const second = ptyAdapter.processes[1]; | |
| 266 | expect(first).toBeDefined(); | |
| 267 | expect(second).toBeDefined(); | |
| 268 | if (!first || !second) return; | |
| 269 | ||
| 270 | await manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); | |
| 271 | await manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); | |
| 272 | ||
| 273 | expect(first.writes).toEqual(["pwd\n"]); | |
| 274 | expect(second.writes).toEqual(["ls\n"]); | |
| 275 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | |
| 276 | ||
| 277 | manager.dispose(); | |
| 278 | }); | |
| 279 | ||
| 280 | it("clears transcript and emits cleared event", async () => { | |
| 281 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 282 | const events: TerminalEvent[] = []; | |
| 283 | manager.on("event", (event) => { | |
| 284 | events.push(event); | |
| 285 | }); | |
| 286 | await manager.open(openInput()); | |
| 287 | const process = ptyAdapter.processes[0]; | |
| 288 | expect(process).toBeDefined(); | |
| 289 | if (!process) return; | |
| 290 | ||
| 291 | process.emitData("hello\n"); | |
| 292 | await waitFor(() => fs.existsSync(historyLogPath(logsDir))); | |
| 293 | await manager.clear({ threadId: "thread-1" }); | |
| 294 | await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); | |
| 295 | ||
| 296 | expect(events.some((event) => event.type === "cleared")).toBe(true); | |
| 297 | expect( | |
| 298 | events.some( | |
| 299 | (event) => | |
| 300 | event.type === "cleared" && | |
| 301 | event.threadId === "thread-1" && | |
| 302 | event.terminalId === "default", | |
| 303 | ), | |
| 304 | ).toBe(true); | |
| 305 | ||
| 306 | manager.dispose(); | |
| 307 | }); | |
| 308 | ||
| 309 | it("restarts terminal with empty transcript and respawns pty", async () => { | |
| 310 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 311 | await manager.open(openInput()); | |
| 312 | const firstProcess = ptyAdapter.processes[0]; | |
| 313 | expect(firstProcess).toBeDefined(); | |
| 314 | if (!firstProcess) return; | |
| 315 | firstProcess.emitData("before restart\n"); | |
| 316 | await waitFor(() => fs.existsSync(historyLogPath(logsDir))); | |
| 317 | ||
| 318 | const snapshot = await manager.restart(openInput()); | |
| 319 | expect(snapshot.history).toBe(""); | |
| 320 | expect(snapshot.status).toBe("running"); | |
| 321 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | |
| 322 | await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); | |
| 323 | ||
| 324 | manager.dispose(); | |
| 325 | }); | |
| 326 | ||
| 327 | it("emits exited event and reopens with clean transcript after exit", async () => { | |
| 328 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 329 | const events: TerminalEvent[] = []; | |
| 330 | manager.on("event", (event) => { | |
| 331 | events.push(event); | |
| 332 | }); | |
| 333 | await manager.open(openInput()); | |
| 334 | const process = ptyAdapter.processes[0]; | |
| 335 | expect(process).toBeDefined(); | |
| 336 | if (!process) return; | |
| 337 | process.emitData("old data\n"); | |
| 338 | await waitFor(() => fs.existsSync(historyLogPath(logsDir))); | |
| 339 | process.emitExit({ exitCode: 0, signal: 0 }); | |
| 340 | ||
| 341 | await waitFor(() => events.some((event) => event.type === "exited")); | |
| 342 | const reopened = await manager.open(openInput()); | |
| 343 | ||
| 344 | expect(reopened.history).toBe(""); | |
| 345 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | |
| 346 | expect(fs.readFileSync(historyLogPath(logsDir), "utf8")).toBe(""); | |
| 347 | ||
| 348 | manager.dispose(); | |
| 349 | }); | |
| 350 | ||
| 351 | it("emits subprocess activity events when child-process state changes", async () => { | |
| 352 | let hasRunningSubprocess = false; | |
| 353 | const { manager } = makeManager(5, { | |
| 354 | subprocessChecker: async () => hasRunningSubprocess, | |
| 355 | subprocessPollIntervalMs: 20, | |
| 356 | }); | |
| 357 | const events: TerminalEvent[] = []; | |
| 358 | manager.on("event", (event) => { | |
| 359 | events.push(event); | |
| 360 | }); | |
| 361 | ||
| 362 | await manager.open(openInput()); | |
| 363 | await waitFor(() => events.some((event) => event.type === "started")); | |
| 364 | expect(events.some((event) => event.type === "activity")).toBe(false); | |
| 365 | ||
| 366 | hasRunningSubprocess = true; | |
| 367 | await waitFor( | |
| 368 | () => | |
| 369 | events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true), | |
| 370 | 1_200, | |
| 371 | ); | |
| 372 | ||
| 373 | hasRunningSubprocess = false; | |
| 374 | await waitFor( | |
| 375 | () => | |
| 376 | events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false), | |
| 377 | 1_200, | |
| 378 | ); | |
| 379 | ||
| 380 | manager.dispose(); | |
| 381 | }); | |
| 382 | ||
| 383 | it("caps persisted history to configured line limit", async () => { | |
| 384 | const { manager, ptyAdapter } = makeManager(3); | |
| 385 | await manager.open(openInput()); | |
| 386 | const process = ptyAdapter.processes[0]; | |
| 387 | expect(process).toBeDefined(); | |
| 388 | if (!process) return; | |
| 389 | ||
| 390 | process.emitData("line1\nline2\nline3\nline4\n"); | |
| 391 | await manager.close({ threadId: "thread-1" }); | |
| 392 | ||
| 393 | const reopened = await manager.open(openInput()); | |
| 394 | const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0); | |
| 395 | expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]); | |
| 396 | ||
| 397 | manager.dispose(); | |
| 398 | }); | |
| 399 | ||
| 400 | it("deletes history file when close(deleteHistory=true)", async () => { | |
| 401 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 402 | await manager.open(openInput()); | |
| 403 | const process = ptyAdapter.processes[0]; | |
| 404 | expect(process).toBeDefined(); | |
| 405 | if (!process) return; | |
| 406 | process.emitData("bye\n"); | |
| 407 | await waitFor(() => fs.existsSync(historyLogPath(logsDir))); | |
| 408 | ||
| 409 | await manager.close({ threadId: "thread-1", deleteHistory: true }); | |
| 410 | expect(fs.existsSync(historyLogPath(logsDir))).toBe(false); | |
| 411 | ||
| 412 | manager.dispose(); | |
| 413 | }); | |
| 414 | ||
| 415 | it("closes all terminals for a thread when close omits terminalId", async () => { | |
| 416 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 417 | await manager.open(openInput({ terminalId: "default" })); | |
| 418 | await manager.open(openInput({ terminalId: "sidecar" })); | |
| 419 | const defaultProcess = ptyAdapter.processes[0]; | |
| 420 | const sidecarProcess = ptyAdapter.processes[1]; | |
| 421 | expect(defaultProcess).toBeDefined(); | |
| 422 | expect(sidecarProcess).toBeDefined(); | |
| 423 | if (!defaultProcess || !sidecarProcess) return; | |
| 424 | ||
| 425 | defaultProcess.emitData("default\n"); | |
| 426 | sidecarProcess.emitData("sidecar\n"); | |
| 427 | await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))); | |
| 428 | await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))); | |
| 429 | ||
| 430 | await manager.close({ threadId: "thread-1", deleteHistory: true }); | |
| 431 | ||
| 432 | expect(defaultProcess.killed).toBe(true); | |
| 433 | expect(sidecarProcess.killed).toBe(true); | |
| 434 | expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))).toBe(false); | |
| 435 | expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))).toBe(false); | |
| 436 | ||
| 437 | manager.dispose(); | |
| 438 | }); | |
| 439 | ||
| 440 | it("migrates legacy transcript filenames to terminal-scoped history path on open", async () => { | |
| 441 | const { manager, logsDir } = makeManager(); | |
| 442 | const legacyPath = path.join(logsDir, "thread-1.log"); | |
| 443 | const nextPath = historyLogPath(logsDir); | |
| 444 | fs.writeFileSync(legacyPath, "legacy-line\n", "utf8"); | |
| 445 | ||
| 446 | const snapshot = await manager.open(openInput()); | |
| 447 | ||
| 448 | expect(snapshot.history).toBe("legacy-line\n"); | |
| 449 | expect(fs.existsSync(nextPath)).toBe(true); | |
| 450 | expect(fs.readFileSync(nextPath, "utf8")).toBe("legacy-line\n"); | |
| 451 | expect(fs.existsSync(legacyPath)).toBe(false); | |
| 452 | ||
| 453 | manager.dispose(); | |
| 454 | }); | |
| 455 | ||
| 456 | it("retries with fallback shells when preferred shell spawn fails", async () => { | |
| 457 | const { manager, ptyAdapter } = makeManager(5, { | |
| 458 | shellResolver: () => "/definitely/missing-shell -l", | |
| 459 | }); | |
| 460 | ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed.")); | |
| 461 | ||
| 462 | const snapshot = await manager.open(openInput()); | |
| 463 | ||
| 464 | expect(snapshot.status).toBe("running"); | |
| 465 | expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); | |
| 466 | expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell"); | |
| 467 | ||
| 468 | if (process.platform === "win32") { | |
| 469 | expect( | |
| 470 | ptyAdapter.spawnInputs.some( | |
| 471 | (input) => input.shell === "cmd.exe" || input.shell === "powershell.exe", | |
| 472 | ), | |
| 473 | ).toBe(true); | |
| 474 | } else { | |
| 475 | expect( | |
| 476 | ptyAdapter.spawnInputs.some((input) => | |
| 477 | ["/bin/zsh", "/bin/bash", "/bin/sh", "zsh", "bash", "sh"].includes(input.shell), | |
| 478 | ), | |
| 479 | ).toBe(true); | |
| 480 | } | |
| 481 | ||
| 482 | manager.dispose(); | |
| 483 | }); | |
| 484 | ||
| 485 | it("filters app runtime env variables from terminal sessions", async () => { | |
| 486 | const originalValues = new Map<string, string | undefined>(); | |
| 487 | const setEnv = (key: string, value: string | undefined) => { | |
| 488 | if (!originalValues.has(key)) { | |
| 489 | originalValues.set(key, process.env[key]); | |
| 490 | } | |
| 491 | if (value === undefined) { | |
| 492 | delete process.env[key]; | |
| 493 | return; | |
| 494 | } | |
| 495 | process.env[key] = value; | |
| 496 | }; | |
| 497 | const restoreEnv = () => { | |
| 498 | for (const [key, value] of originalValues) { | |
| 499 | if (value === undefined) { | |
| 500 | delete process.env[key]; | |
| 501 | } else { | |
| 502 | process.env[key] = value; | |
| 503 | } | |
| 504 | } | |
| 505 | }; | |
| 506 | ||
| 507 | setEnv("PORT", "5173"); | |
| 508 | setEnv("T3CODE_PORT", "3773"); | |
| 509 | setEnv("VITE_DEV_SERVER_URL", "http://localhost:5173"); | |
| 510 | setEnv("TEST_TERMINAL_KEEP", "keep-me"); | |
| 511 | ||
| 512 | try { | |
| 513 | const { manager, ptyAdapter } = makeManager(); | |
| 514 | await manager.open(openInput()); | |
| 515 | const spawnInput = ptyAdapter.spawnInputs[0]; | |
| 516 | expect(spawnInput).toBeDefined(); | |
| 517 | if (!spawnInput) return; | |
| 518 | ||
| 519 | expect(spawnInput.env.PORT).toBeUndefined(); | |
| 520 | expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); | |
| 521 | expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); | |
| 522 | expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); | |
| 523 | ||
| 524 | manager.dispose(); | |
| 525 | } finally { | |
| 526 | restoreEnv(); | |
| 527 | } | |
| 528 | }); | |
| 529 | ||
| 530 | it("injects runtime env overrides into spawned terminals", async () => { | |
| 531 | const { manager, ptyAdapter } = makeManager(); | |
| 532 | await manager.open( | |
| 533 | openInput({ | |
| 534 | env: { | |
| 535 | T3CODE_PROJECT_ROOT: "/repo", | |
| 536 | T3CODE_WORKTREE_PATH: "/repo/worktree-a", | |
| 537 | CUSTOM_FLAG: "1", | |
| 538 | }, | |
| 539 | }), | |
| 540 | ); | |
| 541 | const spawnInput = ptyAdapter.spawnInputs[0]; | |
| 542 | expect(spawnInput).toBeDefined(); | |
| 543 | if (!spawnInput) return; | |
| 544 | ||
| 545 | expect(spawnInput.env.T3CODE_PROJECT_ROOT).toBe("/repo"); | |
| 546 | expect(spawnInput.env.T3CODE_WORKTREE_PATH).toBe("/repo/worktree-a"); | |
| 547 | expect(spawnInput.env.CUSTOM_FLAG).toBe("1"); | |
| 548 | ||
| 549 | manager.dispose(); | |
| 550 | }); | |
| 551 | ||
| 552 | it("starts zsh with prompt spacer disabled to avoid `%` end markers", async () => { | |
| 553 | if (process.platform === "win32") return; | |
| 554 | const { manager, ptyAdapter } = makeManager(5, { | |
| 555 | shellResolver: () => "/bin/zsh", | |
| 556 | }); | |
| 557 | await manager.open(openInput()); | |
| 558 | const spawnInput = ptyAdapter.spawnInputs[0]; | |
| 559 | expect(spawnInput).toBeDefined(); | |
| 560 | if (!spawnInput) return; | |
| 561 | ||
| 562 | expect(spawnInput.shell).toBe("/bin/zsh"); | |
| 563 | expect(spawnInput.args).toEqual(["-o", "nopromptsp"]); | |
| 564 | ||
| 565 | manager.dispose(); | |
| 566 | }); | |
| 567 | }); |
apps/server/src/terminalManager.ts deleted-1095| ... | ... | @@ -1,1095 +0,0 @@ |
| 1 | import { EventEmitter } from "node:events"; | |
| 2 | import fs from "node:fs"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { | |
| 6 | DEFAULT_TERMINAL_ID, | |
| 7 | TerminalClearInput, | |
| 8 | TerminalCloseInput, | |
| 9 | TerminalOpenInput, | |
| 10 | TerminalResizeInput, | |
| 11 | TerminalWriteInput, | |
| 12 | type TerminalEvent, | |
| 13 | type TerminalSessionSnapshot, | |
| 14 | type TerminalSessionStatus, | |
| 15 | } from "@t3tools/contracts"; | |
| 16 | import { Effect, Encoding, Layer, Schema, ServiceMap } from "effect"; | |
| 17 | ||
| 18 | import { createLogger } from "./logger"; | |
| 19 | import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; | |
| 20 | import { runProcess } from "./processRunner"; | |
| 21 | ||
| 22 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; | |
| 23 | const DEFAULT_PERSIST_DEBOUNCE_MS = 40; | |
| 24 | const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; | |
| 25 | const DEFAULT_OPEN_COLS = 120; | |
| 26 | const DEFAULT_OPEN_ROWS = 30; | |
| 27 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); | |
| 28 | ||
| 29 | const decodeTerminalOpenInput = Schema.decodeUnknownSync(TerminalOpenInput); | |
| 30 | const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput); | |
| 31 | const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput); | |
| 32 | const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput); | |
| 33 | const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput); | |
| 34 | ||
| 35 | type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>; | |
| 36 | ||
| 37 | export interface TerminalManagerEvents { | |
| 38 | event: [event: TerminalEvent]; | |
| 39 | } | |
| 40 | ||
| 41 | export interface TerminalManagerOptions { | |
| 42 | logsDir?: string; | |
| 43 | historyLineLimit?: number; | |
| 44 | ptyAdapter: PtyAdapterShape; | |
| 45 | shellResolver?: () => string; | |
| 46 | subprocessChecker?: TerminalSubprocessChecker; | |
| 47 | subprocessPollIntervalMs?: number; | |
| 48 | } | |
| 49 | ||
| 50 | export interface TerminalManagerShape { | |
| 51 | readonly open: (input: TerminalOpenInput) => Effect.Effect<TerminalSessionSnapshot, unknown>; | |
| 52 | readonly write: (input: TerminalWriteInput) => Effect.Effect<void, unknown>; | |
| 53 | readonly resize: (input: TerminalResizeInput) => Effect.Effect<void, unknown>; | |
| 54 | readonly clear: (input: TerminalClearInput) => Effect.Effect<void, unknown>; | |
| 55 | readonly restart: (input: TerminalOpenInput) => Effect.Effect<TerminalSessionSnapshot, unknown>; | |
| 56 | readonly close: (input: TerminalCloseInput) => Effect.Effect<void, unknown>; | |
| 57 | readonly subscribe: (listener: (event: TerminalEvent) => void) => Effect.Effect<() => void>; | |
| 58 | readonly dispose: () => Effect.Effect<void>; | |
| 59 | } | |
| 60 | ||
| 61 | export class TerminalManager extends ServiceMap.Service<TerminalManager, TerminalManagerShape>()( | |
| 62 | "terminal/TerminalManager", | |
| 63 | ) {} | |
| 64 | ||
| 65 | interface TerminalSessionState { | |
| 66 | threadId: string; | |
| 67 | terminalId: string; | |
| 68 | cwd: string; | |
| 69 | status: TerminalSessionStatus; | |
| 70 | pid: number | null; | |
| 71 | history: string; | |
| 72 | exitCode: number | null; | |
| 73 | exitSignal: number | null; | |
| 74 | updatedAt: string; | |
| 75 | cols: number; | |
| 76 | rows: number; | |
| 77 | process: PtyProcess | null; | |
| 78 | unsubscribeData: (() => void) | null; | |
| 79 | unsubscribeExit: (() => void) | null; | |
| 80 | hasRunningSubprocess: boolean; | |
| 81 | runtimeEnv: Record<string, string> | null; | |
| 82 | } | |
| 83 | ||
| 84 | interface ShellCandidate { | |
| 85 | shell: string; | |
| 86 | args?: string[]; | |
| 87 | } | |
| 88 | ||
| 89 | interface TerminalStartInput extends TerminalOpenInput { | |
| 90 | cols: number; | |
| 91 | rows: number; | |
| 92 | } | |
| 93 | ||
| 94 | function defaultShellResolver(): string { | |
| 95 | if (process.platform === "win32") { | |
| 96 | return process.env.ComSpec ?? "cmd.exe"; | |
| 97 | } | |
| 98 | return process.env.SHELL ?? "bash"; | |
| 99 | } | |
| 100 | ||
| 101 | function normalizeShellCommand(value: string | undefined): string | null { | |
| 102 | if (!value) return null; | |
| 103 | const trimmed = value.trim(); | |
| 104 | if (trimmed.length === 0) return null; | |
| 105 | ||
| 106 | if (process.platform === "win32") { | |
| 107 | return trimmed; | |
| 108 | } | |
| 109 | ||
| 110 | const firstToken = trimmed.split(/\s+/g)[0]?.trim(); | |
| 111 | if (!firstToken) return null; | |
| 112 | return firstToken.replace(/^['"]|['"]$/g, ""); | |
| 113 | } | |
| 114 | ||
| 115 | function shellCandidateFromCommand(command: string | null): ShellCandidate | null { | |
| 116 | if (!command || command.length === 0) return null; | |
| 117 | const shellName = path.basename(command).toLowerCase(); | |
| 118 | if (process.platform !== "win32" && shellName === "zsh") { | |
| 119 | return { shell: command, args: ["-o", "nopromptsp"] }; | |
| 120 | } | |
| 121 | return { shell: command }; | |
| 122 | } | |
| 123 | ||
| 124 | function formatShellCandidate(candidate: ShellCandidate): string { | |
| 125 | if (!candidate.args || candidate.args.length === 0) return candidate.shell; | |
| 126 | return `${candidate.shell} ${candidate.args.join(" ")}`; | |
| 127 | } | |
| 128 | ||
| 129 | function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellCandidate[] { | |
| 130 | const seen = new Set<string>(); | |
| 131 | const ordered: ShellCandidate[] = []; | |
| 132 | for (const candidate of candidates) { | |
| 133 | if (!candidate) continue; | |
| 134 | const key = formatShellCandidate(candidate); | |
| 135 | if (seen.has(key)) continue; | |
| 136 | seen.add(key); | |
| 137 | ordered.push(candidate); | |
| 138 | } | |
| 139 | return ordered; | |
| 140 | } | |
| 141 | ||
| 142 | function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] { | |
| 143 | const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver())); | |
| 144 | ||
| 145 | if (process.platform === "win32") { | |
| 146 | return uniqueShellCandidates([ | |
| 147 | requested, | |
| 148 | shellCandidateFromCommand(process.env.ComSpec ?? null), | |
| 149 | shellCandidateFromCommand("powershell.exe"), | |
| 150 | shellCandidateFromCommand("cmd.exe"), | |
| 151 | ]); | |
| 152 | } | |
| 153 | ||
| 154 | return uniqueShellCandidates([ | |
| 155 | requested, | |
| 156 | shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)), | |
| 157 | shellCandidateFromCommand("/bin/zsh"), | |
| 158 | shellCandidateFromCommand("/bin/bash"), | |
| 159 | shellCandidateFromCommand("/bin/sh"), | |
| 160 | shellCandidateFromCommand("zsh"), | |
| 161 | shellCandidateFromCommand("bash"), | |
| 162 | shellCandidateFromCommand("sh"), | |
| 163 | ]); | |
| 164 | } | |
| 165 | ||
| 166 | function isRetryableShellSpawnError(error: unknown): boolean { | |
| 167 | if (!(error instanceof Error)) return false; | |
| 168 | const message = error.message.toLowerCase(); | |
| 169 | return ( | |
| 170 | message.includes("posix_spawnp failed") || | |
| 171 | message.includes("enoent") || | |
| 172 | message.includes("not found") || | |
| 173 | message.includes("file not found") || | |
| 174 | message.includes("no such file") | |
| 175 | ); | |
| 176 | } | |
| 177 | ||
| 178 | async function checkWindowsSubprocessActivity(terminalPid: number): Promise<boolean> { | |
| 179 | const command = [ | |
| 180 | `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, | |
| 181 | "if ($children) { exit 0 }", | |
| 182 | "exit 1", | |
| 183 | ].join("; "); | |
| 184 | try { | |
| 185 | const result = await runProcess( | |
| 186 | "powershell.exe", | |
| 187 | ["-NoProfile", "-NonInteractive", "-Command", command], | |
| 188 | { | |
| 189 | timeoutMs: 1_500, | |
| 190 | allowNonZeroExit: true, | |
| 191 | maxBufferBytes: 32_768, | |
| 192 | outputMode: "truncate", | |
| 193 | }, | |
| 194 | ); | |
| 195 | return result.code === 0; | |
| 196 | } catch { | |
| 197 | return false; | |
| 198 | } | |
| 199 | } | |
| 200 | ||
| 201 | async function checkPosixSubprocessActivity(terminalPid: number): Promise<boolean> { | |
| 202 | try { | |
| 203 | const pgrepResult = await runProcess("pgrep", ["-P", String(terminalPid)], { | |
| 204 | timeoutMs: 1_000, | |
| 205 | allowNonZeroExit: true, | |
| 206 | maxBufferBytes: 32_768, | |
| 207 | outputMode: "truncate", | |
| 208 | }); | |
| 209 | if (pgrepResult.code === 0) { | |
| 210 | return pgrepResult.stdout.trim().length > 0; | |
| 211 | } | |
| 212 | if (pgrepResult.code === 1) { | |
| 213 | return false; | |
| 214 | } | |
| 215 | } catch { | |
| 216 | // Fall back to ps when pgrep is unavailable. | |
| 217 | } | |
| 218 | ||
| 219 | try { | |
| 220 | const psResult = await runProcess("ps", ["-eo", "pid=,ppid="], { | |
| 221 | timeoutMs: 1_000, | |
| 222 | allowNonZeroExit: true, | |
| 223 | maxBufferBytes: 262_144, | |
| 224 | outputMode: "truncate", | |
| 225 | }); | |
| 226 | if (psResult.code !== 0) { | |
| 227 | return false; | |
| 228 | } | |
| 229 | ||
| 230 | for (const line of psResult.stdout.split(/\r?\n/g)) { | |
| 231 | const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); | |
| 232 | const pid = Number(pidRaw); | |
| 233 | const ppid = Number(ppidRaw); | |
| 234 | if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; | |
| 235 | if (ppid === terminalPid) { | |
| 236 | return true; | |
| 237 | } | |
| 238 | } | |
| 239 | return false; | |
| 240 | } catch { | |
| 241 | return false; | |
| 242 | } | |
| 243 | } | |
| 244 | ||
| 245 | async function defaultSubprocessChecker(terminalPid: number): Promise<boolean> { | |
| 246 | if (!Number.isInteger(terminalPid) || terminalPid <= 0) { | |
| 247 | return false; | |
| 248 | } | |
| 249 | if (process.platform === "win32") { | |
| 250 | return checkWindowsSubprocessActivity(terminalPid); | |
| 251 | } | |
| 252 | return checkPosixSubprocessActivity(terminalPid); | |
| 253 | } | |
| 254 | ||
| 255 | function capHistory(history: string, maxLines: number): string { | |
| 256 | if (history.length === 0) return history; | |
| 257 | const hasTrailingNewline = history.endsWith("\n"); | |
| 258 | const lines = history.split("\n"); | |
| 259 | if (hasTrailingNewline) { | |
| 260 | lines.pop(); | |
| 261 | } | |
| 262 | if (lines.length <= maxLines) return history; | |
| 263 | const capped = lines.slice(lines.length - maxLines).join("\n"); | |
| 264 | return hasTrailingNewline ? `${capped}\n` : capped; | |
| 265 | } | |
| 266 | ||
| 267 | function legacySafeThreadId(threadId: string): string { | |
| 268 | return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); | |
| 269 | } | |
| 270 | ||
| 271 | function toSafeThreadId(threadId: string): string { | |
| 272 | return `terminal_${Encoding.encodeBase64Url(threadId)}`; | |
| 273 | } | |
| 274 | ||
| 275 | function toSafeTerminalId(terminalId: string): string { | |
| 276 | return Encoding.encodeBase64Url(terminalId); | |
| 277 | } | |
| 278 | ||
| 279 | function toSessionKey(threadId: string, terminalId: string): string { | |
| 280 | return `${threadId}\u0000${terminalId}`; | |
| 281 | } | |
| 282 | ||
| 283 | function shouldExcludeTerminalEnvKey(key: string): boolean { | |
| 284 | const normalizedKey = key.toUpperCase(); | |
| 285 | if (normalizedKey.startsWith("T3CODE_")) { | |
| 286 | return true; | |
| 287 | } | |
| 288 | if (normalizedKey.startsWith("VITE_")) { | |
| 289 | return true; | |
| 290 | } | |
| 291 | return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); | |
| 292 | } | |
| 293 | ||
| 294 | function createTerminalSpawnEnv( | |
| 295 | baseEnv: NodeJS.ProcessEnv, | |
| 296 | runtimeEnv?: Record<string, string> | null, | |
| 297 | ): NodeJS.ProcessEnv { | |
| 298 | const spawnEnv: NodeJS.ProcessEnv = {}; | |
| 299 | for (const [key, value] of Object.entries(baseEnv)) { | |
| 300 | if (value === undefined) continue; | |
| 301 | if (shouldExcludeTerminalEnvKey(key)) continue; | |
| 302 | spawnEnv[key] = value; | |
| 303 | } | |
| 304 | if (runtimeEnv) { | |
| 305 | for (const [key, value] of Object.entries(runtimeEnv)) { | |
| 306 | spawnEnv[key] = value; | |
| 307 | } | |
| 308 | } | |
| 309 | return spawnEnv; | |
| 310 | } | |
| 311 | ||
| 312 | function normalizedRuntimeEnv( | |
| 313 | env: Record<string, string> | undefined, | |
| 314 | ): Record<string, string> | null { | |
| 315 | if (!env) return null; | |
| 316 | const entries = Object.entries(env); | |
| 317 | if (entries.length === 0) return null; | |
| 318 | return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right))); | |
| 319 | } | |
| 320 | ||
| 321 | export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> { | |
| 322 | private readonly sessions = new Map<string, TerminalSessionState>(); | |
| 323 | private readonly logsDir: string; | |
| 324 | private readonly historyLineLimit: number; | |
| 325 | private readonly ptyAdapter: PtyAdapterShape; | |
| 326 | private readonly shellResolver: () => string; | |
| 327 | private readonly persistQueues = new Map<string, Promise<void>>(); | |
| 328 | private readonly persistTimers = new Map<string, ReturnType<typeof setTimeout>>(); | |
| 329 | private readonly pendingPersistHistory = new Map<string, string>(); | |
| 330 | private readonly threadLocks = new Map<string, Promise<void>>(); | |
| 331 | private readonly persistDebounceMs: number; | |
| 332 | private readonly subprocessChecker: TerminalSubprocessChecker; | |
| 333 | private readonly subprocessPollIntervalMs: number; | |
| 334 | private subprocessPollTimer: ReturnType<typeof setInterval> | null = null; | |
| 335 | private subprocessPollInFlight = false; | |
| 336 | private readonly logger = createLogger("terminal"); | |
| 337 | ||
| 338 | constructor(options: TerminalManagerOptions) { | |
| 339 | super(); | |
| 340 | this.logsDir = options.logsDir ?? path.resolve(process.cwd(), ".logs", "terminals"); | |
| 341 | this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; | |
| 342 | this.ptyAdapter = options.ptyAdapter; | |
| 343 | this.shellResolver = options.shellResolver ?? defaultShellResolver; | |
| 344 | this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; | |
| 345 | this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; | |
| 346 | this.subprocessPollIntervalMs = | |
| 347 | options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; | |
| 348 | fs.mkdirSync(this.logsDir, { recursive: true }); | |
| 349 | } | |
| 350 | ||
| 351 | async open(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> { | |
| 352 | const input = decodeTerminalOpenInput(raw); | |
| 353 | return this.runWithThreadLock(input.threadId, async () => { | |
| 354 | await this.assertValidCwd(input.cwd); | |
| 355 | ||
| 356 | const sessionKey = toSessionKey(input.threadId, input.terminalId); | |
| 357 | const existing = this.sessions.get(sessionKey); | |
| 358 | if (!existing) { | |
| 359 | await this.flushPersistQueue(input.threadId, input.terminalId); | |
| 360 | const history = await this.readHistory(input.threadId, input.terminalId); | |
| 361 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | |
| 362 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | |
| 363 | const session: TerminalSessionState = { | |
| 364 | threadId: input.threadId, | |
| 365 | terminalId: input.terminalId, | |
| 366 | cwd: input.cwd, | |
| 367 | status: "starting", | |
| 368 | pid: null, | |
| 369 | history, | |
| 370 | exitCode: null, | |
| 371 | exitSignal: null, | |
| 372 | updatedAt: new Date().toISOString(), | |
| 373 | cols, | |
| 374 | rows, | |
| 375 | process: null, | |
| 376 | unsubscribeData: null, | |
| 377 | unsubscribeExit: null, | |
| 378 | hasRunningSubprocess: false, | |
| 379 | runtimeEnv: normalizedRuntimeEnv(input.env), | |
| 380 | }; | |
| 381 | this.sessions.set(sessionKey, session); | |
| 382 | this.startSession(session, { ...input, cols, rows }, "started"); | |
| 383 | return this.snapshot(session); | |
| 384 | } | |
| 385 | ||
| 386 | const nextRuntimeEnv = normalizedRuntimeEnv(input.env); | |
| 387 | const currentRuntimeEnv = existing.runtimeEnv; | |
| 388 | const targetCols = input.cols ?? existing.cols; | |
| 389 | const targetRows = input.rows ?? existing.rows; | |
| 390 | const runtimeEnvChanged = | |
| 391 | JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv); | |
| 392 | ||
| 393 | if (existing.cwd !== input.cwd || runtimeEnvChanged) { | |
| 394 | this.stopProcess(existing); | |
| 395 | existing.cwd = input.cwd; | |
| 396 | existing.runtimeEnv = nextRuntimeEnv; | |
| 397 | existing.history = ""; | |
| 398 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); | |
| 399 | } else if (existing.status === "exited" || existing.status === "error") { | |
| 400 | existing.runtimeEnv = nextRuntimeEnv; | |
| 401 | existing.history = ""; | |
| 402 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); | |
| 403 | } else if (currentRuntimeEnv !== nextRuntimeEnv) { | |
| 404 | existing.runtimeEnv = nextRuntimeEnv; | |
| 405 | } | |
| 406 | ||
| 407 | if (!existing.process) { | |
| 408 | this.startSession(existing, { ...input, cols: targetCols, rows: targetRows }, "started"); | |
| 409 | return this.snapshot(existing); | |
| 410 | } | |
| 411 | ||
| 412 | if (existing.cols !== targetCols || existing.rows !== targetRows) { | |
| 413 | existing.cols = targetCols; | |
| 414 | existing.rows = targetRows; | |
| 415 | existing.process.resize(targetCols, targetRows); | |
| 416 | existing.updatedAt = new Date().toISOString(); | |
| 417 | } | |
| 418 | ||
| 419 | return this.snapshot(existing); | |
| 420 | }); | |
| 421 | } | |
| 422 | ||
| 423 | async write(raw: TerminalWriteInput): Promise<void> { | |
| 424 | const input = decodeTerminalWriteInput(raw); | |
| 425 | const session = this.requireSession(input.threadId, input.terminalId); | |
| 426 | if (!session.process || session.status !== "running") { | |
| 427 | throw new Error( | |
| 428 | `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, | |
| 429 | ); | |
| 430 | } | |
| 431 | session.process.write(input.data); | |
| 432 | } | |
| 433 | ||
| 434 | async resize(raw: TerminalResizeInput): Promise<void> { | |
| 435 | const input = decodeTerminalResizeInput(raw); | |
| 436 | const session = this.requireSession(input.threadId, input.terminalId); | |
| 437 | if (!session.process || session.status !== "running") { | |
| 438 | throw new Error( | |
| 439 | `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, | |
| 440 | ); | |
| 441 | } | |
| 442 | session.cols = input.cols; | |
| 443 | session.rows = input.rows; | |
| 444 | session.updatedAt = new Date().toISOString(); | |
| 445 | session.process.resize(input.cols, input.rows); | |
| 446 | } | |
| 447 | ||
| 448 | async clear(raw: TerminalClearInput): Promise<void> { | |
| 449 | const input = decodeTerminalClearInput(raw); | |
| 450 | await this.runWithThreadLock(input.threadId, async () => { | |
| 451 | const session = this.requireSession(input.threadId, input.terminalId); | |
| 452 | session.history = ""; | |
| 453 | session.updatedAt = new Date().toISOString(); | |
| 454 | await this.persistHistory(input.threadId, input.terminalId, session.history); | |
| 455 | this.emitEvent({ | |
| 456 | type: "cleared", | |
| 457 | threadId: input.threadId, | |
| 458 | terminalId: input.terminalId, | |
| 459 | createdAt: new Date().toISOString(), | |
| 460 | }); | |
| 461 | }); | |
| 462 | } | |
| 463 | ||
| 464 | async restart(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> { | |
| 465 | const input = decodeTerminalOpenInput(raw); | |
| 466 | return this.runWithThreadLock(input.threadId, async () => { | |
| 467 | await this.assertValidCwd(input.cwd); | |
| 468 | ||
| 469 | const sessionKey = toSessionKey(input.threadId, input.terminalId); | |
| 470 | let session = this.sessions.get(sessionKey); | |
| 471 | if (!session) { | |
| 472 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | |
| 473 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | |
| 474 | session = { | |
| 475 | threadId: input.threadId, | |
| 476 | terminalId: input.terminalId, | |
| 477 | cwd: input.cwd, | |
| 478 | status: "starting", | |
| 479 | pid: null, | |
| 480 | history: "", | |
| 481 | exitCode: null, | |
| 482 | exitSignal: null, | |
| 483 | updatedAt: new Date().toISOString(), | |
| 484 | cols, | |
| 485 | rows, | |
| 486 | process: null, | |
| 487 | unsubscribeData: null, | |
| 488 | unsubscribeExit: null, | |
| 489 | hasRunningSubprocess: false, | |
| 490 | runtimeEnv: normalizedRuntimeEnv(input.env), | |
| 491 | }; | |
| 492 | this.sessions.set(sessionKey, session); | |
| 493 | } else { | |
| 494 | this.stopProcess(session); | |
| 495 | session.cwd = input.cwd; | |
| 496 | session.runtimeEnv = normalizedRuntimeEnv(input.env); | |
| 497 | } | |
| 498 | ||
| 499 | const cols = input.cols ?? session.cols; | |
| 500 | const rows = input.rows ?? session.rows; | |
| 501 | ||
| 502 | session.history = ""; | |
| 503 | await this.persistHistory(input.threadId, input.terminalId, session.history); | |
| 504 | this.startSession(session, { ...input, cols, rows }, "restarted"); | |
| 505 | return this.snapshot(session); | |
| 506 | }); | |
| 507 | } | |
| 508 | ||
| 509 | async close(raw: TerminalCloseInput): Promise<void> { | |
| 510 | const input = decodeTerminalCloseInput(raw); | |
| 511 | await this.runWithThreadLock(input.threadId, async () => { | |
| 512 | if (input.terminalId) { | |
| 513 | await this.closeSession(input.threadId, input.terminalId, input.deleteHistory === true); | |
| 514 | return; | |
| 515 | } | |
| 516 | ||
| 517 | const threadSessions = this.sessionsForThread(input.threadId); | |
| 518 | for (const session of threadSessions) { | |
| 519 | this.stopProcess(session); | |
| 520 | this.sessions.delete(toSessionKey(session.threadId, session.terminalId)); | |
| 521 | } | |
| 522 | await Promise.all( | |
| 523 | threadSessions.map((session) => | |
| 524 | this.flushPersistQueue(session.threadId, session.terminalId), | |
| 525 | ), | |
| 526 | ); | |
| 527 | ||
| 528 | if (input.deleteHistory) { | |
| 529 | await this.deleteAllHistoryForThread(input.threadId); | |
| 530 | } | |
| 531 | this.updateSubprocessPollingState(); | |
| 532 | }); | |
| 533 | } | |
| 534 | ||
| 535 | dispose(): void { | |
| 536 | this.stopSubprocessPolling(); | |
| 537 | const sessions = [...this.sessions.values()]; | |
| 538 | this.sessions.clear(); | |
| 539 | for (const session of sessions) { | |
| 540 | this.stopProcess(session); | |
| 541 | } | |
| 542 | for (const timer of this.persistTimers.values()) { | |
| 543 | clearTimeout(timer); | |
| 544 | } | |
| 545 | this.persistTimers.clear(); | |
| 546 | this.pendingPersistHistory.clear(); | |
| 547 | this.threadLocks.clear(); | |
| 548 | this.persistQueues.clear(); | |
| 549 | } | |
| 550 | ||
| 551 | private startSession( | |
| 552 | session: TerminalSessionState, | |
| 553 | input: TerminalStartInput, | |
| 554 | eventType: "started" | "restarted", | |
| 555 | ): void { | |
| 556 | this.stopProcess(session); | |
| 557 | ||
| 558 | session.status = "starting"; | |
| 559 | session.cwd = input.cwd; | |
| 560 | session.cols = input.cols; | |
| 561 | session.rows = input.rows; | |
| 562 | session.exitCode = null; | |
| 563 | session.exitSignal = null; | |
| 564 | session.hasRunningSubprocess = false; | |
| 565 | session.updatedAt = new Date().toISOString(); | |
| 566 | ||
| 567 | let ptyProcess: PtyProcess | null = null; | |
| 568 | let startedShell: string | null = null; | |
| 569 | try { | |
| 570 | const shellCandidates = resolveShellCandidates(this.shellResolver); | |
| 571 | const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); | |
| 572 | let lastSpawnError: unknown = null; | |
| 573 | ||
| 574 | for (const candidate of shellCandidates) { | |
| 575 | try { | |
| 576 | ptyProcess = this.ptyAdapter.spawn({ | |
| 577 | shell: candidate.shell, | |
| 578 | ...(candidate.args ? { args: candidate.args } : {}), | |
| 579 | cwd: session.cwd, | |
| 580 | cols: session.cols, | |
| 581 | rows: session.rows, | |
| 582 | env: terminalEnv, | |
| 583 | }); | |
| 584 | startedShell = formatShellCandidate(candidate); | |
| 585 | break; | |
| 586 | } catch (error) { | |
| 587 | lastSpawnError = error; | |
| 588 | if (!isRetryableShellSpawnError(error)) { | |
| 589 | throw error; | |
| 590 | } | |
| 591 | } | |
| 592 | } | |
| 593 | ||
| 594 | if (!ptyProcess) { | |
| 595 | const detail = | |
| 596 | lastSpawnError instanceof Error ? lastSpawnError.message : "Terminal start failed"; | |
| 597 | const tried = | |
| 598 | shellCandidates.length > 0 | |
| 599 | ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.` | |
| 600 | : ""; | |
| 601 | throw new Error(`${detail}.${tried}`.trim()); | |
| 602 | } | |
| 603 | ||
| 604 | session.process = ptyProcess; | |
| 605 | session.pid = ptyProcess.pid; | |
| 606 | session.status = "running"; | |
| 607 | session.updatedAt = new Date().toISOString(); | |
| 608 | session.unsubscribeData = ptyProcess.onData((data) => { | |
| 609 | this.onProcessData(session, data); | |
| 610 | }); | |
| 611 | session.unsubscribeExit = ptyProcess.onExit((event) => { | |
| 612 | this.onProcessExit(session, event); | |
| 613 | }); | |
| 614 | this.updateSubprocessPollingState(); | |
| 615 | this.emitEvent({ | |
| 616 | type: eventType, | |
| 617 | threadId: session.threadId, | |
| 618 | terminalId: session.terminalId, | |
| 619 | createdAt: new Date().toISOString(), | |
| 620 | snapshot: this.snapshot(session), | |
| 621 | }); | |
| 622 | } catch (error) { | |
| 623 | if (ptyProcess) { | |
| 624 | try { | |
| 625 | ptyProcess.kill(); | |
| 626 | } catch { | |
| 627 | // Ignore kill errors during failed startup cleanup. | |
| 628 | } | |
| 629 | } | |
| 630 | session.status = "error"; | |
| 631 | session.pid = null; | |
| 632 | session.process = null; | |
| 633 | session.hasRunningSubprocess = false; | |
| 634 | session.updatedAt = new Date().toISOString(); | |
| 635 | this.updateSubprocessPollingState(); | |
| 636 | const message = error instanceof Error ? error.message : "Terminal start failed"; | |
| 637 | this.emitEvent({ | |
| 638 | type: "error", | |
| 639 | threadId: session.threadId, | |
| 640 | terminalId: session.terminalId, | |
| 641 | createdAt: new Date().toISOString(), | |
| 642 | message, | |
| 643 | }); | |
| 644 | this.logger.error("failed to start terminal", { | |
| 645 | threadId: session.threadId, | |
| 646 | terminalId: session.terminalId, | |
| 647 | error: message, | |
| 648 | ...(startedShell ? { shell: startedShell } : {}), | |
| 649 | }); | |
| 650 | } | |
| 651 | } | |
| 652 | ||
| 653 | private onProcessData(session: TerminalSessionState, data: string): void { | |
| 654 | session.history = capHistory(`${session.history}${data}`, this.historyLineLimit); | |
| 655 | session.updatedAt = new Date().toISOString(); | |
| 656 | this.queuePersist(session.threadId, session.terminalId, session.history); | |
| 657 | this.emitEvent({ | |
| 658 | type: "output", | |
| 659 | threadId: session.threadId, | |
| 660 | terminalId: session.terminalId, | |
| 661 | createdAt: new Date().toISOString(), | |
| 662 | data, | |
| 663 | }); | |
| 664 | } | |
| 665 | ||
| 666 | private onProcessExit(session: TerminalSessionState, event: PtyExitEvent): void { | |
| 667 | this.cleanupProcessHandles(session); | |
| 668 | session.process = null; | |
| 669 | session.pid = null; | |
| 670 | session.hasRunningSubprocess = false; | |
| 671 | session.status = "exited"; | |
| 672 | session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null; | |
| 673 | session.exitSignal = Number.isInteger(event.signal) ? event.signal : null; | |
| 674 | session.updatedAt = new Date().toISOString(); | |
| 675 | this.emitEvent({ | |
| 676 | type: "exited", | |
| 677 | threadId: session.threadId, | |
| 678 | terminalId: session.terminalId, | |
| 679 | createdAt: new Date().toISOString(), | |
| 680 | exitCode: session.exitCode, | |
| 681 | exitSignal: session.exitSignal, | |
| 682 | }); | |
| 683 | this.updateSubprocessPollingState(); | |
| 684 | } | |
| 685 | ||
| 686 | private stopProcess(session: TerminalSessionState): void { | |
| 687 | const process = session.process; | |
| 688 | if (!process) return; | |
| 689 | this.cleanupProcessHandles(session); | |
| 690 | session.process = null; | |
| 691 | session.pid = null; | |
| 692 | session.hasRunningSubprocess = false; | |
| 693 | session.status = "exited"; | |
| 694 | session.updatedAt = new Date().toISOString(); | |
| 695 | try { | |
| 696 | process.kill(); | |
| 697 | } catch (error) { | |
| 698 | const message = error instanceof Error ? error.message : String(error); | |
| 699 | this.logger.warn("failed to kill terminal process", { | |
| 700 | threadId: session.threadId, | |
| 701 | terminalId: session.terminalId, | |
| 702 | error: message, | |
| 703 | }); | |
| 704 | } | |
| 705 | this.updateSubprocessPollingState(); | |
| 706 | } | |
| 707 | ||
| 708 | private cleanupProcessHandles(session: TerminalSessionState): void { | |
| 709 | session.unsubscribeData?.(); | |
| 710 | session.unsubscribeData = null; | |
| 711 | session.unsubscribeExit?.(); | |
| 712 | session.unsubscribeExit = null; | |
| 713 | } | |
| 714 | ||
| 715 | private queuePersist(threadId: string, terminalId: string, history: string): void { | |
| 716 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 717 | this.pendingPersistHistory.set(persistenceKey, history); | |
| 718 | this.schedulePersist(threadId, terminalId); | |
| 719 | } | |
| 720 | ||
| 721 | private async persistHistory( | |
| 722 | threadId: string, | |
| 723 | terminalId: string, | |
| 724 | history: string, | |
| 725 | ): Promise<void> { | |
| 726 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 727 | this.clearPersistTimer(threadId, terminalId); | |
| 728 | this.pendingPersistHistory.delete(persistenceKey); | |
| 729 | await this.enqueuePersistWrite(threadId, terminalId, history); | |
| 730 | } | |
| 731 | ||
| 732 | private enqueuePersistWrite( | |
| 733 | threadId: string, | |
| 734 | terminalId: string, | |
| 735 | history: string, | |
| 736 | ): Promise<void> { | |
| 737 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 738 | const task = async () => { | |
| 739 | await fs.promises.writeFile(this.historyPath(threadId, terminalId), history, "utf8"); | |
| 740 | }; | |
| 741 | const previous = this.persistQueues.get(persistenceKey) ?? Promise.resolve(); | |
| 742 | const next = previous | |
| 743 | .catch(() => undefined) | |
| 744 | .then(task) | |
| 745 | .catch((error) => { | |
| 746 | this.logger.warn("failed to persist terminal history", { | |
| 747 | threadId, | |
| 748 | terminalId, | |
| 749 | error: error instanceof Error ? error.message : String(error), | |
| 750 | }); | |
| 751 | }); | |
| 752 | this.persistQueues.set(persistenceKey, next); | |
| 753 | const finalized = next.finally(() => { | |
| 754 | if (this.persistQueues.get(persistenceKey) === next) { | |
| 755 | this.persistQueues.delete(persistenceKey); | |
| 756 | } | |
| 757 | if ( | |
| 758 | this.pendingPersistHistory.has(persistenceKey) && | |
| 759 | !this.persistTimers.has(persistenceKey) | |
| 760 | ) { | |
| 761 | this.schedulePersist(threadId, terminalId); | |
| 762 | } | |
| 763 | }); | |
| 764 | void finalized.catch(() => undefined); | |
| 765 | return finalized; | |
| 766 | } | |
| 767 | ||
| 768 | private schedulePersist(threadId: string, terminalId: string): void { | |
| 769 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 770 | if (this.persistTimers.has(persistenceKey)) return; | |
| 771 | const timer = setTimeout(() => { | |
| 772 | this.persistTimers.delete(persistenceKey); | |
| 773 | const pendingHistory = this.pendingPersistHistory.get(persistenceKey); | |
| 774 | if (pendingHistory === undefined) return; | |
| 775 | this.pendingPersistHistory.delete(persistenceKey); | |
| 776 | void this.enqueuePersistWrite(threadId, terminalId, pendingHistory); | |
| 777 | }, this.persistDebounceMs); | |
| 778 | this.persistTimers.set(persistenceKey, timer); | |
| 779 | } | |
| 780 | ||
| 781 | private clearPersistTimer(threadId: string, terminalId: string): void { | |
| 782 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 783 | const timer = this.persistTimers.get(persistenceKey); | |
| 784 | if (!timer) return; | |
| 785 | clearTimeout(timer); | |
| 786 | this.persistTimers.delete(persistenceKey); | |
| 787 | } | |
| 788 | ||
| 789 | private async readHistory(threadId: string, terminalId: string): Promise<string> { | |
| 790 | const nextPath = this.historyPath(threadId, terminalId); | |
| 791 | try { | |
| 792 | const raw = await fs.promises.readFile(nextPath, "utf8"); | |
| 793 | const capped = capHistory(raw, this.historyLineLimit); | |
| 794 | if (capped !== raw) { | |
| 795 | await fs.promises.writeFile(nextPath, capped, "utf8"); | |
| 796 | } | |
| 797 | return capped; | |
| 798 | } catch (error) { | |
| 799 | if ((error as NodeJS.ErrnoException).code !== "ENOENT") { | |
| 800 | throw error; | |
| 801 | } | |
| 802 | } | |
| 803 | ||
| 804 | if (terminalId !== DEFAULT_TERMINAL_ID) { | |
| 805 | return ""; | |
| 806 | } | |
| 807 | ||
| 808 | const legacyPath = this.legacyHistoryPath(threadId); | |
| 809 | try { | |
| 810 | const raw = await fs.promises.readFile(legacyPath, "utf8"); | |
| 811 | const capped = capHistory(raw, this.historyLineLimit); | |
| 812 | ||
| 813 | // Migrate legacy transcript filename to the terminal-scoped path. | |
| 814 | await fs.promises.writeFile(nextPath, capped, "utf8"); | |
| 815 | try { | |
| 816 | await fs.promises.rm(legacyPath, { force: true }); | |
| 817 | } catch (cleanupError) { | |
| 818 | this.logger.warn("failed to remove legacy terminal history", { | |
| 819 | threadId, | |
| 820 | error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), | |
| 821 | }); | |
| 822 | } | |
| 823 | ||
| 824 | return capped; | |
| 825 | } catch (error) { | |
| 826 | if ((error as NodeJS.ErrnoException).code === "ENOENT") { | |
| 827 | return ""; | |
| 828 | } | |
| 829 | throw error; | |
| 830 | } | |
| 831 | } | |
| 832 | ||
| 833 | private async deleteHistory(threadId: string, terminalId: string): Promise<void> { | |
| 834 | const deletions = [fs.promises.rm(this.historyPath(threadId, terminalId), { force: true })]; | |
| 835 | if (terminalId === DEFAULT_TERMINAL_ID) { | |
| 836 | deletions.push(fs.promises.rm(this.legacyHistoryPath(threadId), { force: true })); | |
| 837 | } | |
| 838 | try { | |
| 839 | await Promise.all(deletions); | |
| 840 | } catch (error) { | |
| 841 | this.logger.warn("failed to delete terminal history", { | |
| 842 | threadId, | |
| 843 | terminalId, | |
| 844 | error: error instanceof Error ? error.message : String(error), | |
| 845 | }); | |
| 846 | } | |
| 847 | } | |
| 848 | ||
| 849 | private async flushPersistQueue(threadId: string, terminalId: string): Promise<void> { | |
| 850 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 851 | this.clearPersistTimer(threadId, terminalId); | |
| 852 | ||
| 853 | while (true) { | |
| 854 | const pendingHistory = this.pendingPersistHistory.get(persistenceKey); | |
| 855 | if (pendingHistory !== undefined) { | |
| 856 | this.pendingPersistHistory.delete(persistenceKey); | |
| 857 | await this.enqueuePersistWrite(threadId, terminalId, pendingHistory); | |
| 858 | } | |
| 859 | ||
| 860 | const pending = this.persistQueues.get(persistenceKey); | |
| 861 | if (!pending) { | |
| 862 | return; | |
| 863 | } | |
| 864 | await pending.catch(() => undefined); | |
| 865 | } | |
| 866 | } | |
| 867 | ||
| 868 | private updateSubprocessPollingState(): void { | |
| 869 | const hasRunningSessions = [...this.sessions.values()].some( | |
| 870 | (session) => session.status === "running" && session.pid !== null, | |
| 871 | ); | |
| 872 | if (hasRunningSessions) { | |
| 873 | this.ensureSubprocessPolling(); | |
| 874 | return; | |
| 875 | } | |
| 876 | this.stopSubprocessPolling(); | |
| 877 | } | |
| 878 | ||
| 879 | private ensureSubprocessPolling(): void { | |
| 880 | if (this.subprocessPollTimer) return; | |
| 881 | this.subprocessPollTimer = setInterval(() => { | |
| 882 | void this.pollSubprocessActivity(); | |
| 883 | }, this.subprocessPollIntervalMs); | |
| 884 | this.subprocessPollTimer.unref?.(); | |
| 885 | void this.pollSubprocessActivity(); | |
| 886 | } | |
| 887 | ||
| 888 | private stopSubprocessPolling(): void { | |
| 889 | if (!this.subprocessPollTimer) return; | |
| 890 | clearInterval(this.subprocessPollTimer); | |
| 891 | this.subprocessPollTimer = null; | |
| 892 | } | |
| 893 | ||
| 894 | private async pollSubprocessActivity(): Promise<void> { | |
| 895 | if (this.subprocessPollInFlight) return; | |
| 896 | ||
| 897 | const runningSessions = [...this.sessions.values()].filter( | |
| 898 | (session): session is TerminalSessionState & { pid: number } => | |
| 899 | session.status === "running" && Number.isInteger(session.pid), | |
| 900 | ); | |
| 901 | if (runningSessions.length === 0) { | |
| 902 | this.stopSubprocessPolling(); | |
| 903 | return; | |
| 904 | } | |
| 905 | ||
| 906 | this.subprocessPollInFlight = true; | |
| 907 | try { | |
| 908 | await Promise.all( | |
| 909 | runningSessions.map(async (session) => { | |
| 910 | const terminalPid = session.pid; | |
| 911 | let hasRunningSubprocess = false; | |
| 912 | try { | |
| 913 | hasRunningSubprocess = await this.subprocessChecker(terminalPid); | |
| 914 | } catch (error) { | |
| 915 | this.logger.warn("failed to check terminal subprocess activity", { | |
| 916 | threadId: session.threadId, | |
| 917 | terminalId: session.terminalId, | |
| 918 | terminalPid, | |
| 919 | error: error instanceof Error ? error.message : String(error), | |
| 920 | }); | |
| 921 | return; | |
| 922 | } | |
| 923 | ||
| 924 | const liveSession = this.sessions.get(toSessionKey(session.threadId, session.terminalId)); | |
| 925 | if (!liveSession || liveSession.status !== "running" || liveSession.pid !== terminalPid) { | |
| 926 | return; | |
| 927 | } | |
| 928 | if (liveSession.hasRunningSubprocess === hasRunningSubprocess) { | |
| 929 | return; | |
| 930 | } | |
| 931 | ||
| 932 | liveSession.hasRunningSubprocess = hasRunningSubprocess; | |
| 933 | liveSession.updatedAt = new Date().toISOString(); | |
| 934 | this.emitEvent({ | |
| 935 | type: "activity", | |
| 936 | threadId: liveSession.threadId, | |
| 937 | terminalId: liveSession.terminalId, | |
| 938 | createdAt: new Date().toISOString(), | |
| 939 | hasRunningSubprocess, | |
| 940 | }); | |
| 941 | }), | |
| 942 | ); | |
| 943 | } finally { | |
| 944 | this.subprocessPollInFlight = false; | |
| 945 | } | |
| 946 | } | |
| 947 | ||
| 948 | private async assertValidCwd(cwd: string): Promise<void> { | |
| 949 | let stats: fs.Stats; | |
| 950 | try { | |
| 951 | stats = await fs.promises.stat(cwd); | |
| 952 | } catch (error) { | |
| 953 | if ((error as NodeJS.ErrnoException).code === "ENOENT") { | |
| 954 | throw new Error(`Terminal cwd does not exist: ${cwd}`, { cause: error }); | |
| 955 | } | |
| 956 | throw error; | |
| 957 | } | |
| 958 | if (!stats.isDirectory()) { | |
| 959 | throw new Error(`Terminal cwd is not a directory: ${cwd}`); | |
| 960 | } | |
| 961 | } | |
| 962 | ||
| 963 | private async closeSession( | |
| 964 | threadId: string, | |
| 965 | terminalId: string, | |
| 966 | deleteHistory: boolean, | |
| 967 | ): Promise<void> { | |
| 968 | const key = toSessionKey(threadId, terminalId); | |
| 969 | const session = this.sessions.get(key); | |
| 970 | if (session) { | |
| 971 | this.stopProcess(session); | |
| 972 | this.sessions.delete(key); | |
| 973 | } | |
| 974 | this.updateSubprocessPollingState(); | |
| 975 | await this.flushPersistQueue(threadId, terminalId); | |
| 976 | if (deleteHistory) { | |
| 977 | await this.deleteHistory(threadId, terminalId); | |
| 978 | } | |
| 979 | } | |
| 980 | ||
| 981 | private sessionsForThread(threadId: string): TerminalSessionState[] { | |
| 982 | return [...this.sessions.values()].filter((session) => session.threadId === threadId); | |
| 983 | } | |
| 984 | ||
| 985 | private async deleteAllHistoryForThread(threadId: string): Promise<void> { | |
| 986 | const threadPrefix = `${toSafeThreadId(threadId)}_`; | |
| 987 | try { | |
| 988 | const entries = await fs.promises.readdir(this.logsDir, { withFileTypes: true }); | |
| 989 | const removals = entries | |
| 990 | .filter((entry) => entry.isFile()) | |
| 991 | .map((entry) => entry.name) | |
| 992 | .filter( | |
| 993 | (name) => | |
| 994 | name === `${toSafeThreadId(threadId)}.log` || | |
| 995 | name === `${legacySafeThreadId(threadId)}.log` || | |
| 996 | name.startsWith(threadPrefix), | |
| 997 | ) | |
| 998 | .map((name) => fs.promises.rm(path.join(this.logsDir, name), { force: true })); | |
| 999 | await Promise.all(removals); | |
| 1000 | } catch (error) { | |
| 1001 | this.logger.warn("failed to delete terminal histories for thread", { | |
| 1002 | threadId, | |
| 1003 | error: error instanceof Error ? error.message : String(error), | |
| 1004 | }); | |
| 1005 | } | |
| 1006 | } | |
| 1007 | ||
| 1008 | private requireSession(threadId: string, terminalId: string): TerminalSessionState { | |
| 1009 | const session = this.sessions.get(toSessionKey(threadId, terminalId)); | |
| 1010 | if (!session) { | |
| 1011 | throw new Error(`Unknown terminal thread: ${threadId}, terminal: ${terminalId}`); | |
| 1012 | } | |
| 1013 | return session; | |
| 1014 | } | |
| 1015 | ||
| 1016 | private snapshot(session: TerminalSessionState): TerminalSessionSnapshot { | |
| 1017 | return { | |
| 1018 | threadId: session.threadId, | |
| 1019 | terminalId: session.terminalId, | |
| 1020 | cwd: session.cwd, | |
| 1021 | status: session.status, | |
| 1022 | pid: session.pid, | |
| 1023 | history: session.history, | |
| 1024 | exitCode: session.exitCode, | |
| 1025 | exitSignal: session.exitSignal, | |
| 1026 | updatedAt: session.updatedAt, | |
| 1027 | }; | |
| 1028 | } | |
| 1029 | ||
| 1030 | private emitEvent(event: TerminalEvent): void { | |
| 1031 | this.emit("event", event); | |
| 1032 | } | |
| 1033 | ||
| 1034 | private historyPath(threadId: string, terminalId: string): string { | |
| 1035 | const threadPart = toSafeThreadId(threadId); | |
| 1036 | if (terminalId === DEFAULT_TERMINAL_ID) { | |
| 1037 | return path.join(this.logsDir, `${threadPart}.log`); | |
| 1038 | } | |
| 1039 | return path.join(this.logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); | |
| 1040 | } | |
| 1041 | ||
| 1042 | private legacyHistoryPath(threadId: string): string { | |
| 1043 | return path.join(this.logsDir, `${legacySafeThreadId(threadId)}.log`); | |
| 1044 | } | |
| 1045 | ||
| 1046 | private async runWithThreadLock<T>(threadId: string, task: () => Promise<T>): Promise<T> { | |
| 1047 | const previous = this.threadLocks.get(threadId) ?? Promise.resolve(); | |
| 1048 | let release: () => void = () => {}; | |
| 1049 | const current = new Promise<void>((resolve) => { | |
| 1050 | release = resolve; | |
| 1051 | }); | |
| 1052 | this.threadLocks.set(threadId, current); | |
| 1053 | await previous.catch(() => undefined); | |
| 1054 | try { | |
| 1055 | return await task(); | |
| 1056 | } finally { | |
| 1057 | release(); | |
| 1058 | if (this.threadLocks.get(threadId) === current) { | |
| 1059 | this.threadLocks.delete(threadId); | |
| 1060 | } | |
| 1061 | } | |
| 1062 | } | |
| 1063 | } | |
| 1064 | ||
| 1065 | export function makeTerminalManagerLive( | |
| 1066 | options: Omit<TerminalManagerOptions, "ptyAdapter"> = {}, | |
| 1067 | ): Layer.Layer<TerminalManager, never, PtyAdapter> { | |
| 1068 | return Layer.effect( | |
| 1069 | TerminalManager, | |
| 1070 | Effect.gen(function* () { | |
| 1071 | const ptyAdapter = yield* PtyAdapter; | |
| 1072 | const runtime = new TerminalManagerRuntime({ ...options, ptyAdapter }); | |
| 1073 | ||
| 1074 | return { | |
| 1075 | open: (input) => Effect.promise(() => runtime.open(input)), | |
| 1076 | write: (input) => Effect.promise(() => runtime.write(input)), | |
| 1077 | resize: (input) => Effect.promise(() => runtime.resize(input)), | |
| 1078 | clear: (input) => Effect.promise(() => runtime.clear(input)), | |
| 1079 | restart: (input) => Effect.promise(() => runtime.restart(input)), | |
| 1080 | close: (input) => Effect.promise(() => runtime.close(input)), | |
| 1081 | subscribe: (listener) => | |
| 1082 | Effect.sync(() => { | |
| 1083 | runtime.on("event", listener); | |
| 1084 | return () => { | |
| 1085 | runtime.off("event", listener); | |
| 1086 | }; | |
| 1087 | }), | |
| 1088 | dispose: () => | |
| 1089 | Effect.sync(() => { | |
| 1090 | runtime.dispose(); | |
| 1091 | }), | |
| 1092 | } satisfies TerminalManagerShape; | |
| 1093 | }), | |
| 1094 | ); | |
| 1095 | } |
apps/server/src/wsServer.test.ts+72-34| ... | ... | @@ -37,11 +37,15 @@ import type { |
| 37 | 37 | TerminalSessionSnapshot, |
| 38 | 38 | TerminalWriteInput, |
| 39 | 39 | } from "@t3tools/contracts"; |
| 40 | import type { TerminalManagerShape } from "./terminalManager"; | |
| 40 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager"; | |
| 41 | 41 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; |
| 42 | 42 | import { SqlClient } from "effect/unstable/sql"; |
| 43 | 43 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; |
| 44 | 44 | import { Open, type OpenShape } from "./open"; |
| 45 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; | |
| 46 | import type { GitCoreShape } from "./git/Services/GitCore.ts"; | |
| 47 | import { GitCore } from "./git/Services/GitCore.ts"; | |
| 48 | import { GitCommandError, GitManagerError } from "./git/Errors.ts"; | |
| 45 | 49 | |
| 46 | 50 | interface PendingMessages { |
| 47 | 51 | queue: unknown[]; |
| ... | ... | @@ -339,10 +343,8 @@ describe("WebSocket Server", () => { |
| 339 | 343 | stateDir?: string; |
| 340 | 344 | providerLayer?: Layer.Layer<ProviderService, unknown>; |
| 341 | 345 | open?: OpenShape; |
| 342 | gitManager?: { | |
| 343 | status: (input: { cwd: string }) => Promise<unknown>; | |
| 344 | runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>; | |
| 345 | }; | |
| 346 | gitManager?: GitManagerShape; | |
| 347 | gitCore?: Pick<GitCoreShape, "listBranches" | "initRepo" | "pullCurrentBranch">; | |
| 346 | 348 | terminalManager?: TerminalManagerShape; |
| 347 | 349 | } = {}, |
| 348 | 350 | ): Promise<Http.Server> { |
| ... | ... | @@ -368,9 +370,21 @@ describe("WebSocket Server", () => { |
| 368 | 370 | authToken: options.authToken, |
| 369 | 371 | } satisfies ServerConfigShape); |
| 370 | 372 | const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); |
| 373 | const runtimeOverrides = Layer.mergeAll( | |
| 374 | options.gitManager ? Layer.succeed(GitManager, options.gitManager) : Layer.empty, | |
| 375 | options.gitCore | |
| 376 | ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape) | |
| 377 | : Layer.empty, | |
| 378 | options.terminalManager | |
| 379 | ? Layer.succeed(TerminalManager, options.terminalManager) | |
| 380 | : Layer.empty, | |
| 381 | ); | |
| 371 | 382 | const runtimeLayer = Layer.merge( |
| 372 | makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), | |
| 373 | infrastructureLayer, | |
| 383 | Layer.merge( | |
| 384 | makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), | |
| 385 | infrastructureLayer, | |
| 386 | ), | |
| 387 | runtimeOverrides, | |
| 374 | 388 | ); |
| 375 | 389 | const dependenciesLayer = Layer.empty.pipe( |
| 376 | 390 | Layer.provideMerge(runtimeLayer), |
| ... | ... | @@ -385,8 +399,6 @@ describe("WebSocket Server", () => { |
| 385 | 399 | const runtime = await Effect.runPromise( |
| 386 | 400 | createServer({ |
| 387 | 401 | autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false, |
| 388 | ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), | |
| 389 | ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), | |
| 390 | 402 | }).pipe(Effect.provide(runtimeServices), Scope.provide(scope)), |
| 391 | 403 | ); |
| 392 | 404 | serverScope = scope; |
| ... | ... | @@ -1049,10 +1061,33 @@ describe("WebSocket Server", () => { |
| 1049 | 1061 | }); |
| 1050 | 1062 | }); |
| 1051 | 1063 | |
| 1052 | it("supports git methods over websocket", async () => { | |
| 1053 | const repoCwd = makeTempDir("t3code-ws-git-project-"); | |
| 1064 | it("routes git core methods over websocket", async () => { | |
| 1065 | const listBranches = vi.fn(() => | |
| 1066 | Effect.succeed({ | |
| 1067 | branches: [], | |
| 1068 | isRepo: false, | |
| 1069 | }), | |
| 1070 | ); | |
| 1071 | const initRepo = vi.fn(() => Effect.void); | |
| 1072 | const pullCurrentBranch = vi.fn(() => | |
| 1073 | Effect.fail( | |
| 1074 | new GitCommandError({ | |
| 1075 | operation: "GitCore.test.pullCurrentBranch", | |
| 1076 | detail: "No upstream configured", | |
| 1077 | command: "git pull", | |
| 1078 | cwd: "/repo/path", | |
| 1079 | }), | |
| 1080 | ), | |
| 1081 | ); | |
| 1054 | 1082 | |
| 1055 | server = await createTestServer({ cwd: "/test" }); | |
| 1083 | server = await createTestServer({ | |
| 1084 | cwd: "/test", | |
| 1085 | gitCore: { | |
| 1086 | listBranches, | |
| 1087 | initRepo, | |
| 1088 | pullCurrentBranch, | |
| 1089 | }, | |
| 1090 | }); | |
| 1056 | 1091 | const addr = server.address(); |
| 1057 | 1092 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1058 | 1093 | |
| ... | ... | @@ -1060,23 +1095,19 @@ describe("WebSocket Server", () => { |
| 1060 | 1095 | connections.push(ws); |
| 1061 | 1096 | await waitForMessage(ws); |
| 1062 | 1097 | |
| 1063 | const beforeInit = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: repoCwd }); | |
| 1064 | expect(beforeInit.error).toBeUndefined(); | |
| 1065 | expect(beforeInit.result).toEqual({ branches: [], isRepo: false }); | |
| 1098 | const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" }); | |
| 1099 | expect(listResponse.error).toBeUndefined(); | |
| 1100 | expect(listResponse.result).toEqual({ branches: [], isRepo: false }); | |
| 1101 | expect(listBranches).toHaveBeenCalledWith({ cwd: "/repo/path" }); | |
| 1066 | 1102 | |
| 1067 | const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: repoCwd }); | |
| 1103 | const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: "/repo/path" }); | |
| 1068 | 1104 | expect(initResponse.error).toBeUndefined(); |
| 1105 | expect(initRepo).toHaveBeenCalledWith({ cwd: "/repo/path" }); | |
| 1069 | 1106 | |
| 1070 | const afterInit = await sendRequest(ws, WS_METHODS.gitListBranches, { | |
| 1071 | cwd: repoCwd, | |
| 1072 | }); | |
| 1073 | expect(afterInit.error).toBeUndefined(); | |
| 1074 | expect((afterInit.result as { isRepo: boolean }).isRepo).toBe(true); | |
| 1075 | ||
| 1076 | const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: repoCwd }); | |
| 1107 | const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: "/repo/path" }); | |
| 1077 | 1108 | expect(pullResponse.result).toBeUndefined(); |
| 1078 | expect(pullResponse.error?.message).toBeDefined(); | |
| 1079 | expect(pullResponse.error?.message).not.toContain("Unknown method"); | |
| 1109 | expect(pullResponse.error?.message).toContain("No upstream configured"); | |
| 1110 | expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path"); | |
| 1080 | 1111 | }); |
| 1081 | 1112 | |
| 1082 | 1113 | it("supports git.status over websocket", async () => { |
| ... | ... | @@ -1094,10 +1125,9 @@ describe("WebSocket Server", () => { |
| 1094 | 1125 | openPr: null, |
| 1095 | 1126 | }; |
| 1096 | 1127 | |
| 1097 | const gitManager = { | |
| 1098 | status: vi.fn().mockResolvedValue(statusResult), | |
| 1099 | runStackedAction: vi.fn(), | |
| 1100 | }; | |
| 1128 | const status = vi.fn(() => Effect.succeed(statusResult)); | |
| 1129 | const runStackedAction = vi.fn(() => Effect.void as any); | |
| 1130 | const gitManager: GitManagerShape = { status, runStackedAction }; | |
| 1101 | 1131 | |
| 1102 | 1132 | server = await createTestServer({ cwd: "/test", gitManager }); |
| 1103 | 1133 | const addr = server.address(); |
| ... | ... | @@ -1112,13 +1142,21 @@ describe("WebSocket Server", () => { |
| 1112 | 1142 | }); |
| 1113 | 1143 | expect(response.error).toBeUndefined(); |
| 1114 | 1144 | expect(response.result).toEqual(statusResult); |
| 1115 | expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" }); | |
| 1145 | expect(status).toHaveBeenCalledWith({ cwd: "/test" }); | |
| 1116 | 1146 | }); |
| 1117 | 1147 | |
| 1118 | 1148 | it("returns errors from git.runStackedAction", async () => { |
| 1119 | const gitManager = { | |
| 1120 | status: vi.fn(), | |
| 1121 | runStackedAction: vi.fn().mockRejectedValue(new Error("Cannot push from detached HEAD.")), | |
| 1149 | const runStackedAction = vi.fn(() => | |
| 1150 | Effect.fail( | |
| 1151 | new GitManagerError({ | |
| 1152 | operation: "GitManager.test.runStackedAction", | |
| 1153 | detail: "Cannot push from detached HEAD.", | |
| 1154 | }), | |
| 1155 | ), | |
| 1156 | ); | |
| 1157 | const gitManager: GitManagerShape = { | |
| 1158 | status: vi.fn(() => Effect.void as any), | |
| 1159 | runStackedAction, | |
| 1122 | 1160 | }; |
| 1123 | 1161 | |
| 1124 | 1162 | server = await createTestServer({ cwd: "/test", gitManager }); |
| ... | ... | @@ -1135,7 +1173,7 @@ describe("WebSocket Server", () => { |
| 1135 | 1173 | }); |
| 1136 | 1174 | expect(response.result).toBeUndefined(); |
| 1137 | 1175 | expect(response.error?.message).toContain("detached HEAD"); |
| 1138 | expect(gitManager.runStackedAction).toHaveBeenCalledWith({ | |
| 1176 | expect(runStackedAction).toHaveBeenCalledWith({ | |
| 1139 | 1177 | cwd: "/test", |
| 1140 | 1178 | action: "commit_push", |
| 1141 | 1179 | }); |
apps/server/src/wsServer.ts+22-33| ... | ... | @@ -20,17 +20,8 @@ import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream, Struct } |
| 20 | 20 | import { WebSocketServer, type WebSocket } from "ws"; |
| 21 | 21 | |
| 22 | 22 | import { createLogger } from "./logger"; |
| 23 | import { GitManager } from "./gitManager"; | |
| 24 | import { | |
| 25 | checkoutGitBranch, | |
| 26 | createGitBranch, | |
| 27 | createGitWorktree, | |
| 28 | initGitRepo, | |
| 29 | listGitBranches, | |
| 30 | pullGitBranch, | |
| 31 | removeGitWorktree, | |
| 32 | } from "./git"; | |
| 33 | import { TerminalManager, type TerminalManagerShape } from "./terminalManager"; | |
| 23 | import { GitManager } from "./git/Services/GitManager.ts"; | |
| 24 | import { TerminalManager } from "./terminal/Services/Manager.ts"; | |
| 34 | 25 | import { Keybindings } from "./keybindings"; |
| 35 | 26 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 36 | 27 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; |
| ... | ... | @@ -41,6 +32,7 @@ import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuer |
| 41 | 32 | import { clamp } from "effect/Number"; |
| 42 | 33 | import { Open } from "./open"; |
| 43 | 34 | import { ServerConfig } from "./config"; |
| 35 | import { GitCore } from "./git/Services/GitCore.ts"; | |
| 44 | 36 | |
| 45 | 37 | const MIME_TYPES: Record<string, string> = { |
| 46 | 38 | ".html": "text/html; charset=utf-8", |
| ... | ... | @@ -60,8 +52,6 @@ const MIME_TYPES: Record<string, string> = { |
| 60 | 52 | export interface ServerOptions { |
| 61 | 53 | autoBootstrapProjectFromCwd?: boolean | undefined; |
| 62 | 54 | logWebSocketEvents?: boolean | undefined; |
| 63 | gitManager?: GitManager | undefined; | |
| 64 | terminalManager?: TerminalManagerShape | undefined; | |
| 65 | 55 | } |
| 66 | 56 | |
| 67 | 57 | export interface ServerShape { |
| ... | ... | @@ -146,6 +136,8 @@ export type ServerCoreRuntimeServices = |
| 146 | 136 | |
| 147 | 137 | export type ServerRuntimeServices = |
| 148 | 138 | | ServerCoreRuntimeServices |
| 139 | | GitManager | |
| 140 | | GitCore | |
| 149 | 141 | | TerminalManager |
| 150 | 142 | | Keybindings |
| 151 | 143 | | Open; |
| ... | ... | @@ -169,19 +161,18 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 169 | 161 | > { |
| 170 | 162 | const serverConfig = yield* ServerConfig; |
| 171 | 163 | const { port, cwd, staticDir, devUrl, authToken, mode, host } = serverConfig; |
| 172 | const defaultTerminalManager = yield* TerminalManager; | |
| 173 | const keybindingsManager = yield* Keybindings; | |
| 174 | 164 | const { |
| 175 | logWebSocketEvents: explicitLogWsEvents, | |
| 176 | gitManager = new GitManager(), | |
| 177 | terminalManager = defaultTerminalManager, | |
| 165 | logWebSocketEvents = parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl), | |
| 166 | autoBootstrapProjectFromCwd = mode === "web", | |
| 178 | 167 | } = options; |
| 179 | const autoBootstrapProjectFromCwd = options.autoBootstrapProjectFromCwd ?? mode === "web"; | |
| 168 | ||
| 169 | const gitManager = yield* GitManager; | |
| 170 | const terminalManager = yield* TerminalManager; | |
| 171 | const keybindingsManager = yield* Keybindings; | |
| 172 | const git = yield* GitCore; | |
| 180 | 173 | |
| 181 | 174 | const clients = new Set<WebSocket>(); |
| 182 | 175 | const logger = createLogger("ws"); |
| 183 | const logWebSocketEvents = | |
| 184 | explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); | |
| 185 | 176 | |
| 186 | 177 | function logOutgoingPush(push: WsPush, recipients: number) { |
| 187 | 178 | if (!logWebSocketEvents) return; |
| ... | ... | @@ -386,47 +377,47 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 386 | 377 | |
| 387 | 378 | case WS_METHODS.gitStatus: { |
| 388 | 379 | const body = stripRequestTag(request.body); |
| 389 | return yield* Effect.promise(() => gitManager.status(body)); | |
| 380 | return yield* gitManager.status(body); | |
| 390 | 381 | } |
| 391 | 382 | |
| 392 | 383 | case WS_METHODS.gitPull: { |
| 393 | 384 | const body = stripRequestTag(request.body); |
| 394 | return yield* Effect.promise(() => pullGitBranch(body)); | |
| 385 | return yield* git.pullCurrentBranch(body.cwd); | |
| 395 | 386 | } |
| 396 | 387 | |
| 397 | 388 | case WS_METHODS.gitRunStackedAction: { |
| 398 | 389 | const body = stripRequestTag(request.body); |
| 399 | return yield* Effect.promise(() => gitManager.runStackedAction(body)); | |
| 390 | return yield* gitManager.runStackedAction(body); | |
| 400 | 391 | } |
| 401 | 392 | |
| 402 | 393 | case WS_METHODS.gitListBranches: { |
| 403 | 394 | const body = stripRequestTag(request.body); |
| 404 | return yield* Effect.promise(() => listGitBranches(body)); | |
| 395 | return yield* git.listBranches(body); | |
| 405 | 396 | } |
| 406 | 397 | |
| 407 | 398 | case WS_METHODS.gitCreateWorktree: { |
| 408 | 399 | const body = stripRequestTag(request.body); |
| 409 | return yield* Effect.promise(() => createGitWorktree(body)); | |
| 400 | return yield* git.createWorktree(body); | |
| 410 | 401 | } |
| 411 | 402 | |
| 412 | 403 | case WS_METHODS.gitRemoveWorktree: { |
| 413 | 404 | const body = stripRequestTag(request.body); |
| 414 | return yield* Effect.promise(() => removeGitWorktree(body)); | |
| 405 | return yield* git.removeWorktree(body); | |
| 415 | 406 | } |
| 416 | 407 | |
| 417 | 408 | case WS_METHODS.gitCreateBranch: { |
| 418 | 409 | const body = stripRequestTag(request.body); |
| 419 | return yield* Effect.promise(() => createGitBranch(body)); | |
| 410 | return yield* git.createBranch(body); | |
| 420 | 411 | } |
| 421 | 412 | |
| 422 | 413 | case WS_METHODS.gitCheckout: { |
| 423 | 414 | const body = stripRequestTag(request.body); |
| 424 | return yield* Effect.promise(() => checkoutGitBranch(body)); | |
| 415 | return yield* git.checkoutBranch(body); | |
| 425 | 416 | } |
| 426 | 417 | |
| 427 | 418 | case WS_METHODS.gitInit: { |
| 428 | 419 | const body = stripRequestTag(request.body); |
| 429 | return yield* Effect.promise(() => initGitRepo(body)); | |
| 420 | return yield* git.initRepo(body); | |
| 430 | 421 | } |
| 431 | 422 | |
| 432 | 423 | case WS_METHODS.terminalOpen: { |
| ... | ... | @@ -579,9 +570,7 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 579 | 570 | unsubscribeTerminalEvents(); |
| 580 | 571 | }), |
| 581 | 572 | ); |
| 582 | yield* Effect.addFinalizer(() => | |
| 583 | terminalManager.dispose().pipe(Effect.orElseSucceed(() => undefined)), | |
| 584 | ); | |
| 573 | yield* Effect.addFinalizer(terminalManager.dispose); | |
| 585 | 574 | |
| 586 | 575 | yield* NodeHttpServer.make(() => httpServer, listenOptions); |
| 587 | 576 |