| 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,8 +15,8 @@ import { Effect, Layer, FileSystem, Path } from "effect"; |
| 15 | 15 | ||
| 16 | import { CheckpointInvariantError } from "../Errors.ts"; | 16 | import { CheckpointInvariantError } from "../Errors.ts"; |
| 17 | import { GitCommandError } from "../../git/Errors.ts"; | 17 | import { GitCommandError } from "../../git/Errors.ts"; |
| 18 | import { GitServiceLive } from "../../git/Layer.ts"; | 18 | import { GitServiceLive } from "../../git/Layers/GitService.ts"; |
| 19 | import { GitService } from "../../git/Service.ts"; | 19 | import { GitService } from "../../git/Services/GitService.ts"; |
| 20 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; | 20 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; |
| 21 | import { CheckpointRef } from "@t3tools/contracts"; | 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,3 +14,69 @@ export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()( |
| 14 | return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; | 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,8 +7,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 7 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; | 7 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; |
| 8 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; | 8 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; |
| 9 | import { ServerConfig } from "./config"; | 9 | import { ServerConfig } from "./config"; |
| 10 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; | ||
| 11 | import { CheckpointStore } from "./checkpointing/Services/CheckpointStore"; | ||
| 12 | import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; | 10 | import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; |
| 13 | import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; | 11 | import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; |
| 14 | import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; | 12 | import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; |
| ... | @@ -19,18 +17,21 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma | ... | @@ -19,18 +17,21 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma |
| 19 | import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; | 17 | import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; |
| 20 | import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; | 18 | import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; |
| 21 | import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; | 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 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; | 20 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; |
| 26 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; | 21 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; |
| 27 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; | 22 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; |
| 28 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; | 23 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; |
| 29 | import { ProviderService } from "./provider/Services/ProviderService"; | 24 | import { ProviderService } from "./provider/Services/ProviderService"; |
| 30 | 25 | ||
| 31 | import { makeTerminalManagerLive, TerminalManager } from "./terminalManager"; | 26 | import { TerminalManagerLive } from "./terminal/Layers/Manager"; |
| 32 | import { PtyAdapter } from "./ptyAdapter"; | 27 | import { KeybindingsLive } from "./keybindings"; |
| 33 | import { Keybindings, 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 | export function makeServerProviderLayer(): Layer.Layer< | 36 | export function makeServerProviderLayer(): Layer.Layer< |
| 36 | ProviderService, | 37 | ProviderService, |
| ... | @@ -53,17 +54,7 @@ export function makeServerProviderLayer(): Layer.Layer< | ... | @@ -53,17 +54,7 @@ export function makeServerProviderLayer(): Layer.Layer< |
| 53 | }).pipe(Layer.unwrap); | 54 | }).pipe(Layer.unwrap); |
| 54 | } | 55 | } |
| 55 | 56 | ||
| 56 | export function makeServerRuntimeServicesLayer(): Layer.Layer< | 57 | export function makeServerRuntimeServicesLayer() { |
| 57 | | OrchestrationEngineService | ||
| 58 | | ProjectionSnapshotQuery | ||
| 59 | | CheckpointStore | ||
| 60 | | CheckpointDiffQuery | ||
| 61 | | OrchestrationReactor | ||
| 62 | | TerminalManager | ||
| 63 | | Keybindings, | ||
| 64 | unknown, | ||
| 65 | SqlClient.SqlClient | ProviderService | ServerConfig | ||
| 66 | > { | ||
| 67 | const orchestrationLayer = OrchestrationEngineLive.pipe( | 58 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 68 | Layer.provide(OrchestrationProjectionPipelineLive), | 59 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 69 | Layer.provide(OrchestrationEventStoreLive), | 60 | Layer.provide(OrchestrationEventStoreLive), |
| ... | @@ -96,14 +87,23 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer< | ... | @@ -96,14 +87,23 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer< |
| 96 | Layer.provideMerge(checkpointReactorLayer), | 87 | Layer.provideMerge(checkpointReactorLayer), |
| 97 | ); | 88 | ); |
| 98 | 89 | ||
| 99 | const terminalLayer = Effect.gen(function* () { | 90 | const terminalLayer = TerminalManagerLive.pipe( |
| 100 | const { stateDir } = yield* ServerConfig; | 91 | Layer.provide(typeof Bun !== "undefined" ? BunPtyAdapterLive : NodePtyAdapterLive), |
| 101 | return makeTerminalManagerLive({ | 92 | ); |
| 102 | logsDir: path.join(stateDir, "logs", "terminals"), | 93 | |
| 103 | }).pipe(Layer.provide(PtyAdapter.layer())); | 94 | const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive)); |
| 104 | }).pipe(Layer.unwrap); | ||
| 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,11 +37,15 @@ import type { |
| 37 | TerminalSessionSnapshot, | 37 | TerminalSessionSnapshot, |
| 38 | TerminalWriteInput, | 38 | TerminalWriteInput, |
| 39 | } from "@t3tools/contracts"; | 39 | } from "@t3tools/contracts"; |
| 40 | import type { TerminalManagerShape } from "./terminalManager"; | 40 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager"; |
| 41 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; | 41 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; |
| 42 | import { SqlClient } from "effect/unstable/sql"; | 42 | import { SqlClient } from "effect/unstable/sql"; |
| 43 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; | 43 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; |
| 44 | import { Open, type OpenShape } from "./open"; | 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 | interface PendingMessages { | 50 | interface PendingMessages { |
| 47 | queue: unknown[]; | 51 | queue: unknown[]; |
| ... | @@ -339,10 +343,8 @@ describe("WebSocket Server", () => { | ... | @@ -339,10 +343,8 @@ describe("WebSocket Server", () => { |
| 339 | stateDir?: string; | 343 | stateDir?: string; |
| 340 | providerLayer?: Layer.Layer<ProviderService, unknown>; | 344 | providerLayer?: Layer.Layer<ProviderService, unknown>; |
| 341 | open?: OpenShape; | 345 | open?: OpenShape; |
| 342 | gitManager?: { | 346 | gitManager?: GitManagerShape; |
| 343 | status: (input: { cwd: string }) => Promise<unknown>; | 347 | gitCore?: Pick<GitCoreShape, "listBranches" | "initRepo" | "pullCurrentBranch">; |
| 344 | runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>; | ||
| 345 | }; | ||
| 346 | terminalManager?: TerminalManagerShape; | 348 | terminalManager?: TerminalManagerShape; |
| 347 | } = {}, | 349 | } = {}, |
| 348 | ): Promise<Http.Server> { | 350 | ): Promise<Http.Server> { |
| ... | @@ -368,9 +370,21 @@ describe("WebSocket Server", () => { | ... | @@ -368,9 +370,21 @@ describe("WebSocket Server", () => { |
| 368 | authToken: options.authToken, | 370 | authToken: options.authToken, |
| 369 | } satisfies ServerConfigShape); | 371 | } satisfies ServerConfigShape); |
| 370 | const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); | 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 | const runtimeLayer = Layer.merge( | 382 | const runtimeLayer = Layer.merge( |
| 372 | makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), | 383 | Layer.merge( |
| 373 | infrastructureLayer, | 384 | makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), |
| 385 | infrastructureLayer, | ||
| 386 | ), | ||
| 387 | runtimeOverrides, | ||
| 374 | ); | 388 | ); |
| 375 | const dependenciesLayer = Layer.empty.pipe( | 389 | const dependenciesLayer = Layer.empty.pipe( |
| 376 | Layer.provideMerge(runtimeLayer), | 390 | Layer.provideMerge(runtimeLayer), |
| ... | @@ -385,8 +399,6 @@ describe("WebSocket Server", () => { | ... | @@ -385,8 +399,6 @@ describe("WebSocket Server", () => { |
| 385 | const runtime = await Effect.runPromise( | 399 | const runtime = await Effect.runPromise( |
| 386 | createServer({ | 400 | createServer({ |
| 387 | autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false, | 401 | autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false, |
| 388 | ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), | ||
| 389 | ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), | ||
| 390 | }).pipe(Effect.provide(runtimeServices), Scope.provide(scope)), | 402 | }).pipe(Effect.provide(runtimeServices), Scope.provide(scope)), |
| 391 | ); | 403 | ); |
| 392 | serverScope = scope; | 404 | serverScope = scope; |
| ... | @@ -1049,10 +1061,33 @@ describe("WebSocket Server", () => { | ... | @@ -1049,10 +1061,33 @@ describe("WebSocket Server", () => { |
| 1049 | }); | 1061 | }); |
| 1050 | }); | 1062 | }); |
| 1051 | 1063 | ||
| 1052 | it("supports git methods over websocket", async () => { | 1064 | it("routes git core methods over websocket", async () => { |
| 1053 | const repoCwd = makeTempDir("t3code-ws-git-project-"); | 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 | const addr = server.address(); | 1091 | const addr = server.address(); |
| 1057 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1092 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1058 | 1093 | ||
| ... | @@ -1060,23 +1095,19 @@ describe("WebSocket Server", () => { | ... | @@ -1060,23 +1095,19 @@ describe("WebSocket Server", () => { |
| 1060 | connections.push(ws); | 1095 | connections.push(ws); |
| 1061 | await waitForMessage(ws); | 1096 | await waitForMessage(ws); |
| 1062 | 1097 | ||
| 1063 | const beforeInit = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: repoCwd }); | 1098 | const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" }); |
| 1064 | expect(beforeInit.error).toBeUndefined(); | 1099 | expect(listResponse.error).toBeUndefined(); |
| 1065 | expect(beforeInit.result).toEqual({ branches: [], isRepo: false }); | 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 | expect(initResponse.error).toBeUndefined(); | 1104 | expect(initResponse.error).toBeUndefined(); |
| 1105 | expect(initRepo).toHaveBeenCalledWith({ cwd: "/repo/path" }); | ||
| 1069 | 1106 | ||
| 1070 | const afterInit = await sendRequest(ws, WS_METHODS.gitListBranches, { | 1107 | const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: "/repo/path" }); |
| 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 }); | ||
| 1077 | expect(pullResponse.result).toBeUndefined(); | 1108 | expect(pullResponse.result).toBeUndefined(); |
| 1078 | expect(pullResponse.error?.message).toBeDefined(); | 1109 | expect(pullResponse.error?.message).toContain("No upstream configured"); |
| 1079 | expect(pullResponse.error?.message).not.toContain("Unknown method"); | 1110 | expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path"); |
| 1080 | }); | 1111 | }); |
| 1081 | 1112 | ||
| 1082 | it("supports git.status over websocket", async () => { | 1113 | it("supports git.status over websocket", async () => { |
| ... | @@ -1094,10 +1125,9 @@ describe("WebSocket Server", () => { | ... | @@ -1094,10 +1125,9 @@ describe("WebSocket Server", () => { |
| 1094 | openPr: null, | 1125 | openPr: null, |
| 1095 | }; | 1126 | }; |
| 1096 | 1127 | ||
| 1097 | const gitManager = { | 1128 | const status = vi.fn(() => Effect.succeed(statusResult)); |
| 1098 | status: vi.fn().mockResolvedValue(statusResult), | 1129 | const runStackedAction = vi.fn(() => Effect.void as any); |
| 1099 | runStackedAction: vi.fn(), | 1130 | const gitManager: GitManagerShape = { status, runStackedAction }; |
| 1100 | }; | ||
| 1101 | 1131 | ||
| 1102 | server = await createTestServer({ cwd: "/test", gitManager }); | 1132 | server = await createTestServer({ cwd: "/test", gitManager }); |
| 1103 | const addr = server.address(); | 1133 | const addr = server.address(); |
| ... | @@ -1112,13 +1142,21 @@ describe("WebSocket Server", () => { | ... | @@ -1112,13 +1142,21 @@ describe("WebSocket Server", () => { |
| 1112 | }); | 1142 | }); |
| 1113 | expect(response.error).toBeUndefined(); | 1143 | expect(response.error).toBeUndefined(); |
| 1114 | expect(response.result).toEqual(statusResult); | 1144 | expect(response.result).toEqual(statusResult); |
| 1115 | expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" }); | 1145 | expect(status).toHaveBeenCalledWith({ cwd: "/test" }); |
| 1116 | }); | 1146 | }); |
| 1117 | 1147 | ||
| 1118 | it("returns errors from git.runStackedAction", async () => { | 1148 | it("returns errors from git.runStackedAction", async () => { |
| 1119 | const gitManager = { | 1149 | const runStackedAction = vi.fn(() => |
| 1120 | status: vi.fn(), | 1150 | Effect.fail( |
| 1121 | runStackedAction: vi.fn().mockRejectedValue(new Error("Cannot push from detached HEAD.")), | 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 | server = await createTestServer({ cwd: "/test", gitManager }); | 1162 | server = await createTestServer({ cwd: "/test", gitManager }); |
| ... | @@ -1135,7 +1173,7 @@ describe("WebSocket Server", () => { | ... | @@ -1135,7 +1173,7 @@ describe("WebSocket Server", () => { |
| 1135 | }); | 1173 | }); |
| 1136 | expect(response.result).toBeUndefined(); | 1174 | expect(response.result).toBeUndefined(); |
| 1137 | expect(response.error?.message).toContain("detached HEAD"); | 1175 | expect(response.error?.message).toContain("detached HEAD"); |
| 1138 | expect(gitManager.runStackedAction).toHaveBeenCalledWith({ | 1176 | expect(runStackedAction).toHaveBeenCalledWith({ |
| 1139 | cwd: "/test", | 1177 | cwd: "/test", |
| 1140 | action: "commit_push", | 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,17 +20,8 @@ import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream, Struct } |
| 20 | import { WebSocketServer, type WebSocket } from "ws"; | 20 | import { WebSocketServer, type WebSocket } from "ws"; |
| 21 | 21 | ||
| 22 | import { createLogger } from "./logger"; | 22 | import { createLogger } from "./logger"; |
| 23 | import { GitManager } from "./gitManager"; | 23 | import { GitManager } from "./git/Services/GitManager.ts"; |
| 24 | import { | 24 | import { TerminalManager } from "./terminal/Services/Manager.ts"; |
| 25 | checkoutGitBranch, | ||
| 26 | createGitBranch, | ||
| 27 | createGitWorktree, | ||
| 28 | initGitRepo, | ||
| 29 | listGitBranches, | ||
| 30 | pullGitBranch, | ||
| 31 | removeGitWorktree, | ||
| 32 | } from "./git"; | ||
| 33 | import { TerminalManager, type TerminalManagerShape } from "./terminalManager"; | ||
| 34 | import { Keybindings } from "./keybindings"; | 25 | import { Keybindings } from "./keybindings"; |
| 35 | import { searchWorkspaceEntries } from "./workspaceEntries"; | 26 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 36 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; | 27 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; |
| ... | @@ -41,6 +32,7 @@ import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuer | ... | @@ -41,6 +32,7 @@ import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuer |
| 41 | import { clamp } from "effect/Number"; | 32 | import { clamp } from "effect/Number"; |
| 42 | import { Open } from "./open"; | 33 | import { Open } from "./open"; |
| 43 | import { ServerConfig } from "./config"; | 34 | import { ServerConfig } from "./config"; |
| 35 | import { GitCore } from "./git/Services/GitCore.ts"; | ||
| 44 | 36 | ||
| 45 | const MIME_TYPES: Record<string, string> = { | 37 | const MIME_TYPES: Record<string, string> = { |
| 46 | ".html": "text/html; charset=utf-8", | 38 | ".html": "text/html; charset=utf-8", |
| ... | @@ -60,8 +52,6 @@ const MIME_TYPES: Record<string, string> = { | ... | @@ -60,8 +52,6 @@ const MIME_TYPES: Record<string, string> = { |
| 60 | export interface ServerOptions { | 52 | export interface ServerOptions { |
| 61 | autoBootstrapProjectFromCwd?: boolean | undefined; | 53 | autoBootstrapProjectFromCwd?: boolean | undefined; |
| 62 | logWebSocketEvents?: boolean | undefined; | 54 | logWebSocketEvents?: boolean | undefined; |
| 63 | gitManager?: GitManager | undefined; | ||
| 64 | terminalManager?: TerminalManagerShape | undefined; | ||
| 65 | } | 55 | } |
| 66 | 56 | ||
| 67 | export interface ServerShape { | 57 | export interface ServerShape { |
| ... | @@ -146,6 +136,8 @@ export type ServerCoreRuntimeServices = | ... | @@ -146,6 +136,8 @@ export type ServerCoreRuntimeServices = |
| 146 | 136 | ||
| 147 | export type ServerRuntimeServices = | 137 | export type ServerRuntimeServices = |
| 148 | | ServerCoreRuntimeServices | 138 | | ServerCoreRuntimeServices |
| 139 | | GitManager | ||
| 140 | | GitCore | ||
| 149 | | TerminalManager | 141 | | TerminalManager |
| 150 | | Keybindings | 142 | | Keybindings |
| 151 | | Open; | 143 | | Open; |
| ... | @@ -169,19 +161,18 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E | ... | @@ -169,19 +161,18 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 169 | > { | 161 | > { |
| 170 | const serverConfig = yield* ServerConfig; | 162 | const serverConfig = yield* ServerConfig; |
| 171 | const { port, cwd, staticDir, devUrl, authToken, mode, host } = serverConfig; | 163 | const { port, cwd, staticDir, devUrl, authToken, mode, host } = serverConfig; |
| 172 | const defaultTerminalManager = yield* TerminalManager; | ||
| 173 | const keybindingsManager = yield* Keybindings; | ||
| 174 | const { | 164 | const { |
| 175 | logWebSocketEvents: explicitLogWsEvents, | 165 | logWebSocketEvents = parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl), |
| 176 | gitManager = new GitManager(), | 166 | autoBootstrapProjectFromCwd = mode === "web", |
| 177 | terminalManager = defaultTerminalManager, | ||
| 178 | } = options; | 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 | const clients = new Set<WebSocket>(); | 174 | const clients = new Set<WebSocket>(); |
| 182 | const logger = createLogger("ws"); | 175 | const logger = createLogger("ws"); |
| 183 | const logWebSocketEvents = | ||
| 184 | explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); | ||
| 185 | 176 | ||
| 186 | function logOutgoingPush(push: WsPush, recipients: number) { | 177 | function logOutgoingPush(push: WsPush, recipients: number) { |
| 187 | if (!logWebSocketEvents) return; | 178 | if (!logWebSocketEvents) return; |
| ... | @@ -386,47 +377,47 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E | ... | @@ -386,47 +377,47 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 386 | 377 | ||
| 387 | case WS_METHODS.gitStatus: { | 378 | case WS_METHODS.gitStatus: { |
| 388 | const body = stripRequestTag(request.body); | 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 | case WS_METHODS.gitPull: { | 383 | case WS_METHODS.gitPull: { |
| 393 | const body = stripRequestTag(request.body); | 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 | case WS_METHODS.gitRunStackedAction: { | 388 | case WS_METHODS.gitRunStackedAction: { |
| 398 | const body = stripRequestTag(request.body); | 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 | case WS_METHODS.gitListBranches: { | 393 | case WS_METHODS.gitListBranches: { |
| 403 | const body = stripRequestTag(request.body); | 394 | const body = stripRequestTag(request.body); |
| 404 | return yield* Effect.promise(() => listGitBranches(body)); | 395 | return yield* git.listBranches(body); |
| 405 | } | 396 | } |
| 406 | 397 | ||
| 407 | case WS_METHODS.gitCreateWorktree: { | 398 | case WS_METHODS.gitCreateWorktree: { |
| 408 | const body = stripRequestTag(request.body); | 399 | const body = stripRequestTag(request.body); |
| 409 | return yield* Effect.promise(() => createGitWorktree(body)); | 400 | return yield* git.createWorktree(body); |
| 410 | } | 401 | } |
| 411 | 402 | ||
| 412 | case WS_METHODS.gitRemoveWorktree: { | 403 | case WS_METHODS.gitRemoveWorktree: { |
| 413 | const body = stripRequestTag(request.body); | 404 | const body = stripRequestTag(request.body); |
| 414 | return yield* Effect.promise(() => removeGitWorktree(body)); | 405 | return yield* git.removeWorktree(body); |
| 415 | } | 406 | } |
| 416 | 407 | ||
| 417 | case WS_METHODS.gitCreateBranch: { | 408 | case WS_METHODS.gitCreateBranch: { |
| 418 | const body = stripRequestTag(request.body); | 409 | const body = stripRequestTag(request.body); |
| 419 | return yield* Effect.promise(() => createGitBranch(body)); | 410 | return yield* git.createBranch(body); |
| 420 | } | 411 | } |
| 421 | 412 | ||
| 422 | case WS_METHODS.gitCheckout: { | 413 | case WS_METHODS.gitCheckout: { |
| 423 | const body = stripRequestTag(request.body); | 414 | const body = stripRequestTag(request.body); |
| 424 | return yield* Effect.promise(() => checkoutGitBranch(body)); | 415 | return yield* git.checkoutBranch(body); |
| 425 | } | 416 | } |
| 426 | 417 | ||
| 427 | case WS_METHODS.gitInit: { | 418 | case WS_METHODS.gitInit: { |
| 428 | const body = stripRequestTag(request.body); | 419 | const body = stripRequestTag(request.body); |
| 429 | return yield* Effect.promise(() => initGitRepo(body)); | 420 | return yield* git.initRepo(body); |
| 430 | } | 421 | } |
| 431 | 422 | ||
| 432 | case WS_METHODS.terminalOpen: { | 423 | case WS_METHODS.terminalOpen: { |
| ... | @@ -579,9 +570,7 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E | ... | @@ -579,9 +570,7 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 579 | unsubscribeTerminalEvents(); | 570 | unsubscribeTerminalEvents(); |
| 580 | }), | 571 | }), |
| 581 | ); | 572 | ); |
| 582 | yield* Effect.addFinalizer(() => | 573 | yield* Effect.addFinalizer(terminalManager.dispose); |
| 583 | terminalManager.dispose().pipe(Effect.orElseSucceed(() => undefined)), | ||
| 584 | ); | ||
| 585 | 574 | ||
| 586 | yield* NodeHttpServer.make(() => httpServer, listenOptions); | 575 | yield* NodeHttpServer.make(() => httpServer, listenOptions); |
| 587 | 576 |