authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-25 17:41:10-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log52681634cdf71e05a8df069664953b42352a388c
tree0a4e87fa1fe68ae55cc0f0f7afc0eef5bc6d902e
parent35f39435f3feb5bc6b44d25d069cf29c98e99ed5

refactor git manager


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";
1515
16import { CheckpointInvariantError } from "../Errors.ts";16import { CheckpointInvariantError } from "../Errors.ts";
17import { GitCommandError } from "../../git/Errors.ts";17import { GitCommandError } from "../../git/Errors.ts";
18import { GitServiceLive } from "../../git/Layer.ts";18import { GitServiceLive } from "../../git/Layers/GitService.ts";
19import { GitService } from "../../git/Service.ts";19import { GitService } from "../../git/Services/GitService.ts";
20import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";20import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
21import { CheckpointRef } from "@t3tools/contracts";21import { CheckpointRef } from "@t3tools/contracts";
2222
apps/server/src/codexTextGenerator.ts deleted-254
...@@ -1,254 +0,0 @@
1import { randomUUID } from "node:crypto";
2import fs from "node:fs/promises";
3import os from "node:os";
4import path from "node:path";
5
6import type {
7 CommitMessageGenerationInput,
8 CommitMessageGenerationResult,
9 PrContentGenerationInput,
10 PrContentGenerationResult,
11 TextGenerationService,
12} from "./coreServices";
13import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner";
14
15interface ProcessRunner {
16 (
17 command: string,
18 args: readonly string[],
19 options?: ProcessRunOptions,
20 ): Promise<ProcessRunResult>;
21}
22
23const CODEX_MODEL = "gpt-5.3-codex";
24const CODEX_REASONING_EFFORT = "low";
25
26const 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
36const 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
46function 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
59function 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
72function 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
78async 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
84async function safeUnlink(filePath: string): Promise<void> {
85 try {
86 await fs.unlink(filePath);
87 } catch {
88 // Best-effort cleanup.
89 }
90}
91
92function 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
105function 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
113async 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
173interface CodexTextGeneratorDeps {
174 runProcess?: ProcessRunner;
175}
176
177export 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 @@
1export interface CommitMessageGenerationInput {
2 cwd: string;
3 branch: string | null;
4 stagedSummary: string;
5 stagedPatch: string;
6}
7
8export interface CommitMessageGenerationResult {
9 subject: string;
10 body: string;
11}
12
13export interface PrContentGenerationInput {
14 cwd: string;
15 baseBranch: string;
16 headBranch: string;
17 commitSummary: string;
18 diffSummary: string;
19 diffPatch: string;
20}
21
22export interface PrContentGenerationResult {
23 title: string;
24 body: string;
25}
26
27export 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 @@
1import { existsSync } from "node:fs";
2import { mkdtemp, rm, writeFile } from "node:fs/promises";
3import { tmpdir } from "node:os";
4import path from "node:path";
5import { describe, expect, it, vi } from "vitest";
6
7import {
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). */
22async 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. */
35async 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. */
46async 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
55async 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
76describe("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 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import {
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";
19import { type ProcessRunResult, runProcess } from "./processRunner";
20
21export interface TerminalCommandInput {
22 command: string;
23 cwd: string;
24 timeoutMs?: number;
25 maxOutputBytes?: number;
26}
27
28export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> {
29 upstreamRef: string | null;
30}
31
32export interface GitPreparedCommitContext {
33 stagedSummary: string;
34 stagedPatch: string;
35}
36
37export interface GitPushResult {
38 status: "pushed" | "skipped_up_to_date";
39 branch: string;
40 upstreamBranch?: string | undefined;
41 setUpstream?: boolean | undefined;
42}
43
44export interface GitRangeContext {
45 commitSummary: string;
46 diffSummary: string;
47 diffPatch: string;
48}
49
50interface RunGitOptions {
51 timeoutMs?: number | undefined;
52 allowNonZeroExit?: boolean | undefined;
53}
54
55interface ExecuteGitOptions {
56 timeoutMs?: number | undefined;
57 allowNonZeroExit?: boolean | undefined;
58 fallbackErrorMessage?: string | undefined;
59}
60
61const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
62const STATUS_UPSTREAM_REFRESH_INTERVAL_MS = 15_000;
63const STATUS_UPSTREAM_REFRESH_TIMEOUT_MS = 5_000;
64
65/** Spawn git directly with an argv array — no shell, no quoting needed. */
66function 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
80function trimStdout(value: string): string {
81 return value.trim();
82}
83
84function 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
93function 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
117function 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
139function commandLabel(args: readonly string[]): string {
140 return `git ${args.join(" ")}`;
141}
142
143function 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
156function 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
169async 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
190async 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
214export 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
672const defaultGitCoreService = new GitCoreService();
673
674export 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
702export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {
703 return defaultGitCoreService.listBranches(input);
704}
705
706export async function pullGitBranch(input: GitPullInput): Promise<GitPullResult> {
707 return defaultGitCoreService.pullCurrentBranch(input.cwd);
708}
709
710export async function createGitWorktree(
711 input: GitCreateWorktreeInput,
712): Promise<GitCreateWorktreeResult> {
713 return defaultGitCoreService.createWorktree(input);
714}
715
716export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> {
717 await defaultGitCoreService.removeWorktree(input);
718}
719
720export async function createGitBranch(input: GitCreateBranchInput): Promise<void> {
721 await defaultGitCoreService.createBranch(input);
722}
723
724export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> {
725 await defaultGitCoreService.checkoutBranch(input);
726}
727
728export 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 */
21export 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 */
34export 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 */
50export 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 */
63export type GitCoreError = GitCommandError;
64
65/**
66 * GitHubCliServiceError - Errors emitted by GitHub CLI service helpers.
67 */
68export type GitHubCliServiceError = GitHubCliError;
69
70/**
71 * TextGenerationServiceError - Errors emitted by commit/PR text generation.
72 */
73export type TextGenerationServiceError = TextGenerationError;
74
75/**
76 * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration.
77 */
78export type GitManagerServiceError =
79 | GitManagerError
80 | GitCoreError
81 | GitHubCliServiceError
82 | TextGenerationServiceError;
apps/server/src/git/Layer.test.ts deleted-59
...@@ -1,59 +0,0 @@
1import { NodeServices } from "@effect/platform-node";
2import { it, assert } from "@effect/vitest";
3import { Effect, Layer } from "effect";
4
5import { GitCommandError } from "./Errors.ts";
6import { GitServiceLive } from "./Layer.ts";
7import { GitService } from "./Service.ts";
8
9const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer));
10
11layer("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 */
9import { Effect, Layer, Option, Stream } from "effect";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11import { GitCommandError } from "./Errors.ts";
12import { ExecuteGitInput, ExecuteGitResult, GitService, GitServiceShape } from "./Service.ts";
13
14const DEFAULT_TIMEOUT_MS = 30_000;
15const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
16
17function quoteGitCommand(args: ReadonlyArray<string>): string {
18 return `git ${args.join(" ")}`;
19}
20
21function 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
37const 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
65const 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
141export const GitServiceLive = Layer.effect(GitService, makeGitService);
apps/server/src/git/Layers/CodexTextGeneration.test.ts created+205
...@@ -0,0 +1,205 @@
1import { NodeServices } from "@effect/platform-node";
2import { it } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Path } from "effect";
4import { expect } from "vitest";
5
6import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
7import { TextGenerationError } from "../Errors.ts";
8import { TextGeneration } from "../Services/TextGeneration.ts";
9
10function 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
46function 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
114const CodexTextGenerationTestLayer = Layer.provideMerge(
115 CodexTextGenerationLive,
116 NodeServices.layer,
117);
118
119it.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 @@
1import { randomUUID } from "node:crypto";
2
3import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect";
4import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
5
6import { TextGenerationError } from "../Errors.ts";
7import {
8 type CommitMessageGenerationResult,
9 type PrContentGenerationResult,
10 type TextGenerationShape,
11 TextGeneration,
12} from "../Services/TextGeneration.ts";
13
14const CODEX_MODEL = "gpt-5.3-codex";
15const CODEX_REASONING_EFFORT = "low";
16const CODEX_TIMEOUT_MS = 180_000;
17
18const 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
28const 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
38function 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
74function 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
87function 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
100function 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
106function 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
119function 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
127const 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
393export const CodexTextGenerationLive = Layer.effect(TextGeneration, makeCodexTextGeneration);
apps/server/src/git/Layers/GitCore.test.ts created+1157
...@@ -0,0 +1,1157 @@
1import { existsSync } from "node:fs";
2import path from "node:path";
3
4import { NodeServices } from "@effect/platform-node";
5import { it } from "@effect/vitest";
6import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
7import { describe, expect, vi } from "vitest";
8
9import { GitServiceLive } from "./GitService.ts";
10import { GitService, type GitServiceShape } from "../Services/GitService.ts";
11import { GitCoreLive } from "./GitCore.ts";
12import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
13import { GitCommandError } from "../Errors.ts";
14import { type ProcessRunResult, runProcess } from "../../processRunner.ts";
15
16// ── Helpers ──
17
18const GitServiceTestLayer = GitServiceLive.pipe(Layer.provide(NodeServices.layer));
19const GitCoreTestLayer = GitCoreLive.pipe(
20 Layer.provide(GitServiceTestLayer),
21 Layer.provide(NodeServices.layer),
22);
23const TestLayer = Layer.mergeAll(NodeServices.layer, GitServiceTestLayer, GitCoreTestLayer);
24
25function 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
34function 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). */
45function 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
63function 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
88const 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
115function 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
122function 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
129function 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
136function 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
143function 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
150function 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
157function 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. */
165function 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
182function 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
206it.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 @@
1import { Effect, FileSystem, Layer, Path } from "effect";
2
3import { GitCommandError } from "../Errors.ts";
4import { GitService } from "../Services/GitService.ts";
5import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
6
7const STATUS_UPSTREAM_REFRESH_INTERVAL_MS = 15_000;
8const STATUS_UPSTREAM_REFRESH_TIMEOUT_MS = 5_000;
9
10interface ExecuteGitOptions {
11 timeoutMs?: number | undefined;
12 allowNonZeroExit?: boolean | undefined;
13 fallbackErrorMessage?: string | undefined;
14}
15
16function trimStdout(value: string): string {
17 return value.trim();
18}
19
20function 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
29function 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
53function 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
75function commandLabel(args: readonly string[]): string {
76 return `git ${args.join(" ")}`;
77}
78
79function 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
95const 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
746export const GitCoreLive = Layer.effect(GitCore, makeGitCore);
apps/server/src/git/Layers/GitHubCli.ts created+178
...@@ -0,0 +1,178 @@
1import { Effect, Layer } from "effect";
2
3import { runProcess } from "../../processRunner";
4import { GitHubCliError } from "../Errors.ts";
5import { GitHubCli, type GitHubCliShape } from "../Services/GitHubCli.ts";
6
7const DEFAULT_TIMEOUT_MS = 30_000;
8
9function 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
47function 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
102const 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
178export const GitHubCliLive = Layer.effect(GitHubCli, makeGitHubCli);
apps/server/src/git/Layers/GitManager.test.ts created+660
...@@ -0,0 +1,660 @@
1import fs from "node:fs";
2import path from "node:path";
3
4import { NodeServices } from "@effect/platform-node";
5import { it } from "@effect/vitest";
6import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
7import { expect } from "vitest";
8
9import { GitHubCliError, TextGenerationError } from "../Errors.ts";
10import { type GitManagerShape } from "../Services/GitManager.ts";
11import {
12 type GitHubCliShape,
13 type GitHubPullRequestSummary,
14 GitHubCli,
15} from "../Services/GitHubCli.ts";
16import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
17import { GitServiceLive } from "./GitService.ts";
18import { GitService } from "../Services/GitService.ts";
19import { GitCoreLive } from "./GitCore.ts";
20import { makeGitManager } from "./GitManager.ts";
21
22interface FakeGhScenario {
23 prListSequence?: string[];
24 createdPrUrl?: string;
25 defaultBranch?: string;
26 failWith?: Error;
27}
28
29interface 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
46function 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
55function 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
75function 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
89function 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
101function 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
142const 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
170function 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
289function 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
300function 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
325const GitManagerTestLayer = Layer.provideMerge(GitServiceLive, NodeServices.layer);
326
327it.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 @@
1import { randomUUID } from "node:crypto";
2
3import { Effect, FileSystem, Layer, Path } from "effect";
4
5import { GitManagerError } from "../Errors.ts";
6import { GitManager, type GitManagerShape } from "../Services/GitManager.ts";
7import { GitCore } from "../Services/GitCore.ts";
8import { GitHubCli } from "../Services/GitHubCli.ts";
9import { TextGeneration } from "../Services/TextGeneration.ts";
10
11interface OpenPrInfo {
12 number: number;
13 title: string;
14 url: string;
15 baseRefName: string;
16 headRefName: string;
17}
18
19function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError {
20 return new GitManagerError({
21 operation,
22 detail,
23 ...(cause !== undefined ? { cause } : {}),
24 });
25}
26
27function limitContext(value: string, maxChars: number): string {
28 if (value.length <= maxChars) return value;
29 return `${value.slice(0, maxChars)}\n\n[truncated]`;
30}
31
32function 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
48function 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
66function 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
85function 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
101export 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
318export const GitManagerLive = Layer.effect(GitManager, makeGitManager);
apps/server/src/git/Layers/GitService.test.ts created+59
...@@ -0,0 +1,59 @@
1import { NodeServices } from "@effect/platform-node";
2import { it, assert } from "@effect/vitest";
3import { Effect, Layer } from "effect";
4
5import { GitCommandError } from "../Errors.ts";
6import { GitServiceLive } from "./GitService.ts";
7import { GitService } from "../Services/GitService.ts";
8
9const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer));
10
11layer("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 */
9import { Effect, Layer, Option, Stream } from "effect";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11import { GitCommandError } from "../Errors.ts";
12import {
13 ExecuteGitInput,
14 ExecuteGitResult,
15 GitService,
16 GitServiceShape,
17} from "../Services/GitService.ts";
18
19const DEFAULT_TIMEOUT_MS = 30_000;
20const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
21
22function quoteGitCommand(args: ReadonlyArray<string>): string {
23 return `git ${args.join(" ")}`;
24}
25
26function 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
42const 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
70const 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
146export 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 */
9import { ServiceMap } from "effect";
10import type { Effect } from "effect";
11
12import type { GitCommandError } from "./Errors.ts";
13
14export 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
24export interface ExecuteGitResult {
25 readonly code: number;
26 readonly stdout: string;
27 readonly stderr: string;
28}
29
30export 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 */
40export 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 */
9import { ServiceMap } from "effect";
10import type { Effect } from "effect";
11import 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
25import type { GitCommandError } from "../Errors.ts";
26
27export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> {
28 upstreamRef: string | null;
29}
30
31export interface GitPreparedCommitContext {
32 stagedSummary: string;
33 stagedPatch: string;
34}
35
36export interface GitPushResult {
37 status: "pushed" | "skipped_up_to_date";
38 branch: string;
39 upstreamBranch?: string | undefined;
40 setUpstream?: boolean | undefined;
41}
42
43export 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 */
52export 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 */
146export 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 */
8import { ServiceMap } from "effect";
9import type { Effect } from "effect";
10
11import type { ProcessRunResult } from "../../processRunner";
12import type { GitHubCliError } from "../Errors.ts";
13
14export 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 */
25export 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 */
66export 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 */
9import {
10 GitRunStackedActionInput,
11 GitRunStackedActionResult,
12 GitStatusInput,
13 GitStatusResult,
14} from "@t3tools/contracts";
15import { ServiceMap } from "effect";
16import type { Effect } from "effect";
17import type { GitManagerServiceError } from "../Errors.ts";
18
19/**
20 * GitManagerShape - Service API for high-level Git workflow actions.
21 */
22export 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 */
39export 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 */
9import { ServiceMap } from "effect";
10import type { Effect } from "effect";
11
12import type { GitCommandError } from "../Errors.ts";
13
14export 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
24export interface ExecuteGitResult {
25 readonly code: number;
26 readonly stdout: string;
27 readonly stderr: string;
28}
29
30export 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 */
40export 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 */
9import { ServiceMap } from "effect";
10import type { Effect } from "effect";
11
12import type { TextGenerationError } from "../Errors.ts";
13
14export interface CommitMessageGenerationInput {
15 cwd: string;
16 branch: string | null;
17 stagedSummary: string;
18 stagedPatch: string;
19}
20
21export interface CommitMessageGenerationResult {
22 subject: string;
23 body: string;
24}
25
26export interface PrContentGenerationInput {
27 cwd: string;
28 baseBranch: string;
29 headBranch: string;
30 commitSummary: string;
31 diffSummary: string;
32 diffPatch: string;
33}
34
35export interface PrContentGenerationResult {
36 title: string;
37 body: string;
38}
39
40export 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 */
50export 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 */
69export class TextGeneration extends ServiceMap.Service<TextGeneration, TextGenerationShape>()(
70 "git/TextGeneration",
71) {}
apps/server/src/gitManager.test.ts deleted-482
...@@ -1,482 +0,0 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { afterEach, describe, expect, it } from "vitest";
6
7import { GitManager } from "./gitManager";
8import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner";
9
10interface FakeGhScenario {
11 prListSequence?: string[];
12 createdPrUrl?: string;
13 defaultBranch?: string;
14 failWith?: Error;
15}
16
17interface 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
34const tempDirs: string[] = [];
35
36function makeTempDir(prefix: string): string {
37 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
38 tempDirs.push(dir);
39 return dir;
40}
41
42async function runGit(
43 cwd: string,
44 args: readonly string[],
45 allowNonZeroExit = false,
46): Promise<ProcessRunResult> {
47 return runProcess("git", args, { cwd, allowNonZeroExit });
48}
49
50async function runGitStdout(cwd: string, args: readonly string[]): Promise<string> {
51 const result = await runGit(cwd, args);
52 return result.stdout.trim();
53}
54
55async 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
64async function createBareRemote(): Promise<string> {
65 const remoteDir = makeTempDir("t3code-git-remote-");
66 await runProcess("git", ["init", "--bare"], { cwd: remoteDir });
67 return remoteDir;
68}
69
70function 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
84function 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
157afterEach(() => {
158 for (const dir of tempDirs.splice(0, tempDirs.length)) {
159 fs.rmSync(dir, { recursive: true, force: true });
160 }
161});
162
163describe("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 @@
1import { randomUUID } from "node:crypto";
2import fs from "node:fs/promises";
3import os from "node:os";
4import path from "node:path";
5
6import {
7 GitRunStackedActionInput,
8 GitRunStackedActionResult,
9 GitStatusInput,
10 GitStatusResult,
11} from "@t3tools/contracts";
12
13import type {
14 CommitMessageGenerationResult,
15 PrContentGenerationResult,
16 TextGenerationService,
17} from "./coreServices";
18import { CodexTextGenerator } from "./codexTextGenerator";
19import { GitCoreService } from "./git";
20import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner";
21
22const GH_CLI_TIMEOUT_MS = 30_000;
23
24interface GitManagerDeps {
25 runProcess?: (
26 command: string,
27 args: readonly string[],
28 options?: ProcessRunOptions,
29 ) => Promise<ProcessRunResult>;
30 textGenerator?: TextGenerationService;
31 gitCore?: GitCoreService;
32}
33
34interface OpenPrInfo {
35 number: number;
36 title: string;
37 url: string;
38 baseRefName: string;
39 headRefName: string;
40}
41
42function 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
76function trimStdout(value: string): string {
77 return value.trim();
78}
79
80function limitContext(value: string, maxChars: number): string {
81 if (value.length <= maxChars) return value;
82 return `${value.slice(0, maxChars)}\n\n[truncated]`;
83}
84
85function 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
97function 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
115function 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
134function 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
146function 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
160function 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
176export 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 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { assert, it, vi } from "@effect/vitest";
6import { Effect, Layer } from "effect";
7import { afterEach } from "vitest";
8
9import { ensureNodePtySpawnHelperExecutable } from "./ptyAdapter";
10
11function fileMode(filePath: string): number {
12 return fs.statSync(filePath).mode & 0o777;
13}
14
15const tempDirs: string[] = [];
16
17afterEach(() => {
18 vi.unstubAllGlobals();
19 for (const dir of tempDirs.splice(0, tempDirs.length)) {
20 fs.rmSync(dir, { recursive: true, force: true });
21 }
22});
23
24const helperLayer = it.layer(Layer.empty);
25
26helperLayer("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 @@
1import fs from "node:fs";
2import { createRequire } from "node:module";
3import path from "node:path";
4
5import { Effect, Layer, ServiceMap } from "effect";
6
7export interface PtyExitEvent {
8 exitCode: number;
9 signal: number | null;
10}
11
12export 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
21export interface PtySpawnInput {
22 shell: string;
23 args?: string[];
24 cwd: string;
25 cols: number;
26 rows: number;
27 env: NodeJS.ProcessEnv;
28}
29
30export interface PtyAdapterShape {
31 spawn(input: PtySpawnInput): PtyProcess;
32}
33
34export class PtyAdapter extends ServiceMap.Service<PtyAdapter, PtyAdapterShape>()(
35 "terminal/PtyAdapter",
36) {
37 static readonly layer = () =>
38 typeof Bun !== "undefined" ? BunPtyAdapterLive : NodePtyAdapterLive;
39}
40
41const requireForNodePty = createRequire(import.meta.url);
42let didEnsureSpawnHelperExecutable = false;
43
44function 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
66export 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
87class 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
126class 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}
210export 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
230export 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";
7import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";7import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";
8import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";8import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
9import { ServerConfig } from "./config";9import { ServerConfig } from "./config";
10import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
11import { CheckpointStore } from "./checkpointing/Services/CheckpointStore";
12import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";10import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
13import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";11import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
14import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";12import { 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
19import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";17import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
20import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";18import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
21import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";19import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
22import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
23import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
24import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
25import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";20import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
26import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";21import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
27import { makeProviderServiceLive } from "./provider/Layers/ProviderService";22import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
28import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";23import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
29import { ProviderService } from "./provider/Services/ProviderService";24import { ProviderService } from "./provider/Services/ProviderService";
3025
31import { makeTerminalManagerLive, TerminalManager } from "./terminalManager";26import { TerminalManagerLive } from "./terminal/Layers/Manager";
32import { PtyAdapter } from "./ptyAdapter";27import { KeybindingsLive } from "./keybindings";
33import { Keybindings, KeybindingsLive } from "./keybindings";28import { GitManagerLive } from "./git/Layers/GitManager";
29import { GitCoreLive } from "./git/Layers/GitCore";
30import { GitHubCliLive } from "./git/Layers/GitHubCli";
31import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration";
32import { GitServiceLive } from "./git/Layers/GitService";
33import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY";
34import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY";
3435
35export function makeServerProviderLayer(): Layer.Layer<36export 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}
5556
56export function makeServerRuntimeServicesLayer(): Layer.Layer<57export 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 );
9889
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);
10595
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 );
107101
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 @@
1import { Effect, Layer } from "effect";
2import { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY";
3
4class 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
89export 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 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import {
6 DEFAULT_TERMINAL_ID,
7 type TerminalEvent,
8 type TerminalOpenInput,
9} from "@t3tools/contracts";
10import { afterEach, describe, expect, it } from "vitest";
11
12import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY";
13import { TerminalManagerRuntime } from "./Manager";
14import { Encoding } from "effect";
15
16class 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
64class 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
82function 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
100function 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
110function historyLogName(threadId: string): string {
111 return `terminal_${Encoding.encodeBase64Url(threadId)}.log`;
112}
113
114function 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
122function historyLogPath(logsDir: string, threadId = "thread-1"): string {
123 return path.join(logsDir, historyLogName(threadId));
124}
125
126function multiTerminalHistoryLogPath(
127 logsDir: string,
128 threadId = "thread-1",
129 terminalId = "default",
130): string {
131 return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId));
132}
133
134describe("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 @@
1import { EventEmitter } from "node:events";
2import fs from "node:fs";
3import path from "node:path";
4
5import {
6 DEFAULT_TERMINAL_ID,
7 TerminalClearInput,
8 TerminalCloseInput,
9 TerminalOpenInput,
10 TerminalResizeInput,
11 TerminalWriteInput,
12 type TerminalEvent,
13 type TerminalSessionSnapshot,
14} from "@t3tools/contracts";
15import { Effect, Encoding, Layer, Path, Schema } from "effect";
16
17import { createLogger } from "../../logger";
18import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "../Services/PTY";
19import { runProcess } from "../../processRunner";
20import { ServerConfig } from "../../config";
21import {
22 ShellCandidate,
23 TerminalManager,
24 TerminalManagerEvents,
25 TerminalManagerOptions,
26 TerminalManagerShape,
27 TerminalSessionState,
28 TerminalStartInput,
29} from "../Services/Manager";
30
31const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
32const DEFAULT_PERSIST_DEBOUNCE_MS = 40;
33const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000;
34const DEFAULT_OPEN_COLS = 120;
35const DEFAULT_OPEN_ROWS = 30;
36const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
37
38const decodeTerminalOpenInput = Schema.decodeUnknownSync(TerminalOpenInput);
39const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput);
40const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput);
41const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput);
42const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput);
43
44type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>;
45
46function defaultShellResolver(): string {
47 if (process.platform === "win32") {
48 return process.env.ComSpec ?? "cmd.exe";
49 }
50 return process.env.SHELL ?? "bash";
51}
52
53function 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
67function 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
76function formatShellCandidate(candidate: ShellCandidate): string {
77 if (!candidate.args || candidate.args.length === 0) return candidate.shell;
78 return `${candidate.shell} ${candidate.args.join(" ")}`;
79}
80
81function 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
94function 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
118function 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
130async 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
153async 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
197async 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
207function 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
219function legacySafeThreadId(threadId: string): string {
220 return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");
221}
222
223function toSafeThreadId(threadId: string): string {
224 return `terminal_${Encoding.encodeBase64Url(threadId)}`;
225}
226
227function toSafeTerminalId(terminalId: string): string {
228 return Encoding.encodeBase64Url(terminalId);
229}
230
231function toSessionKey(threadId: string, terminalId: string): string {
232 return `${threadId}\u0000${terminalId}`;
233}
234
235function 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
246function 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
264function 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
273export 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
1017export 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 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { assert, it, vi } from "@effect/vitest";
6import { Effect, Layer } from "effect";
7import { afterEach } from "vitest";
8
9import { ensureNodePtySpawnHelperExecutable } from "./NodePTY";
10
11function fileMode(filePath: string): number {
12 return fs.statSync(filePath).mode & 0o777;
13}
14
15const tempDirs: string[] = [];
16
17afterEach(() => {
18 vi.unstubAllGlobals();
19 for (const dir of tempDirs.splice(0, tempDirs.length)) {
20 fs.rmSync(dir, { recursive: true, force: true });
21 }
22});
23
24const helperLayer = it.layer(Layer.empty);
25
26helperLayer("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 @@
1import fs from "node:fs";
2import { createRequire } from "node:module";
3import path from "node:path";
4
5import { Effect, Layer } from "effect";
6import { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY";
7
8const requireForNodePty = createRequire(import.meta.url);
9let didEnsureSpawnHelperExecutable = false;
10
11function 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
33export 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
54class 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
93export 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 @@
1import {
2 TerminalClearInput,
3 TerminalCloseInput,
4 TerminalEvent,
5 TerminalOpenInput,
6 TerminalResizeInput,
7 TerminalSessionSnapshot,
8 TerminalSessionStatus,
9 TerminalWriteInput,
10} from "@t3tools/contracts";
11import { PtyAdapterShape, PtyProcess } from "./PTY";
12import { Effect, ServiceMap } from "effect";
13
14type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>;
15
16export interface TerminalManagerEvents {
17 event: [event: TerminalEvent];
18}
19
20export 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
39export interface ShellCandidate {
40 shell: string;
41 args?: string[];
42}
43
44export interface TerminalStartInput extends TerminalOpenInput {
45 cols: number;
46 rows: number;
47}
48
49export interface TerminalManagerOptions {
50 logsDir?: string;
51 historyLineLimit?: number;
52 ptyAdapter: PtyAdapterShape;
53 shellResolver?: () => string;
54 subprocessChecker?: TerminalSubprocessChecker;
55 subprocessPollIntervalMs?: number;
56}
57
58export 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
69export class TerminalManager extends ServiceMap.Service<TerminalManager, TerminalManagerShape>()(
70 "terminal/TerminalManager",
71) {}
apps/server/src/terminal/Services/PTY.ts created+32
...@@ -0,0 +1,32 @@
1import { ServiceMap } from "effect";
2
3export interface PtyExitEvent {
4 exitCode: number;
5 signal: number | null;
6}
7
8export 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
17export interface PtySpawnInput {
18 shell: string;
19 args?: string[];
20 cwd: string;
21 cols: number;
22 rows: number;
23 env: NodeJS.ProcessEnv;
24}
25
26export interface PtyAdapterShape {
27 spawn(input: PtySpawnInput): PtyProcess;
28}
29
30export class PtyAdapter extends ServiceMap.Service<PtyAdapter, PtyAdapterShape>()(
31 "terminal/PtyAdapter",
32) {}
apps/server/src/terminalManager.test.ts deleted-567
...@@ -1,567 +0,0 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import {
6 DEFAULT_TERMINAL_ID,
7 type TerminalEvent,
8 type TerminalOpenInput,
9} from "@t3tools/contracts";
10import { afterEach, describe, expect, it } from "vitest";
11
12import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter";
13import { TerminalManagerRuntime } from "./terminalManager";
14import { Encoding } from "effect";
15
16class 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
64class 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
82function 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
100function 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
110function historyLogName(threadId: string): string {
111 return `terminal_${Encoding.encodeBase64Url(threadId)}.log`;
112}
113
114function 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
122function historyLogPath(logsDir: string, threadId = "thread-1"): string {
123 return path.join(logsDir, historyLogName(threadId));
124}
125
126function multiTerminalHistoryLogPath(
127 logsDir: string,
128 threadId = "thread-1",
129 terminalId = "default",
130): string {
131 return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId));
132}
133
134describe("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 @@
1import { EventEmitter } from "node:events";
2import fs from "node:fs";
3import path from "node:path";
4
5import {
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";
16import { Effect, Encoding, Layer, Schema, ServiceMap } from "effect";
17
18import { createLogger } from "./logger";
19import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "./ptyAdapter";
20import { runProcess } from "./processRunner";
21
22const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
23const DEFAULT_PERSIST_DEBOUNCE_MS = 40;
24const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000;
25const DEFAULT_OPEN_COLS = 120;
26const DEFAULT_OPEN_ROWS = 30;
27const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
28
29const decodeTerminalOpenInput = Schema.decodeUnknownSync(TerminalOpenInput);
30const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput);
31const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput);
32const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput);
33const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput);
34
35type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>;
36
37export interface TerminalManagerEvents {
38 event: [event: TerminalEvent];
39}
40
41export interface TerminalManagerOptions {
42 logsDir?: string;
43 historyLineLimit?: number;
44 ptyAdapter: PtyAdapterShape;
45 shellResolver?: () => string;
46 subprocessChecker?: TerminalSubprocessChecker;
47 subprocessPollIntervalMs?: number;
48}
49
50export 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
61export class TerminalManager extends ServiceMap.Service<TerminalManager, TerminalManagerShape>()(
62 "terminal/TerminalManager",
63) {}
64
65interface 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
84interface ShellCandidate {
85 shell: string;
86 args?: string[];
87}
88
89interface TerminalStartInput extends TerminalOpenInput {
90 cols: number;
91 rows: number;
92}
93
94function defaultShellResolver(): string {
95 if (process.platform === "win32") {
96 return process.env.ComSpec ?? "cmd.exe";
97 }
98 return process.env.SHELL ?? "bash";
99}
100
101function 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
115function 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
124function formatShellCandidate(candidate: ShellCandidate): string {
125 if (!candidate.args || candidate.args.length === 0) return candidate.shell;
126 return `${candidate.shell} ${candidate.args.join(" ")}`;
127}
128
129function 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
142function 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
166function 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
178async 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
201async 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
245async 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
255function 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
267function legacySafeThreadId(threadId: string): string {
268 return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");
269}
270
271function toSafeThreadId(threadId: string): string {
272 return `terminal_${Encoding.encodeBase64Url(threadId)}`;
273}
274
275function toSafeTerminalId(terminalId: string): string {
276 return Encoding.encodeBase64Url(terminalId);
277}
278
279function toSessionKey(threadId: string, terminalId: string): string {
280 return `${threadId}\u0000${terminalId}`;
281}
282
283function 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
294function 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
312function 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
321export 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
1065export 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";
40import type { TerminalManagerShape } from "./terminalManager";40import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager";
41import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";41import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
42import { SqlClient } from "effect/unstable/sql";42import { SqlClient } from "effect/unstable/sql";
43import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";43import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
44import { Open, type OpenShape } from "./open";44import { Open, type OpenShape } from "./open";
45import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
46import type { GitCoreShape } from "./git/Services/GitCore.ts";
47import { GitCore } from "./git/Services/GitCore.ts";
48import { GitCommandError, GitManagerError } from "./git/Errors.ts";
4549
46interface PendingMessages {50interface 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 });
10511063
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 );
10541082
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;
10581093
...@@ -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);
10621097
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" });
10661102
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" });
10691106
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 });
10811112
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 };
10961127
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 };
11011131
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 });
11171147
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 };
11231161
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 }
20import { WebSocketServer, type WebSocket } from "ws";20import { WebSocketServer, type WebSocket } from "ws";
2121
22import { createLogger } from "./logger";22import { createLogger } from "./logger";
23import { GitManager } from "./gitManager";23import { GitManager } from "./git/Services/GitManager.ts";
24import {24import { TerminalManager } from "./terminal/Services/Manager.ts";
25 checkoutGitBranch,
26 createGitBranch,
27 createGitWorktree,
28 initGitRepo,
29 listGitBranches,
30 pullGitBranch,
31 removeGitWorktree,
32} from "./git";
33import { TerminalManager, type TerminalManagerShape } from "./terminalManager";
34import { Keybindings } from "./keybindings";25import { Keybindings } from "./keybindings";
35import { searchWorkspaceEntries } from "./workspaceEntries";26import { searchWorkspaceEntries } from "./workspaceEntries";
36import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";27import { 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
41import { clamp } from "effect/Number";32import { clamp } from "effect/Number";
42import { Open } from "./open";33import { Open } from "./open";
43import { ServerConfig } from "./config";34import { ServerConfig } from "./config";
35import { GitCore } from "./git/Services/GitCore.ts";
4436
45const MIME_TYPES: Record<string, string> = {37const 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> = {
60export interface ServerOptions {52export 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}
6656
67export interface ServerShape {57export interface ServerShape {
...@@ -146,6 +136,8 @@ export type ServerCoreRuntimeServices =...@@ -146,6 +136,8 @@ export type ServerCoreRuntimeServices =
146136
147export type ServerRuntimeServices =137export type ServerRuntimeServices =
148 | ServerCoreRuntimeServices138 | ServerCoreRuntimeServices
139 | GitManager
140 | GitCore
149 | TerminalManager141 | TerminalManager
150 | Keybindings142 | 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;
180173
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);
185176
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
386377
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 }
391382
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 }
396387
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 }
401392
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 }
406397
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 }
411402
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 }
416407
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 }
421412
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 }
426417
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 }
431422
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 );
585574
586 yield* NodeHttpServer.make(() => httpServer, listenOptions);575 yield* NodeHttpServer.make(() => httpServer, listenOptions);
587576