authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 11:15:11-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 11:15:11-08:00
log6fc632f9a162abcfaa8ed1bdc659ecce526f26ed
treee529cdb586d928c9ecfe7f6c50ffa95429c0d7f9
parentae296e59a427ca67bca9ed9e43990adbb371d2e7

Harden git and terminal flows and surface open PR status

- include open PR metadata in git status with graceful `gh` failure handling - centralize git command execution in `GitCoreService` via `runProcess` with truncation support - add PTY spawn-helper permission fixes, shell fallback retries, and tests for new behavior

14 files changed, 779 insertions(+), 333 deletions(-)

apps/server/src/git.test.ts+19
...@@ -438,6 +438,25 @@ describe("git integration", () => {...@@ -438,6 +438,25 @@ describe("git integration", () => {
438 });438 });
439439
440 describe("GitCoreService", () => {440 describe("GitCoreService", () => {
441 it("supports branch lifecycle operations through the service API", async () => {
442 await using tmp = await makeTmpDir();
443 const core = new GitCoreService();
444
445 await core.initRepo({ cwd: tmp.path });
446 await git(tmp.path, "config user.email 'test@test.com'");
447 await git(tmp.path, "config user.name 'Test'");
448 await writeFile(path.join(tmp.path, "README.md"), "# test\n");
449 await git(tmp.path, "add .");
450 await git(tmp.path, "commit -m 'initial commit'");
451
452 await core.createBranch({ cwd: tmp.path, branch: "feature/service-api" });
453 await core.checkoutBranch({ cwd: tmp.path, branch: "feature/service-api" });
454 const branches = await core.listBranches({ cwd: tmp.path });
455
456 expect(branches.isRepo).toBe(true);
457 expect(branches.branches.find((branch) => branch.current)?.name).toBe("feature/service-api");
458 });
459
441 it("reports status details and dirty state", async () => {460 it("reports status details and dirty state", async () => {
442 await using tmp = await makeTmpDir();461 await using tmp = await makeTmpDir();
443 await initRepoWithCommit(tmp.path);462 await initRepoWithCommit(tmp.path);
apps/server/src/git.ts+187-238
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import { spawn } from "node:child_process";
2import fs from "node:fs";1import fs from "node:fs";
3import os from "node:os";2import os from "node:os";
4import path from "node:path";3import path from "node:path";
...@@ -17,6 +16,7 @@ import {...@@ -17,6 +16,7 @@ import {
17 type GitStatusInput,16 type GitStatusInput,
18 type GitStatusResult,17 type GitStatusResult,
19} from "@t3tools/contracts";18} from "@t3tools/contracts";
19import { type ProcessRunResult, runProcess } from "./processRunner";
2020
21export interface TerminalCommandInput {21export interface TerminalCommandInput {
22 command: string;22 command: string;
...@@ -25,15 +25,9 @@ export interface TerminalCommandInput {...@@ -25,15 +25,9 @@ export interface TerminalCommandInput {
25 maxOutputBytes?: number;25 maxOutputBytes?: number;
26}26}
2727
28export interface TerminalCommandResult {28export type TerminalCommandResult = ProcessRunResult;
29 stdout: string;
30 stderr: string;
31 code: number | null;
32 signal: NodeJS.Signals | null;
33 timedOut: boolean;
34}
3529
36export interface GitStatusDetails extends GitStatusResult {30export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> {
37 upstreamRef: string | null;31 upstreamRef: string | null;
38}32}
3933
...@@ -60,84 +54,26 @@ interface RunGitOptions {...@@ -60,84 +54,26 @@ interface RunGitOptions {
60 allowNonZeroExit?: boolean | undefined;54 allowNonZeroExit?: boolean | undefined;
61}55}
6256
63const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;57interface ExecuteGitOptions {
6458 timeoutMs?: number | undefined;
65function appendChunkWithinLimit(59 allowNonZeroExit?: boolean | undefined;
66 target: string,60 fallbackErrorMessage?: string | undefined;
67 currentBytes: number,
68 chunk: Buffer,
69 maxBytes: number,
70): {
71 next: string;
72 nextBytes: number;
73 truncated: boolean;
74} {
75 const remaining = maxBytes - currentBytes;
76 if (remaining <= 0) {
77 return { next: target, nextBytes: currentBytes, truncated: true };
78 }
79 if (chunk.length <= remaining) {
80 return {
81 next: `${target}${chunk.toString()}`,
82 nextBytes: currentBytes + chunk.length,
83 truncated: false,
84 };
85 }
86 return {
87 next: `${target}${chunk.subarray(0, remaining).toString()}`,
88 nextBytes: currentBytes + remaining,
89 truncated: true,
90 };
91}61}
9262
93/** Spawn git directly with an argv array — no shell, no quoting needed. */63const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
94function runGit(args: readonly string[], cwd: string, timeoutMs = 30_000): Promise<TerminalCommandResult> {
95 return new Promise((resolve, reject) => {
96 const child = spawn("git", args, {
97 cwd,
98 env: process.env,
99 stdio: ["ignore", "pipe", "pipe"],
100 });
10164
102 const maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES;65/** Spawn git directly with an argv array — no shell, no quoting needed. */
103 let stdout = "";66function runGit(
104 let stderr = "";67 args: readonly string[],
105 let stdoutBytes = 0;68 cwd: string,
106 let stderrBytes = 0;69 timeoutMs = 30_000,
107 let timedOut = false;70): Promise<ProcessRunResult> {
108 let outputTruncated = false;71 return runProcess("git", args, {
10972 cwd,
110 const timeout = setTimeout(() => {73 timeoutMs,
111 timedOut = true;74 allowNonZeroExit: true,
112 child.kill("SIGTERM");75 maxBufferBytes: DEFAULT_MAX_OUTPUT_BYTES,
113 setTimeout(() => {76 outputMode: "truncate",
114 if (!child.killed) child.kill("SIGKILL");
115 }, 1_000).unref();
116 }, timeoutMs);
117
118 child.stdout?.on("data", (chunk: Buffer) => {
119 const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunk, maxOutputBytes);
120 stdout = appended.next;
121 stdoutBytes = appended.nextBytes;
122 outputTruncated = outputTruncated || appended.truncated;
123 });
124 child.stderr?.on("data", (chunk: Buffer) => {
125 const appended = appendChunkWithinLimit(stderr, stderrBytes, chunk, maxOutputBytes);
126 stderr = appended.next;
127 stderrBytes = appended.nextBytes;
128 outputTruncated = outputTruncated || appended.truncated;
129 });
130 child.on("error", (error) => {
131 clearTimeout(timeout);
132 reject(error);
133 });
134 child.on("close", (code, signal) => {
135 clearTimeout(timeout);
136 if (outputTruncated) {
137 stderr = `${stderr}\n[output truncated at ${maxOutputBytes} bytes]`;
138 }
139 resolve({ stdout, stderr, code: code ?? null, signal: signal ?? null, timedOut });
140 });
141 });77 });
142}78}
14379
...@@ -162,11 +98,10 @@ function normalizeGitSpawnError(cwd: string, error: unknown): Error {...@@ -162,11 +98,10 @@ function normalizeGitSpawnError(cwd: string, error: unknown): Error {
162 if (!(error instanceof Error)) {98 if (!(error instanceof Error)) {
163 return new Error("Failed to run git command.");99 return new Error("Failed to run git command.");
164 }100 }
165 const code = (error as NodeJS.ErrnoException).code;101 if (!fs.existsSync(cwd)) {
166 if (code === "ENOENT") {102 return new Error(`Working directory does not exist: ${cwd}`);
167 if (!fs.existsSync(cwd)) {103 }
168 return new Error(`Working directory does not exist: ${cwd}`);104 if (error.message.includes("Command not found: git")) {
169 }
170 return new Error("Git is required but not available on PATH.");105 return new Error("Git is required but not available on PATH.");
171 }106 }
172 return new Error(`Failed to run git command: ${error.message}`);107 return new Error(`Failed to run git command: ${error.message}`);
...@@ -206,6 +141,30 @@ async function runGitOrThrow(...@@ -206,6 +141,30 @@ async function runGitOrThrow(
206 return result;141 return result;
207}142}
208143
144async function executeGit(
145 cwd: string,
146 args: readonly string[],
147 options: ExecuteGitOptions = {},
148): Promise<TerminalCommandResult> {
149 const result = await runGitOrThrow(cwd, args, {
150 timeoutMs: options.timeoutMs,
151 allowNonZeroExit: true,
152 });
153
154 if (options.allowNonZeroExit || result.code === 0) {
155 return result;
156 }
157
158 const stderr = result.stderr.trim();
159 if (stderr.length > 0) {
160 throw new Error(stderr);
161 }
162 if (options.fallbackErrorMessage) {
163 throw new Error(options.fallbackErrorMessage);
164 }
165 throw normalizeGitExecutionError(args, result);
166}
167
209export class GitCoreService {168export class GitCoreService {
210 async status(raw: GitStatusInput): Promise<GitStatusResult> {169 async status(raw: GitStatusInput): Promise<GitStatusResult> {
211 const input = gitStatusInputSchema.parse(raw);170 const input = gitStatusInputSchema.parse(raw);
...@@ -216,6 +175,7 @@ export class GitCoreService {...@@ -216,6 +175,7 @@ export class GitCoreService {
216 hasUpstream: details.hasUpstream,175 hasUpstream: details.hasUpstream,
217 aheadCount: details.aheadCount,176 aheadCount: details.aheadCount,
218 behindCount: details.behindCount,177 behindCount: details.behindCount,
178 openPr: null,
219 });179 });
220 }180 }
221181
...@@ -352,16 +312,132 @@ export class GitCoreService {...@@ -352,16 +312,132 @@ export class GitCoreService {
352 return value.length > 0 ? value : null;312 return value.length > 0 ? value : null;
353 }313 }
354314
315 async listBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {
316 const result = await executeGit(input.cwd, ["branch", "--no-color"], {
317 timeoutMs: 10_000,
318 allowNonZeroExit: true,
319 });
320
321 if (result.code !== 0) {
322 const stderr = result.stderr.trim();
323 if (stderr.toLowerCase().includes("not a git repository")) {
324 return { branches: [], isRepo: false };
325 }
326 throw new Error(stderr || "git branch failed");
327 }
328
329 const [defaultRef, worktreeList] = await Promise.all([
330 executeGit(input.cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], {
331 timeoutMs: 5_000,
332 allowNonZeroExit: true,
333 }),
334 executeGit(input.cwd, ["worktree", "list", "--porcelain"], {
335 timeoutMs: 5_000,
336 allowNonZeroExit: true,
337 }),
338 ]);
339 const defaultBranch =
340 defaultRef.code === 0
341 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
342 : null;
343
344 const worktreeMap = new Map<string, string>();
345 if (worktreeList.code === 0) {
346 let currentPath: string | null = null;
347 for (const line of worktreeList.stdout.split("\n")) {
348 if (line.startsWith("worktree ")) {
349 const candidatePath = line.slice("worktree ".length);
350 currentPath = fs.existsSync(candidatePath) ? candidatePath : null;
351 } else if (line.startsWith("branch refs/heads/") && currentPath) {
352 worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
353 } else if (line === "") {
354 currentPath = null;
355 }
356 }
357 }
358
359 const branches = result.stdout
360 .split("\n")
361 .map((line) => line.trim())
362 .filter((line) => line.length > 0)
363 .map((line) => {
364 const name = line.replace(/^[*+]\s+/, "");
365 return {
366 name,
367 current: line.startsWith("* "),
368 isDefault: name === defaultBranch,
369 worktreePath: worktreeMap.get(name) ?? null,
370 };
371 })
372 .toSorted((a, b) => {
373 if (a.current !== b.current) return a.current ? -1 : 1;
374 if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;
375 return a.name.localeCompare(b.name);
376 });
377
378 return { branches, isRepo: true };
379 }
380
381 async createWorktree(input: GitCreateWorktreeInput): Promise<GitCreateWorktreeResult> {
382 const sanitizedBranch = input.newBranch.replace(/\//g, "-");
383 const repoName = path.basename(input.cwd);
384 const worktreePath =
385 input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch);
386
387 await executeGit(
388 input.cwd,
389 ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch],
390 { fallbackErrorMessage: "git worktree add failed" },
391 );
392
393 return {
394 worktree: {
395 path: worktreePath,
396 branch: input.newBranch,
397 },
398 };
399 }
400
401 async removeWorktree(input: GitRemoveWorktreeInput): Promise<void> {
402 await executeGit(input.cwd, ["worktree", "remove", input.path], {
403 timeoutMs: 15_000,
404 fallbackErrorMessage: "git worktree remove failed",
405 });
406 }
407
408 async createBranch(input: GitCreateBranchInput): Promise<void> {
409 await executeGit(input.cwd, ["branch", input.branch], {
410 timeoutMs: 10_000,
411 fallbackErrorMessage: "git branch create failed",
412 });
413 }
414
415 async checkoutBranch(input: GitCheckoutInput): Promise<void> {
416 await executeGit(input.cwd, ["checkout", input.branch], {
417 timeoutMs: 10_000,
418 fallbackErrorMessage: "git checkout failed",
419 });
420 }
421
422 async initRepo(input: GitInitInput): Promise<void> {
423 await executeGit(input.cwd, ["init"], {
424 timeoutMs: 10_000,
425 fallbackErrorMessage: "git init failed",
426 });
427 }
428
355 async git(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise<void> {429 async git(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise<void> {
356 await runGitOrThrow(cwd, args, { allowNonZeroExit });430 await executeGit(cwd, args, { allowNonZeroExit });
357 }431 }
358432
359 async gitStdout(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise<string> {433 async gitStdout(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise<string> {
360 const result = await runGitOrThrow(cwd, args, { allowNonZeroExit });434 const result = await executeGit(cwd, args, { allowNonZeroExit });
361 return result.stdout;435 return result.stdout;
362 }436 }
363}437}
364438
439const defaultGitCoreService = new GitCoreService();
440
365export async function runTerminalCommand(441export async function runTerminalCommand(
366 input: TerminalCommandInput,442 input: TerminalCommandInput,
367): Promise<TerminalCommandResult> {443): Promise<TerminalCommandResult> {
...@@ -374,173 +450,46 @@ export async function runTerminalCommand(...@@ -374,173 +450,46 @@ export async function runTerminalCommand(
374 const args =450 const args =
375 process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command];451 process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command];
376452
377 return new Promise((resolve, reject) => {453 const result = await runProcess(shellPath, args, {
378 const child = spawn(shellPath, args, {454 cwd: input.cwd,
379 cwd: input.cwd,455 timeoutMs: input.timeoutMs ?? 30_000,
380 env: process.env,456 allowNonZeroExit: true,
381 stdio: ["ignore", "pipe", "pipe"],457 maxBufferBytes: maxOutputBytes,
382 });458 outputMode: "truncate",
383
384 let stdout = "";
385 let stderr = "";
386 let stdoutBytes = 0;
387 let stderrBytes = 0;
388 let timedOut = false;
389 let outputTruncated = false;
390
391 const timeout = setTimeout(() => {
392 timedOut = true;
393 child.kill("SIGTERM");
394 setTimeout(() => {
395 if (!child.killed) {
396 child.kill("SIGKILL");
397 }
398 }, 1_000).unref();
399 }, input.timeoutMs ?? 30_000);
400
401 child.stdout?.on("data", (chunk: Buffer) => {
402 const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunk, maxOutputBytes);
403 stdout = appended.next;
404 stdoutBytes = appended.nextBytes;
405 outputTruncated = outputTruncated || appended.truncated;
406 });
407
408 child.stderr?.on("data", (chunk: Buffer) => {
409 const appended = appendChunkWithinLimit(stderr, stderrBytes, chunk, maxOutputBytes);
410 stderr = appended.next;
411 stderrBytes = appended.nextBytes;
412 outputTruncated = outputTruncated || appended.truncated;
413 });
414
415 child.on("error", (error) => {
416 clearTimeout(timeout);
417 reject(error);
418 });
419
420 child.on("close", (code, signal) => {
421 clearTimeout(timeout);
422 if (outputTruncated) {
423 stderr = `${stderr}\n[output truncated at ${maxOutputBytes} bytes]`;
424 }
425 resolve({
426 stdout,
427 stderr,
428 code: code ?? null,
429 signal: signal ?? null,
430 timedOut,
431 });
432 });
433 });459 });
434}
435
436export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {
437 const result = await runGit(["branch", "--no-color"], input.cwd, 10_000);
438460
439 if (result.code !== 0) {461 if (!result.stdoutTruncated && !result.stderrTruncated) {
440 const stderr = result.stderr.trim();462 return result;
441 if (stderr.includes("not a git repository")) {
442 return { branches: [], isRepo: false };
443 }
444 throw new Error(stderr || "git branch failed");
445 }
446
447 const [defaultRef, worktreeList] = await Promise.all([
448 runGit(["symbolic-ref", "refs/remotes/origin/HEAD"], input.cwd, 5_000),
449 runGit(["worktree", "list", "--porcelain"], input.cwd, 5_000),
450 ]);
451 const defaultBranch =
452 defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null;
453
454 const worktreeMap = new Map<string, string>();
455 if (worktreeList.code === 0) {
456 let currentPath: string | null = null;
457 for (const line of worktreeList.stdout.split("\n")) {
458 if (line.startsWith("worktree ")) {
459 const candidatePath = line.slice("worktree ".length);
460 currentPath = fs.existsSync(candidatePath) ? candidatePath : null;
461 } else if (line.startsWith("branch refs/heads/") && currentPath) {
462 worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
463 } else if (line === "") {
464 currentPath = null;
465 }
466 }
467 }463 }
468464
469 const branches = result.stdout465 return {
470 .split("\n")466 ...result,
471 .map((line) => line.trim())467 stderr: `${result.stderr}\n[output truncated at ${maxOutputBytes} bytes]`,
472 .filter((line) => line.length > 0)468 };
473 .map((line) => {469}
474 const name = line.replace(/^[*+]\s+/, "");
475 return {
476 name,
477 current: line.startsWith("* "),
478 isDefault: name === defaultBranch,
479 worktreePath: worktreeMap.get(name) ?? null,
480 };
481 })
482 .toSorted((a, b) => {
483 if (a.current !== b.current) return a.current ? -1 : 1;
484 if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;
485 return a.name.localeCompare(b.name);
486 });
487470
488 return { branches, isRepo: true };471export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {
472 return defaultGitCoreService.listBranches(input);
489}473}
490474
491export async function createGitWorktree(475export async function createGitWorktree(
492 input: GitCreateWorktreeInput,476 input: GitCreateWorktreeInput,
493): Promise<GitCreateWorktreeResult> {477): Promise<GitCreateWorktreeResult> {
494 const sanitizedBranch = input.newBranch.replace(/\//g, "-");478 return defaultGitCoreService.createWorktree(input);
495 const repoName = path.basename(input.cwd);
496 const worktreePath =
497 input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch);
498
499 const result = await runGit(
500 ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch],
501 input.cwd,
502 );
503
504 if (result.code !== 0) {
505 throw new Error(result.stderr.trim() || "git worktree add failed");
506 }
507
508 return {
509 worktree: {
510 path: worktreePath,
511 branch: input.newBranch,
512 },
513 };
514}479}
515480
516export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> {481export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> {
517 const result = await runGit(["worktree", "remove", input.path], input.cwd, 15_000);482 await defaultGitCoreService.removeWorktree(input);
518
519 if (result.code !== 0) {
520 throw new Error(result.stderr.trim() || "git worktree remove failed");
521 }
522}483}
523484
524export async function createGitBranch(input: GitCreateBranchInput): Promise<void> {485export async function createGitBranch(input: GitCreateBranchInput): Promise<void> {
525 const result = await runGit(["branch", input.branch], input.cwd, 10_000);486 await defaultGitCoreService.createBranch(input);
526
527 if (result.code !== 0) {
528 throw new Error(result.stderr.trim() || "git branch create failed");
529 }
530}487}
531488
532export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> {489export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> {
533 const result = await runGit(["checkout", input.branch], input.cwd, 10_000);490 await defaultGitCoreService.checkoutBranch(input);
534
535 if (result.code !== 0) {
536 throw new Error(result.stderr.trim() || "git checkout failed");
537 }
538}491}
539492
540export async function initGitRepo(input: GitInitInput): Promise<void> {493export async function initGitRepo(input: GitInitInput): Promise<void> {
541 const result = await runGit(["init"], input.cwd, 10_000);494 await defaultGitCoreService.initRepo(input);
542
543 if (result.code !== 0) {
544 throw new Error(result.stderr.trim() || "git init failed");
545 }
546}495}
apps/server/src/gitManager.test.ts+59
...@@ -168,6 +168,65 @@ afterEach(() => {...@@ -168,6 +168,65 @@ afterEach(() => {
168});168});
169169
170describe("GitManager", () => {170describe("GitManager", () => {
171 it("status includes open PR metadata when branch already has an open PR", async () => {
172 const repoDir = makeTempDir("t3code-git-manager-");
173 await initRepo(repoDir);
174 await runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]);
175 const remoteDir = await createBareRemote();
176 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
177 await runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]);
178
179 const { runner } = createRunnerWithFakeGh({
180 prListSequence: [
181 JSON.stringify([
182 {
183 number: 13,
184 title: "Existing PR",
185 url: "https://github.com/pingdotgg/codething-mvp/pull/13",
186 baseRefName: "main",
187 headRefName: "feature/status-open-pr",
188 },
189 ]),
190 ],
191 });
192
193 const manager = new GitManager({
194 runProcess: runner,
195 textGenerator: createTextGenerator(),
196 });
197
198 const status = await manager.status({ cwd: repoDir });
199 expect(status.branch).toBe("feature/status-open-pr");
200 expect(status.openPr).toEqual({
201 number: 13,
202 title: "Existing PR",
203 url: "https://github.com/pingdotgg/codething-mvp/pull/13",
204 baseBranch: "main",
205 headBranch: "feature/status-open-pr",
206 });
207 });
208
209 it("status is resilient to gh lookup failures and returns openPr null", async () => {
210 const repoDir = makeTempDir("t3code-git-manager-");
211 await initRepo(repoDir);
212 await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]);
213 const remoteDir = await createBareRemote();
214 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
215 await runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]);
216
217 const { runner } = createRunnerWithFakeGh({
218 failWith: new Error("Command not found: gh"),
219 });
220 const manager = new GitManager({
221 runProcess: runner,
222 textGenerator: createTextGenerator(),
223 });
224
225 const status = await manager.status({ cwd: repoDir });
226 expect(status.branch).toBe("feature/status-no-gh");
227 expect(status.openPr).toBeNull();
228 });
229
171 it("creates a commit when working tree is dirty", async () => {230 it("creates a commit when working tree is dirty", async () => {
172 const repoDir = makeTempDir("t3code-git-manager-");231 const repoDir = makeTempDir("t3code-git-manager-");
173 await initRepo(repoDir);232 await initRepo(repoDir);
apps/server/src/gitManager.ts+56-20
...@@ -6,6 +6,7 @@ import path from "node:path";...@@ -6,6 +6,7 @@ import path from "node:path";
6import {6import {
7 gitRunStackedActionInputSchema,7 gitRunStackedActionInputSchema,
8 gitRunStackedActionResultSchema,8 gitRunStackedActionResultSchema,
9 gitStatusInputSchema,
9 type GitRunStackedActionInput,10 type GitRunStackedActionInput,
10 type GitRunStackedActionResult,11 type GitRunStackedActionResult,
11 type GitStatusInput,12 type GitStatusInput,
...@@ -19,7 +20,11 @@ import type {...@@ -19,7 +20,11 @@ import type {
19} from "./coreServices";20} from "./coreServices";
20import { CodexTextGenerator } from "./codexTextGenerator";21import { CodexTextGenerator } from "./codexTextGenerator";
21import { GitCoreService } from "./git";22import { GitCoreService } from "./git";
22import { type ProcessRunOptions, runProcess } from "./processRunner";23import {
24 type ProcessRunOptions,
25 type ProcessRunResult,
26 runProcess,
27} from "./processRunner";
2328
24const GH_CLI_TIMEOUT_MS = 30_000;29const GH_CLI_TIMEOUT_MS = 30_000;
2530
...@@ -28,13 +33,7 @@ interface GitManagerDeps {...@@ -28,13 +33,7 @@ interface GitManagerDeps {
28 command: string,33 command: string,
29 args: readonly string[],34 args: readonly string[],
30 options?: ProcessRunOptions,35 options?: ProcessRunOptions,
31 ) => Promise<{36 ) => Promise<ProcessRunResult>;
32 stdout: string;
33 stderr: string;
34 code: number | null;
35 signal: NodeJS.Signals | null;
36 timedOut: boolean;
37 }>;
38 textGenerator?: TextGenerationService;37 textGenerator?: TextGenerationService;
39 gitCore?: GitCoreService;38 gitCore?: GitCoreService;
40}39}
...@@ -155,6 +154,22 @@ function normalizeGitHubAuthError(error: unknown): Error | undefined {...@@ -155,6 +154,22 @@ function normalizeGitHubAuthError(error: unknown): Error | undefined {
155 return undefined;154 return undefined;
156}155}
157156
157function toStatusOpenPr(pr: OpenPrInfo): {
158 number: number;
159 title: string;
160 url: string;
161 baseBranch: string;
162 headBranch: string;
163} {
164 return {
165 number: pr.number,
166 title: pr.title,
167 url: pr.url,
168 baseBranch: pr.baseRefName,
169 headBranch: pr.headRefName,
170 };
171}
172
158export class GitManager {173export class GitManager {
159 private readonly run: NonNullable<GitManagerDeps["runProcess"]>;174 private readonly run: NonNullable<GitManagerDeps["runProcess"]>;
160 private readonly textGenerator: TextGenerationService;175 private readonly textGenerator: TextGenerationService;
...@@ -167,7 +182,29 @@ export class GitManager {...@@ -167,7 +182,29 @@ export class GitManager {
167 }182 }
168183
169 async status(raw: GitStatusInput): Promise<GitStatusResult> {184 async status(raw: GitStatusInput): Promise<GitStatusResult> {
170 return this.gitCore.status(raw);185 const input = gitStatusInputSchema.parse(raw);
186 const details = await this.gitCore.statusDetails(input.cwd);
187
188 let openPr: ReturnType<typeof toStatusOpenPr> | null = null;
189 if (details.branch && details.hasUpstream) {
190 try {
191 const existing = await this.findOpenPr(input.cwd, details.branch);
192 if (existing) {
193 openPr = toStatusOpenPr(existing);
194 }
195 } catch {
196 // PR lookup is best-effort for status rendering.
197 }
198 }
199
200 return {
201 branch: details.branch,
202 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
203 hasUpstream: details.hasUpstream,
204 aheadCount: details.aheadCount,
205 behindCount: details.behindCount,
206 openPr,
207 };
171 }208 }
172209
173 async runStackedAction(210 async runStackedAction(
...@@ -413,24 +450,23 @@ export class GitManager {...@@ -413,24 +450,23 @@ export class GitManager {
413 }450 }
414451
415 private async runGh(cwd: string, args: readonly string[]): Promise<void> {452 private async runGh(cwd: string, args: readonly string[]): Promise<void> {
416 try {453 await this.runGhCommand(cwd, args);
417 await this.run("gh", args, { cwd, timeoutMs: GH_CLI_TIMEOUT_MS });
418 } catch (error) {
419 throw (
420 asCommandNotFound("gh", error) ??
421 normalizeGitHubAuthError(error) ??
422 (error instanceof Error ? error : new Error("GitHub CLI command failed."))
423 );
424 }
425 }454 }
426455
427 private async runGhStdout(cwd: string, args: readonly string[]): Promise<string> {456 private async runGhStdout(cwd: string, args: readonly string[]): Promise<string> {
457 const result = await this.runGhCommand(cwd, args);
458 return result.stdout;
459 }
460
461 private async runGhCommand(
462 cwd: string,
463 args: readonly string[],
464 ): Promise<ProcessRunResult> {
428 try {465 try {
429 const result = await this.run("gh", args, {466 return await this.run("gh", args, {
430 cwd,467 cwd,
431 timeoutMs: GH_CLI_TIMEOUT_MS,468 timeoutMs: GH_CLI_TIMEOUT_MS,
432 });469 });
433 return result.stdout;
434 } catch (error) {470 } catch (error) {
435 throw (471 throw (
436 asCommandNotFound("gh", error) ??472 asCommandNotFound("gh", error) ??
apps/server/src/processRunner.test.ts created+31
...@@ -0,0 +1,31 @@
1import { describe, expect, it } from "vitest";
2
3import { runProcess } from "./processRunner";
4
5describe("runProcess", () => {
6 it("fails when output exceeds max buffer in default mode", async () => {
7 await expect(
8 runProcess(
9 "node",
10 ["-e", "process.stdout.write('x'.repeat(2048))"],
11 { maxBufferBytes: 128 },
12 ),
13 ).rejects.toThrow("exceeded stdout buffer limit");
14 });
15
16 it("truncates output when outputMode is truncate", async () => {
17 const result = await runProcess(
18 "node",
19 ["-e", "process.stdout.write('x'.repeat(2048))"],
20 {
21 maxBufferBytes: 128,
22 outputMode: "truncate",
23 },
24 );
25
26 expect(result.code).toBe(0);
27 expect(result.stdout.length).toBeLessThanOrEqual(128);
28 expect(result.stdoutTruncated).toBe(true);
29 expect(result.stderrTruncated).toBe(false);
30 });
31});
apps/server/src/processRunner.ts+64-2
...@@ -7,6 +7,7 @@ export interface ProcessRunOptions {...@@ -7,6 +7,7 @@ export interface ProcessRunOptions {
7 stdin?: string | undefined;7 stdin?: string | undefined;
8 allowNonZeroExit?: boolean | undefined;8 allowNonZeroExit?: boolean | undefined;
9 maxBufferBytes?: number | undefined;9 maxBufferBytes?: number | undefined;
10 outputMode?: "error" | "truncate" | undefined;
10}11}
1112
12export interface ProcessRunResult {13export interface ProcessRunResult {
...@@ -15,6 +16,8 @@ export interface ProcessRunResult {...@@ -15,6 +16,8 @@ export interface ProcessRunResult {
15 code: number | null;16 code: number | null;
16 signal: NodeJS.Signals | null;17 signal: NodeJS.Signals | null;
17 timedOut: boolean;18 timedOut: boolean;
19 stdoutTruncated?: boolean | undefined;
20 stderrTruncated?: boolean | undefined;
18}21}
1922
20function commandLabel(command: string, args: readonly string[]): string {23function commandLabel(command: string, args: readonly string[]): string {
...@@ -79,6 +82,34 @@ function normalizeBufferError(...@@ -79,6 +82,34 @@ function normalizeBufferError(
7982
80const DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024;83const DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
8184
85function appendChunkWithinLimit(
86 target: string,
87 currentBytes: number,
88 chunk: Buffer,
89 maxBytes: number,
90): {
91 next: string;
92 nextBytes: number;
93 truncated: boolean;
94} {
95 const remaining = maxBytes - currentBytes;
96 if (remaining <= 0) {
97 return { next: target, nextBytes: currentBytes, truncated: true };
98 }
99 if (chunk.length <= remaining) {
100 return {
101 next: `${target}${chunk.toString()}`,
102 nextBytes: currentBytes + chunk.length,
103 truncated: false,
104 };
105 }
106 return {
107 next: `${target}${chunk.subarray(0, remaining).toString()}`,
108 nextBytes: currentBytes + remaining,
109 truncated: true,
110 };
111}
112
82export async function runProcess(113export async function runProcess(
83 command: string,114 command: string,
84 args: readonly string[],115 args: readonly string[],
...@@ -86,6 +117,7 @@ export async function runProcess(...@@ -86,6 +117,7 @@ export async function runProcess(
86): Promise<ProcessRunResult> {117): Promise<ProcessRunResult> {
87 const timeoutMs = options.timeoutMs ?? 60_000;118 const timeoutMs = options.timeoutMs ?? 60_000;
88 const maxBufferBytes = options.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;119 const maxBufferBytes = options.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
120 const outputMode = options.outputMode ?? "error";
89121
90 return new Promise<ProcessRunResult>((resolve, reject) => {122 return new Promise<ProcessRunResult>((resolve, reject) => {
91 const child = spawn(command, args, {123 const child = spawn(command, args, {
...@@ -98,6 +130,8 @@ export async function runProcess(...@@ -98,6 +130,8 @@ export async function runProcess(
98 let stderr = "";130 let stderr = "";
99 let stdoutBytes = 0;131 let stdoutBytes = 0;
100 let stderrBytes = 0;132 let stderrBytes = 0;
133 let stdoutTruncated = false;
134 let stderrTruncated = false;
101 let timedOut = false;135 let timedOut = false;
102 let settled = false;136 let settled = false;
103 let forceKillTimer: ReturnType<typeof setTimeout> | null = null;137 let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
...@@ -131,15 +165,41 @@ export async function runProcess(...@@ -131,15 +165,41 @@ export async function runProcess(
131 stream: "stdout" | "stderr",165 stream: "stdout" | "stderr",
132 chunk: Buffer | string,166 chunk: Buffer | string,
133 ): Error | null => {167 ): Error | null => {
134 const text = chunk.toString();168 const chunkBuffer =
135 const byteLength = Buffer.byteLength(text);169 typeof chunk === "string" ? Buffer.from(chunk) : chunk;
170 const text = chunkBuffer.toString();
171 const byteLength = chunkBuffer.length;
136 if (stream === "stdout") {172 if (stream === "stdout") {
173 if (outputMode === "truncate") {
174 const appended = appendChunkWithinLimit(
175 stdout,
176 stdoutBytes,
177 chunkBuffer,
178 maxBufferBytes,
179 );
180 stdout = appended.next;
181 stdoutBytes = appended.nextBytes;
182 stdoutTruncated = stdoutTruncated || appended.truncated;
183 return null;
184 }
137 stdout += text;185 stdout += text;
138 stdoutBytes += byteLength;186 stdoutBytes += byteLength;
139 if (stdoutBytes > maxBufferBytes) {187 if (stdoutBytes > maxBufferBytes) {
140 return normalizeBufferError(command, args, "stdout", maxBufferBytes);188 return normalizeBufferError(command, args, "stdout", maxBufferBytes);
141 }189 }
142 } else {190 } else {
191 if (outputMode === "truncate") {
192 const appended = appendChunkWithinLimit(
193 stderr,
194 stderrBytes,
195 chunkBuffer,
196 maxBufferBytes,
197 );
198 stderr = appended.next;
199 stderrBytes = appended.nextBytes;
200 stderrTruncated = stderrTruncated || appended.truncated;
201 return null;
202 }
143 stderr += text;203 stderr += text;
144 stderrBytes += byteLength;204 stderrBytes += byteLength;
145 if (stderrBytes > maxBufferBytes) {205 if (stderrBytes > maxBufferBytes) {
...@@ -176,6 +236,8 @@ export async function runProcess(...@@ -176,6 +236,8 @@ export async function runProcess(
176 code,236 code,
177 signal,237 signal,
178 timedOut,238 timedOut,
239 stdoutTruncated,
240 stderrTruncated,
179 };241 };
180242
181 finalize(() => {243 finalize(() => {
apps/server/src/ptyAdapter.test.ts created+54
...@@ -0,0 +1,54 @@
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 { ensureNodePtySpawnHelperExecutable } from "./ptyAdapter";
8
9function fileMode(filePath: string): number {
10 return fs.statSync(filePath).mode & 0o777;
11}
12
13describe("ensureNodePtySpawnHelperExecutable", () => {
14 const tempDirs: string[] = [];
15
16 afterEach(() => {
17 for (const dir of tempDirs.splice(0, tempDirs.length)) {
18 fs.rmSync(dir, { recursive: true, force: true });
19 }
20 });
21
22 it("adds executable bits when helper exists but is not executable", () => {
23 if (process.platform === "win32") return;
24
25 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-"));
26 tempDirs.push(dir);
27 const helperPath = path.join(dir, "spawn-helper");
28 fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8");
29 fs.chmodSync(helperPath, 0o644);
30
31 ensureNodePtySpawnHelperExecutable(helperPath);
32
33 expect(fileMode(helperPath) & 0o111).toBe(0o111);
34 });
35
36 it("keeps executable helper as executable", () => {
37 if (process.platform === "win32") return;
38
39 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-"));
40 tempDirs.push(dir);
41 const helperPath = path.join(dir, "spawn-helper");
42 fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8");
43 fs.chmodSync(helperPath, 0o755);
44
45 ensureNodePtySpawnHelperExecutable(helperPath);
46
47 expect(fileMode(helperPath) & 0o111).toBe(0o111);
48 });
49
50 it("does not throw when helper path is missing", () => {
51 const missing = path.join(os.tmpdir(), `does-not-exist-${Date.now()}`);
52 expect(() => ensureNodePtySpawnHelperExecutable(missing)).not.toThrow();
53 });
54});
apps/server/src/ptyAdapter.ts+60-1
...@@ -1,3 +1,7 @@...@@ -1,3 +1,7 @@
1import fs from "node:fs";
2import { createRequire } from "node:module";
3import path from "node:path";
4
1import * as nodePty from "node-pty";5import * as nodePty from "node-pty";
26
3export interface PtyExitEvent {7export interface PtyExitEvent {
...@@ -26,6 +30,57 @@ export interface PtyAdapter {...@@ -26,6 +30,57 @@ export interface PtyAdapter {
26 spawn(input: PtySpawnInput): PtyProcess;30 spawn(input: PtySpawnInput): PtyProcess;
27}31}
2832
33const requireForNodePty = createRequire(import.meta.url);
34let didEnsureSpawnHelperExecutable = false;
35
36function resolveNodePtySpawnHelperPath(): string | null {
37 try {
38 const packageJsonPath = requireForNodePty.resolve("node-pty/package.json");
39 const packageDir = path.dirname(packageJsonPath);
40 const candidates = [
41 path.join(packageDir, "build", "Release", "spawn-helper"),
42 path.join(packageDir, "build", "Debug", "spawn-helper"),
43 path.join(
44 packageDir,
45 "prebuilds",
46 `${process.platform}-${process.arch}`,
47 "spawn-helper",
48 ),
49 ];
50
51 for (const candidate of candidates) {
52 if (fs.existsSync(candidate)) {
53 return candidate;
54 }
55 }
56
57 return null;
58 } catch {
59 return null;
60 }
61}
62
63export function ensureNodePtySpawnHelperExecutable(explicitPath?: string): void {
64 if (process.platform === "win32") return;
65 if (!explicitPath && didEnsureSpawnHelperExecutable) return;
66
67 const helperPath = explicitPath ?? resolveNodePtySpawnHelperPath();
68 if (!helperPath) return;
69 if (!explicitPath) {
70 didEnsureSpawnHelperExecutable = true;
71 }
72
73 try {
74 const stat = fs.statSync(helperPath);
75 const mode = stat.mode & 0o777;
76 if ((mode & 0o111) === 0) {
77 fs.chmodSync(helperPath, mode | 0o111);
78 }
79 } catch {
80 // Best effort only. If chmod fails, node-pty spawn will surface the real error.
81 }
82}
83
29class NodePtyProcess implements PtyProcess {84class NodePtyProcess implements PtyProcess {
30 constructor(private readonly process: nodePty.IPty) {}85 constructor(private readonly process: nodePty.IPty) {}
3186
...@@ -67,12 +122,16 @@ class NodePtyProcess implements PtyProcess {...@@ -67,12 +122,16 @@ class NodePtyProcess implements PtyProcess {
67122
68export class NodePtyAdapter implements PtyAdapter {123export class NodePtyAdapter implements PtyAdapter {
69 spawn(input: PtySpawnInput): PtyProcess {124 spawn(input: PtySpawnInput): PtyProcess {
125 ensureNodePtySpawnHelperExecutable();
70 const ptyProcess = nodePty.spawn(input.shell, [], {126 const ptyProcess = nodePty.spawn(input.shell, [], {
71 cwd: input.cwd,127 cwd: input.cwd,
72 cols: input.cols,128 cols: input.cols,
73 rows: input.rows,129 rows: input.rows,
74 env: input.env,130 env: input.env,
75 name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color",131 name:
132 globalThis.process.platform === "win32"
133 ? "xterm-color"
134 : "xterm-256color",
76 });135 });
77 return new NodePtyProcess(ptyProcess);136 return new NodePtyProcess(ptyProcess);
78 }137 }
apps/server/src/terminalManager.test.ts+41-2
...@@ -62,10 +62,15 @@ class FakePtyProcess implements PtyProcess {...@@ -62,10 +62,15 @@ class FakePtyProcess implements PtyProcess {
62class FakePtyAdapter implements PtyAdapter {62class FakePtyAdapter implements PtyAdapter {
63 readonly spawnInputs: PtySpawnInput[] = [];63 readonly spawnInputs: PtySpawnInput[] = [];
64 readonly processes: FakePtyProcess[] = [];64 readonly processes: FakePtyProcess[] = [];
65 readonly spawnFailures: Error[] = [];
65 private nextPid = 9000;66 private nextPid = 9000;
6667
67 spawn(input: PtySpawnInput): PtyProcess {68 spawn(input: PtySpawnInput): PtyProcess {
68 this.spawnInputs.push(input);69 this.spawnInputs.push(input);
70 const failure = this.spawnFailures.shift();
71 if (failure) {
72 throw failure;
73 }
69 const process = new FakePtyProcess(this.nextPid++);74 const process = new FakePtyProcess(this.nextPid++);
70 this.processes.push(process);75 this.processes.push(process);
71 return process;76 return process;
...@@ -117,7 +122,10 @@ describe("TerminalManager", () => {...@@ -117,7 +122,10 @@ describe("TerminalManager", () => {
117 }122 }
118 });123 });
119124
120 function makeManager(historyLineLimit = 5) {125 function makeManager(
126 historyLineLimit = 5,
127 options: { shellResolver?: () => string } = {},
128 ) {
121 const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-"));129 const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-"));
122 tempDirs.push(logsDir);130 tempDirs.push(logsDir);
123 const ptyAdapter = new FakePtyAdapter();131 const ptyAdapter = new FakePtyAdapter();
...@@ -125,7 +133,7 @@ describe("TerminalManager", () => {...@@ -125,7 +133,7 @@ describe("TerminalManager", () => {
125 logsDir,133 logsDir,
126 ptyAdapter,134 ptyAdapter,
127 historyLineLimit,135 historyLineLimit,
128 shellResolver: () => "/bin/bash",136 shellResolver: options.shellResolver ?? (() => "/bin/bash"),
129 });137 });
130 return { logsDir, ptyAdapter, manager };138 return { logsDir, ptyAdapter, manager };
131 }139 }
...@@ -266,4 +274,35 @@ describe("TerminalManager", () => {...@@ -266,4 +274,35 @@ describe("TerminalManager", () => {
266274
267 manager.dispose();275 manager.dispose();
268 });276 });
277
278 it("retries with fallback shells when preferred shell spawn fails", async () => {
279 const { manager, ptyAdapter } = makeManager(5, {
280 shellResolver: () => "/definitely/missing-shell -l",
281 });
282 ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed."));
283
284 const snapshot = await manager.open(openInput());
285
286 expect(snapshot.status).toBe("running");
287 expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2);
288 expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell");
289
290 if (process.platform === "win32") {
291 expect(
292 ptyAdapter.spawnInputs.some(
293 (input) => input.shell === "cmd.exe" || input.shell === "powershell.exe",
294 ),
295 ).toBe(true);
296 } else {
297 expect(
298 ptyAdapter.spawnInputs.some((input) =>
299 ["/bin/zsh", "/bin/bash", "/bin/sh", "zsh", "bash", "sh"].includes(
300 input.shell,
301 ),
302 ),
303 ).toBe(true);
304 }
305
306 manager.dispose();
307 });
269});308});
apps/server/src/terminalManager.ts+98-8
...@@ -57,6 +57,68 @@ function defaultShellResolver(): string {...@@ -57,6 +57,68 @@ function defaultShellResolver(): string {
57 return process.env.SHELL ?? "bash";57 return process.env.SHELL ?? "bash";
58}58}
5959
60function normalizeShellCommand(value: string | undefined): string | null {
61 if (!value) return null;
62 const trimmed = value.trim();
63 if (trimmed.length === 0) return null;
64
65 if (process.platform === "win32") {
66 return trimmed;
67 }
68
69 const firstToken = trimmed.split(/\s+/g)[0]?.trim();
70 if (!firstToken) return null;
71 return firstToken.replace(/^['"]|['"]$/g, "");
72}
73
74function uniqueShells(shells: Array<string | null>): string[] {
75 const seen = new Set<string>();
76 const ordered: string[] = [];
77 for (const shell of shells) {
78 if (!shell || shell.length === 0) continue;
79 if (seen.has(shell)) continue;
80 seen.add(shell);
81 ordered.push(shell);
82 }
83 return ordered;
84}
85
86function resolveShellCandidates(shellResolver: () => string): string[] {
87 const requested = normalizeShellCommand(shellResolver());
88
89 if (process.platform === "win32") {
90 return uniqueShells([
91 requested,
92 process.env.ComSpec ?? null,
93 "powershell.exe",
94 "cmd.exe",
95 ]);
96 }
97
98 return uniqueShells([
99 requested,
100 normalizeShellCommand(process.env.SHELL),
101 "/bin/zsh",
102 "/bin/bash",
103 "/bin/sh",
104 "zsh",
105 "bash",
106 "sh",
107 ]);
108}
109
110function isRetryableShellSpawnError(error: unknown): boolean {
111 if (!(error instanceof Error)) return false;
112 const message = error.message.toLowerCase();
113 return (
114 message.includes("posix_spawnp failed") ||
115 message.includes("enoent") ||
116 message.includes("not found") ||
117 message.includes("file not found") ||
118 message.includes("no such file")
119 );
120}
121
60function capHistory(history: string, maxLines: number): string {122function capHistory(history: string, maxLines: number): string {
61 if (history.length === 0) return history;123 if (history.length === 0) return history;
62 const hasTrailingNewline = history.endsWith("\n");124 const hasTrailingNewline = history.endsWith("\n");
...@@ -270,16 +332,43 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -270,16 +332,43 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
270 session.exitSignal = null;332 session.exitSignal = null;
271 session.updatedAt = new Date().toISOString();333 session.updatedAt = new Date().toISOString();
272334
273 const shell = this.shellResolver();
274 let ptyProcess: PtyProcess | null = null;335 let ptyProcess: PtyProcess | null = null;
336 let startedShell: string | null = null;
275 try {337 try {
276 ptyProcess = this.ptyAdapter.spawn({338 const shellCandidates = resolveShellCandidates(this.shellResolver);
277 shell,339 let lastSpawnError: unknown = null;
278 cwd: session.cwd,340
279 cols: session.cols,341 for (const shell of shellCandidates) {
280 rows: session.rows,342 try {
281 env: process.env,343 ptyProcess = this.ptyAdapter.spawn({
282 });344 shell,
345 cwd: session.cwd,
346 cols: session.cols,
347 rows: session.rows,
348 env: process.env,
349 });
350 startedShell = shell;
351 break;
352 } catch (error) {
353 lastSpawnError = error;
354 if (!isRetryableShellSpawnError(error)) {
355 throw error;
356 }
357 }
358 }
359
360 if (!ptyProcess) {
361 const detail =
362 lastSpawnError instanceof Error
363 ? lastSpawnError.message
364 : "Terminal start failed";
365 const tried =
366 shellCandidates.length > 0
367 ? ` Tried shells: ${shellCandidates.join(", ")}.`
368 : "";
369 throw new Error(`${detail}.${tried}`.trim());
370 }
371
283 session.process = ptyProcess;372 session.process = ptyProcess;
284 session.pid = ptyProcess.pid;373 session.pid = ptyProcess.pid;
285 session.status = "running";374 session.status = "running";
...@@ -318,6 +407,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -318,6 +407,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
318 this.logger.error("failed to start terminal", {407 this.logger.error("failed to start terminal", {
319 threadId: session.threadId,408 threadId: session.threadId,
320 error: message,409 error: message,
410 ...(startedShell ? { shell: startedShell } : {}),
321 });411 });
322 }412 }
323 }413 }
apps/server/src/wsServer.test.ts+2
...@@ -524,6 +524,7 @@ describe("WebSocket Server", () => {...@@ -524,6 +524,7 @@ describe("WebSocket Server", () => {
524 hasUpstream: false,524 hasUpstream: false,
525 aheadCount: 0,525 aheadCount: 0,
526 behindCount: 0,526 behindCount: 0,
527 openPr: null,
527 }),528 }),
528 runStackedAction: vi.fn(),529 runStackedAction: vi.fn(),
529 };530 };
...@@ -547,6 +548,7 @@ describe("WebSocket Server", () => {...@@ -547,6 +548,7 @@ describe("WebSocket Server", () => {
547 hasUpstream: false,548 hasUpstream: false,
548 aheadCount: 0,549 aheadCount: 0,
549 behindCount: 0,550 behindCount: 0,
551 openPr: null,
550 });552 });
551 expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" });553 expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" });
552 });554 });
apps/web/src/components/ChatView.tsx+98-60
...@@ -63,6 +63,69 @@ interface GitActionMenuItem {...@@ -63,6 +63,69 @@ interface GitActionMenuItem {
63 id: GitStackedAction;63 id: GitStackedAction;
64 label: string;64 label: string;
65 disabled: boolean;65 disabled: boolean;
66 icon: "commit" | "push" | "pr";
67}
68
69function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boolean }) {
70 const toneClass = props.disabled ? "text-muted-foreground/45" : "text-foreground/85";
71
72 if (props.icon === "commit") {
73 return (
74 <svg
75 viewBox="0 0 20 20"
76 aria-hidden="true"
77 className={`h-5 w-5 shrink-0 ${toneClass}`}
78 fill="none"
79 stroke="currentColor"
80 strokeWidth="1.8"
81 strokeLinecap="round"
82 strokeLinejoin="round"
83 >
84 <circle cx="10" cy="10" r="4.2" />
85 <path d="M1.8 10h4.2" />
86 <path d="M14 10h4.2" />
87 </svg>
88 );
89 }
90
91 if (props.icon === "push") {
92 return (
93 <svg
94 viewBox="0 0 20 20"
95 aria-hidden="true"
96 className={`h-5 w-5 shrink-0 ${toneClass}`}
97 fill="none"
98 stroke="currentColor"
99 strokeWidth="1.8"
100 strokeLinecap="round"
101 strokeLinejoin="round"
102 >
103 <path d="M5.2 16h9.6a3.2 3.2 0 0 0 .2-6.4h-.5A4.8 4.8 0 0 0 5 9 3.7 3.7 0 0 0 5.2 16Z" />
104 <path d="M10 5.8v6.2" />
105 <path d="m7.7 8.1 2.3-2.3 2.3 2.3" />
106 </svg>
107 );
108 }
109
110 return (
111 <svg
112 viewBox="0 0 20 20"
113 aria-hidden="true"
114 className={`h-5 w-5 shrink-0 ${toneClass}`}
115 fill="none"
116 stroke="currentColor"
117 strokeWidth="1.8"
118 strokeLinecap="round"
119 strokeLinejoin="round"
120 >
121 <circle cx="6" cy="5" r="1.7" />
122 <circle cx="6" cy="15" r="1.7" />
123 <circle cx="14" cy="10" r="1.7" />
124 <path d="M6 6.8v6.5" />
125 <path d="M7.8 6.1c2.9 0 3.8 1.4 5.1 2.9" />
126 <path d="M7.8 13.9c2.9 0 3.8-1.4 5.1-2.9" />
127 </svg>
128 );
66}129}
67130
68function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {131function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
...@@ -277,65 +340,34 @@ export default function ChatView() {...@@ -277,65 +340,34 @@ export default function ChatView() {
277 if (!gitStatus) return [];340 if (!gitStatus) return [];
278341
279 const hasBranch = gitStatus.branch !== null;342 const hasBranch = gitStatus.branch !== null;
343 const hasOpenPr = gitStatus.openPr !== null;
280 const canCommit = !gitBaseDisabled && gitStatus.hasWorkingTreeChanges;344 const canCommit = !gitBaseDisabled && gitStatus.hasWorkingTreeChanges;
281 const canPushOrPr = !gitBaseDisabled && hasBranch;345 const canPush =
282346 !gitBaseDisabled && hasBranch && (gitStatus.hasWorkingTreeChanges || gitStatus.aheadCount > 0);
283 if (!hasBranch) {347 const canViewPr =
284 return [348 !gitBaseDisabled &&
285 {349 hasBranch &&
286 id: "commit",350 gitStatus.behindCount === 0 &&
287 label: "Commit",351 (gitStatus.hasWorkingTreeChanges || gitStatus.aheadCount > 0 || hasOpenPr);
288 disabled: !canCommit,
289 },
290 ];
291 }
292
293 if (gitStatus.hasWorkingTreeChanges) {
294 return [
295 {
296 id: "commit",
297 label: "Commit",
298 disabled: !canCommit,
299 },
300 {
301 id: "commit_push",
302 label: "Commit & Push",
303 disabled: !canPushOrPr,
304 },
305 {
306 id: "commit_push_pr",
307 label: "Commit, Push & Create PR",
308 disabled: !canPushOrPr,
309 },
310 ];
311 }
312
313 if (gitStatus.aheadCount > 0) {
314 return [
315 {
316 id: "commit",
317 label: "Commit",
318 disabled: true,
319 },
320 {
321 id: "commit_push",
322 label: "Commit & Push",
323 disabled: !canPushOrPr,
324 },
325 {
326 id: "commit_push_pr",
327 label: "Commit, Push & Create PR",
328 disabled: !canPushOrPr,
329 },
330 ];
331 }
332352
333 const openPrDisabled = !canPushOrPr || gitStatus.behindCount > 0;
334 return [353 return [
354 {
355 id: "commit",
356 label: "Commit",
357 disabled: !canCommit,
358 icon: "commit",
359 },
360 {
361 id: "commit_push",
362 label: "Push",
363 disabled: !canPush,
364 icon: "push",
365 },
335 {366 {
336 id: "commit_push_pr",367 id: "commit_push_pr",
337 label: "Open PR",368 label: "View PR",
338 disabled: openPrDisabled,369 disabled: !canViewPr,
370 icon: "pr",
339 },371 },
340 ];372 ];
341 }, [gitBaseDisabled, gitStatus]);373 }, [gitBaseDisabled, gitStatus]);
...@@ -882,15 +914,20 @@ export default function ChatView() {...@@ -882,15 +914,20 @@ export default function ChatView() {
882 <button914 <button
883 type="button"915 type="button"
884 className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-[10px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-60"916 className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-[10px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-60"
885 onClick={() => setIsGitMenuOpen((v) => !v)}917 onClick={() => {
918 if (!isGitMenuOpen) {
919 void refreshGitStatus().catch(() => undefined);
920 }
921 setIsGitMenuOpen((v) => !v);
922 }}
886 disabled={!gitStatus || isGitActionRunning}923 disabled={!gitStatus || isGitActionRunning}
887 >924 >
888 {isGitActionRunning ? "Running..." : "Git actions"}925 {isGitActionRunning ? "Running..." : "Git actions"}
889 <span aria-hidden="true">▾</span>926 <span aria-hidden="true">▾</span>
890 </button>927 </button>
891 {isGitMenuOpen && (928 {isGitMenuOpen && (
892 <div className="absolute right-0 top-full z-50 mt-1 w-[240px] rounded-xl border border-border bg-popover p-2 shadow-xl">929 <div className="absolute right-0 top-full z-50 mt-1 w-[280px] rounded-3xl border border-border bg-popover p-3 shadow-xl">
893 <p className="px-2 pb-1 text-[10px] uppercase tracking-[0.1em] text-muted-foreground/60">930 <p className="px-3 pb-2 text-[13px] text-muted-foreground/75">
894 Git actions931 Git actions
895 </p>932 </p>
896 {gitActionMenuItems.map((item) => {933 {gitActionMenuItems.map((item) => {
...@@ -898,13 +935,14 @@ export default function ChatView() {...@@ -898,13 +935,14 @@ export default function ChatView() {
898 <button935 <button
899 key={item.id}936 key={item.id}
900 type="button"937 type="button"
901 className="mb-1 flex w-full items-center rounded-lg px-2 py-2 text-left text-[11px] text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"938 className="mb-1.5 flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-[14px] text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:text-muted-foreground/65"
902 disabled={item.disabled}939 disabled={item.disabled}
903 onClick={() => {940 onClick={() => {
904 void runGitAction(item.id);941 void runGitAction(item.id);
905 }}942 }}
906 >943 >
907 {item.label}944 <GitActionIcon icon={item.icon} disabled={item.disabled} />
945 <span>{item.label}</span>
908 </button>946 </button>
909 );947 );
910 })}948 })}
...@@ -918,7 +956,7 @@ export default function ChatView() {...@@ -918,7 +956,7 @@ export default function ChatView() {
918 !gitStatus.hasWorkingTreeChanges &&956 !gitStatus.hasWorkingTreeChanges &&
919 gitStatus.aheadCount === 0 &&957 gitStatus.aheadCount === 0 &&
920 gitStatus.behindCount > 0 && (958 gitStatus.behindCount > 0 && (
921 <p className="px-2 pt-1 text-[10px] text-amber-500 dark:text-amber-300">959 <p className="px-3 pt-1 text-[10px] text-amber-500 dark:text-amber-300">
922 Branch is behind upstream. Pull/rebase before opening a PR.960 Branch is behind upstream. Pull/rebase before opening a PR.
923 </p>961 </p>
924 )}962 )}
packages/contracts/src/git.test.ts+1-1
...@@ -21,6 +21,7 @@ describe("git contracts", () => {...@@ -21,6 +21,7 @@ describe("git contracts", () => {
21 hasUpstream: false,21 hasUpstream: false,
22 aheadCount: 0,22 aheadCount: 0,
23 behindCount: 0,23 behindCount: 0,
24 openPr: null,
24 });25 });
25 expect(parsed.branch).toBe("feature/git-actions");26 expect(parsed.branch).toBe("feature/git-actions");
26 expect(parsed.hasWorkingTreeChanges).toBe(true);27 expect(parsed.hasWorkingTreeChanges).toBe(true);
...@@ -70,4 +71,3 @@ describe("git contracts", () => {...@@ -70,4 +71,3 @@ describe("git contracts", () => {
70 expect(parsed.pr.number).toBe(123);71 expect(parsed.pr.number).toBe(123);
71 });72 });
72});73});
73
packages/contracts/src/git.ts+9-1
...@@ -74,6 +74,15 @@ export const gitStatusResultSchema = z.object({...@@ -74,6 +74,15 @@ export const gitStatusResultSchema = z.object({
74 hasUpstream: z.boolean(),74 hasUpstream: z.boolean(),
75 aheadCount: z.number().int().nonnegative(),75 aheadCount: z.number().int().nonnegative(),
76 behindCount: z.number().int().nonnegative(),76 behindCount: z.number().int().nonnegative(),
77 openPr: z
78 .object({
79 number: z.number().int().positive(),
80 title: z.string().min(1),
81 url: z.string().url(),
82 baseBranch: z.string().min(1),
83 headBranch: z.string().min(1),
84 })
85 .nullable(),
77});86});
7887
79export const gitStackedActionSchema = z.enum([88export const gitStackedActionSchema = z.enum([
...@@ -127,4 +136,3 @@ export type GitStatusResult = z.infer<typeof gitStatusResultSchema>;...@@ -127,4 +136,3 @@ export type GitStatusResult = z.infer<typeof gitStatusResultSchema>;
127export type GitStackedAction = z.infer<typeof gitStackedActionSchema>;136export type GitStackedAction = z.infer<typeof gitStackedActionSchema>;
128export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>;137export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>;
129export type GitRunStackedActionResult = z.infer<typeof gitRunStackedActionResultSchema>;138export type GitRunStackedActionResult = z.infer<typeof gitRunStackedActionResultSchema>;
130