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", () => {
438438 });
439439
440440 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
441460 it("reports status details and dirty state", async () => {
442461 await using tmp = await makeTmpDir();
443462 await initRepoWithCommit(tmp.path);
apps/server/src/git.ts+187-238
......@@ -1,4 +1,3 @@
1import { spawn } from "node:child_process";
21import fs from "node:fs";
32import os from "node:os";
43import path from "node:path";
......@@ -17,6 +16,7 @@ import {
1716 type GitStatusInput,
1817 type GitStatusResult,
1918} from "@t3tools/contracts";
19import { type ProcessRunResult, runProcess } from "./processRunner";
2020
2121export interface TerminalCommandInput {
2222 command: string;
......@@ -25,15 +25,9 @@ export interface TerminalCommandInput {
2525 maxOutputBytes?: number;
2626}
2727
28export interface TerminalCommandResult {
29 stdout: string;
30 stderr: string;
31 code: number | null;
32 signal: NodeJS.Signals | null;
33 timedOut: boolean;
34}
28export type TerminalCommandResult = ProcessRunResult;
3529
36export interface GitStatusDetails extends GitStatusResult {
30export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> {
3731 upstreamRef: string | null;
3832}
3933
......@@ -60,84 +54,26 @@ interface RunGitOptions {
6054 allowNonZeroExit?: boolean | undefined;
6155}
6256
63const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
64
65function appendChunkWithinLimit(
66 target: string,
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 };
57interface ExecuteGitOptions {
58 timeoutMs?: number | undefined;
59 allowNonZeroExit?: boolean | undefined;
60 fallbackErrorMessage?: string | undefined;
9161}
9262
93/** Spawn git directly with an argv array — no shell, no quoting needed. */
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 });
63const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
10164
102 const maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES;
103 let stdout = "";
104 let stderr = "";
105 let stdoutBytes = 0;
106 let stderrBytes = 0;
107 let timedOut = false;
108 let outputTruncated = false;
109
110 const timeout = setTimeout(() => {
111 timedOut = true;
112 child.kill("SIGTERM");
113 setTimeout(() => {
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 });
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",
14177 });
14278}
14379
......@@ -162,11 +98,10 @@ function normalizeGitSpawnError(cwd: string, error: unknown): Error {
16298 if (!(error instanceof Error)) {
16399 return new Error("Failed to run git command.");
164100 }
165 const code = (error as NodeJS.ErrnoException).code;
166 if (code === "ENOENT") {
167 if (!fs.existsSync(cwd)) {
168 return new Error(`Working directory does not exist: ${cwd}`);
169 }
101 if (!fs.existsSync(cwd)) {
102 return new Error(`Working directory does not exist: ${cwd}`);
103 }
104 if (error.message.includes("Command not found: git")) {
170105 return new Error("Git is required but not available on PATH.");
171106 }
172107 return new Error(`Failed to run git command: ${error.message}`);
......@@ -206,6 +141,30 @@ async function runGitOrThrow(
206141 return result;
207142}
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
209168export class GitCoreService {
210169 async status(raw: GitStatusInput): Promise<GitStatusResult> {
211170 const input = gitStatusInputSchema.parse(raw);
......@@ -216,6 +175,7 @@ export class GitCoreService {
216175 hasUpstream: details.hasUpstream,
217176 aheadCount: details.aheadCount,
218177 behindCount: details.behindCount,
178 openPr: null,
219179 });
220180 }
221181
......@@ -352,16 +312,132 @@ export class GitCoreService {
352312 return value.length > 0 ? value : null;
353313 }
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
355429 async git(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise<void> {
356 await runGitOrThrow(cwd, args, { allowNonZeroExit });
430 await executeGit(cwd, args, { allowNonZeroExit });
357431 }
358432
359433 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 });
361435 return result.stdout;
362436 }
363437}
364438
439const defaultGitCoreService = new GitCoreService();
440
365441export async function runTerminalCommand(
366442 input: TerminalCommandInput,
367443): Promise<TerminalCommandResult> {
......@@ -374,173 +450,46 @@ export async function runTerminalCommand(
374450 const args =
375451 process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command];
376452
377 return new Promise((resolve, reject) => {
378 const child = spawn(shellPath, args, {
379 cwd: input.cwd,
380 env: process.env,
381 stdio: ["ignore", "pipe", "pipe"],
382 });
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 });
453 const result = await runProcess(shellPath, args, {
454 cwd: input.cwd,
455 timeoutMs: input.timeoutMs ?? 30_000,
456 allowNonZeroExit: true,
457 maxBufferBytes: maxOutputBytes,
458 outputMode: "truncate",
433459 });
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) {
440 const stderr = result.stderr.trim();
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 }
461 if (!result.stdoutTruncated && !result.stderrTruncated) {
462 return result;
467463 }
468464
469 const branches = result.stdout
470 .split("\n")
471 .map((line) => line.trim())
472 .filter((line) => line.length > 0)
473 .map((line) => {
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 });
465 return {
466 ...result,
467 stderr: `${result.stderr}\n[output truncated at ${maxOutputBytes} bytes]`,
468 };
469}
487470
488 return { branches, isRepo: true };
471export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {
472 return defaultGitCoreService.listBranches(input);
489473}
490474
491475export async function createGitWorktree(
492476 input: GitCreateWorktreeInput,
493477): Promise<GitCreateWorktreeResult> {
494 const sanitizedBranch = input.newBranch.replace(/\//g, "-");
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 };
478 return defaultGitCoreService.createWorktree(input);
514479}
515480
516481export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> {
517 const result = await runGit(["worktree", "remove", input.path], input.cwd, 15_000);
518
519 if (result.code !== 0) {
520 throw new Error(result.stderr.trim() || "git worktree remove failed");
521 }
482 await defaultGitCoreService.removeWorktree(input);
522483}
523484
524485export async function createGitBranch(input: GitCreateBranchInput): Promise<void> {
525 const result = await runGit(["branch", input.branch], input.cwd, 10_000);
526
527 if (result.code !== 0) {
528 throw new Error(result.stderr.trim() || "git branch create failed");
529 }
486 await defaultGitCoreService.createBranch(input);
530487}
531488
532489export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> {
533 const result = await runGit(["checkout", input.branch], input.cwd, 10_000);
534
535 if (result.code !== 0) {
536 throw new Error(result.stderr.trim() || "git checkout failed");
537 }
490 await defaultGitCoreService.checkoutBranch(input);
538491}
539492
540493export async function initGitRepo(input: GitInitInput): Promise<void> {
541 const result = await runGit(["init"], input.cwd, 10_000);
542
543 if (result.code !== 0) {
544 throw new Error(result.stderr.trim() || "git init failed");
545 }
494 await defaultGitCoreService.initRepo(input);
546495}
apps/server/src/gitManager.test.ts+59
......@@ -168,6 +168,65 @@ afterEach(() => {
168168});
169169
170170describe("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
171230 it("creates a commit when working tree is dirty", async () => {
172231 const repoDir = makeTempDir("t3code-git-manager-");
173232 await initRepo(repoDir);
apps/server/src/gitManager.ts+56-20
......@@ -6,6 +6,7 @@ import path from "node:path";
66import {
77 gitRunStackedActionInputSchema,
88 gitRunStackedActionResultSchema,
9 gitStatusInputSchema,
910 type GitRunStackedActionInput,
1011 type GitRunStackedActionResult,
1112 type GitStatusInput,
......@@ -19,7 +20,11 @@ import type {
1920} from "./coreServices";
2021import { CodexTextGenerator } from "./codexTextGenerator";
2122import { GitCoreService } from "./git";
22import { type ProcessRunOptions, runProcess } from "./processRunner";
23import {
24 type ProcessRunOptions,
25 type ProcessRunResult,
26 runProcess,
27} from "./processRunner";
2328
2429const GH_CLI_TIMEOUT_MS = 30_000;
2530
......@@ -28,13 +33,7 @@ interface GitManagerDeps {
2833 command: string,
2934 args: readonly string[],
3035 options?: ProcessRunOptions,
31 ) => Promise<{
32 stdout: string;
33 stderr: string;
34 code: number | null;
35 signal: NodeJS.Signals | null;
36 timedOut: boolean;
37 }>;
36 ) => Promise<ProcessRunResult>;
3837 textGenerator?: TextGenerationService;
3938 gitCore?: GitCoreService;
4039}
......@@ -155,6 +154,22 @@ function normalizeGitHubAuthError(error: unknown): Error | undefined {
155154 return undefined;
156155}
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
158173export class GitManager {
159174 private readonly run: NonNullable<GitManagerDeps["runProcess"]>;
160175 private readonly textGenerator: TextGenerationService;
......@@ -167,7 +182,29 @@ export class GitManager {
167182 }
168183
169184 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 };
171208 }
172209
173210 async runStackedAction(
......@@ -413,24 +450,23 @@ export class GitManager {
413450 }
414451
415452 private async runGh(cwd: string, args: readonly string[]): Promise<void> {
416 try {
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 }
453 await this.runGhCommand(cwd, args);
425454 }
426455
427456 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> {
428465 try {
429 const result = await this.run("gh", args, {
466 return await this.run("gh", args, {
430467 cwd,
431468 timeoutMs: GH_CLI_TIMEOUT_MS,
432469 });
433 return result.stdout;
434470 } catch (error) {
435471 throw (
436472 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 {
77 stdin?: string | undefined;
88 allowNonZeroExit?: boolean | undefined;
99 maxBufferBytes?: number | undefined;
10 outputMode?: "error" | "truncate" | undefined;
1011}
1112
1213export interface ProcessRunResult {
......@@ -15,6 +16,8 @@ export interface ProcessRunResult {
1516 code: number | null;
1617 signal: NodeJS.Signals | null;
1718 timedOut: boolean;
19 stdoutTruncated?: boolean | undefined;
20 stderrTruncated?: boolean | undefined;
1821}
1922
2023function commandLabel(command: string, args: readonly string[]): string {
......@@ -79,6 +82,34 @@ function normalizeBufferError(
7982
8083const 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
82113export async function runProcess(
83114 command: string,
84115 args: readonly string[],
......@@ -86,6 +117,7 @@ export async function runProcess(
86117): Promise<ProcessRunResult> {
87118 const timeoutMs = options.timeoutMs ?? 60_000;
88119 const maxBufferBytes = options.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
120 const outputMode = options.outputMode ?? "error";
89121
90122 return new Promise<ProcessRunResult>((resolve, reject) => {
91123 const child = spawn(command, args, {
......@@ -98,6 +130,8 @@ export async function runProcess(
98130 let stderr = "";
99131 let stdoutBytes = 0;
100132 let stderrBytes = 0;
133 let stdoutTruncated = false;
134 let stderrTruncated = false;
101135 let timedOut = false;
102136 let settled = false;
103137 let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
......@@ -131,15 +165,41 @@ export async function runProcess(
131165 stream: "stdout" | "stderr",
132166 chunk: Buffer | string,
133167 ): Error | null => {
134 const text = chunk.toString();
135 const byteLength = Buffer.byteLength(text);
168 const chunkBuffer =
169 typeof chunk === "string" ? Buffer.from(chunk) : chunk;
170 const text = chunkBuffer.toString();
171 const byteLength = chunkBuffer.length;
136172 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 }
137185 stdout += text;
138186 stdoutBytes += byteLength;
139187 if (stdoutBytes > maxBufferBytes) {
140188 return normalizeBufferError(command, args, "stdout", maxBufferBytes);
141189 }
142190 } 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 }
143203 stderr += text;
144204 stderrBytes += byteLength;
145205 if (stderrBytes > maxBufferBytes) {
......@@ -176,6 +236,8 @@ export async function runProcess(
176236 code,
177237 signal,
178238 timedOut,
239 stdoutTruncated,
240 stderrTruncated,
179241 };
180242
181243 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 @@
1import fs from "node:fs";
2import { createRequire } from "node:module";
3import path from "node:path";
4
15import * as nodePty from "node-pty";
26
37export interface PtyExitEvent {
......@@ -26,6 +30,57 @@ export interface PtyAdapter {
2630 spawn(input: PtySpawnInput): PtyProcess;
2731}
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
2984class NodePtyProcess implements PtyProcess {
3085 constructor(private readonly process: nodePty.IPty) {}
3186
......@@ -67,12 +122,16 @@ class NodePtyProcess implements PtyProcess {
67122
68123export class NodePtyAdapter implements PtyAdapter {
69124 spawn(input: PtySpawnInput): PtyProcess {
125 ensureNodePtySpawnHelperExecutable();
70126 const ptyProcess = nodePty.spawn(input.shell, [], {
71127 cwd: input.cwd,
72128 cols: input.cols,
73129 rows: input.rows,
74130 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",
76135 });
77136 return new NodePtyProcess(ptyProcess);
78137 }
apps/server/src/terminalManager.test.ts+41-2
......@@ -62,10 +62,15 @@ class FakePtyProcess implements PtyProcess {
6262class FakePtyAdapter implements PtyAdapter {
6363 readonly spawnInputs: PtySpawnInput[] = [];
6464 readonly processes: FakePtyProcess[] = [];
65 readonly spawnFailures: Error[] = [];
6566 private nextPid = 9000;
6667
6768 spawn(input: PtySpawnInput): PtyProcess {
6869 this.spawnInputs.push(input);
70 const failure = this.spawnFailures.shift();
71 if (failure) {
72 throw failure;
73 }
6974 const process = new FakePtyProcess(this.nextPid++);
7075 this.processes.push(process);
7176 return process;
......@@ -117,7 +122,10 @@ describe("TerminalManager", () => {
117122 }
118123 });
119124
120 function makeManager(historyLineLimit = 5) {
125 function makeManager(
126 historyLineLimit = 5,
127 options: { shellResolver?: () => string } = {},
128 ) {
121129 const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-"));
122130 tempDirs.push(logsDir);
123131 const ptyAdapter = new FakePtyAdapter();
......@@ -125,7 +133,7 @@ describe("TerminalManager", () => {
125133 logsDir,
126134 ptyAdapter,
127135 historyLineLimit,
128 shellResolver: () => "/bin/bash",
136 shellResolver: options.shellResolver ?? (() => "/bin/bash"),
129137 });
130138 return { logsDir, ptyAdapter, manager };
131139 }
......@@ -266,4 +274,35 @@ describe("TerminalManager", () => {
266274
267275 manager.dispose();
268276 });
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 });
269308});
apps/server/src/terminalManager.ts+98-8
......@@ -57,6 +57,68 @@ function defaultShellResolver(): string {
5757 return process.env.SHELL ?? "bash";
5858}
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
60122function capHistory(history: string, maxLines: number): string {
61123 if (history.length === 0) return history;
62124 const hasTrailingNewline = history.endsWith("\n");
......@@ -270,16 +332,43 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
270332 session.exitSignal = null;
271333 session.updatedAt = new Date().toISOString();
272334
273 const shell = this.shellResolver();
274335 let ptyProcess: PtyProcess | null = null;
336 let startedShell: string | null = null;
275337 try {
276 ptyProcess = this.ptyAdapter.spawn({
277 shell,
278 cwd: session.cwd,
279 cols: session.cols,
280 rows: session.rows,
281 env: process.env,
282 });
338 const shellCandidates = resolveShellCandidates(this.shellResolver);
339 let lastSpawnError: unknown = null;
340
341 for (const shell of shellCandidates) {
342 try {
343 ptyProcess = this.ptyAdapter.spawn({
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
283372 session.process = ptyProcess;
284373 session.pid = ptyProcess.pid;
285374 session.status = "running";
......@@ -318,6 +407,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
318407 this.logger.error("failed to start terminal", {
319408 threadId: session.threadId,
320409 error: message,
410 ...(startedShell ? { shell: startedShell } : {}),
321411 });
322412 }
323413 }
apps/server/src/wsServer.test.ts+2
......@@ -524,6 +524,7 @@ describe("WebSocket Server", () => {
524524 hasUpstream: false,
525525 aheadCount: 0,
526526 behindCount: 0,
527 openPr: null,
527528 }),
528529 runStackedAction: vi.fn(),
529530 };
......@@ -547,6 +548,7 @@ describe("WebSocket Server", () => {
547548 hasUpstream: false,
548549 aheadCount: 0,
549550 behindCount: 0,
551 openPr: null,
550552 });
551553 expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" });
552554 });
apps/web/src/components/ChatView.tsx+98-60
......@@ -63,6 +63,69 @@ interface GitActionMenuItem {
6363 id: GitStackedAction;
6464 label: string;
6565 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 );
66129}
67130
68131function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
......@@ -277,65 +340,34 @@ export default function ChatView() {
277340 if (!gitStatus) return [];
278341
279342 const hasBranch = gitStatus.branch !== null;
343 const hasOpenPr = gitStatus.openPr !== null;
280344 const canCommit = !gitBaseDisabled && gitStatus.hasWorkingTreeChanges;
281 const canPushOrPr = !gitBaseDisabled && hasBranch;
282
283 if (!hasBranch) {
284 return [
285 {
286 id: "commit",
287 label: "Commit",
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 }
345 const canPush =
346 !gitBaseDisabled && hasBranch && (gitStatus.hasWorkingTreeChanges || gitStatus.aheadCount > 0);
347 const canViewPr =
348 !gitBaseDisabled &&
349 hasBranch &&
350 gitStatus.behindCount === 0 &&
351 (gitStatus.hasWorkingTreeChanges || gitStatus.aheadCount > 0 || hasOpenPr);
332352
333 const openPrDisabled = !canPushOrPr || gitStatus.behindCount > 0;
334353 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 },
335366 {
336367 id: "commit_push_pr",
337 label: "Open PR",
338 disabled: openPrDisabled,
368 label: "View PR",
369 disabled: !canViewPr,
370 icon: "pr",
339371 },
340372 ];
341373 }, [gitBaseDisabled, gitStatus]);
......@@ -882,15 +914,20 @@ export default function ChatView() {
882914 <button
883915 type="button"
884916 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 }}
886923 disabled={!gitStatus || isGitActionRunning}
887924 >
888925 {isGitActionRunning ? "Running..." : "Git actions"}
889926 <span aria-hidden="true">▾</span>
890927 </button>
891928 {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">
893 <p className="px-2 pb-1 text-[10px] uppercase tracking-[0.1em] text-muted-foreground/60">
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">
930 <p className="px-3 pb-2 text-[13px] text-muted-foreground/75">
894931 Git actions
895932 </p>
896933 {gitActionMenuItems.map((item) => {
......@@ -898,13 +935,14 @@ export default function ChatView() {
898935 <button
899936 key={item.id}
900937 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"
902939 disabled={item.disabled}
903940 onClick={() => {
904941 void runGitAction(item.id);
905942 }}
906943 >
907 {item.label}
944 <GitActionIcon icon={item.icon} disabled={item.disabled} />
945 <span>{item.label}</span>
908946 </button>
909947 );
910948 })}
......@@ -918,7 +956,7 @@ export default function ChatView() {
918956 !gitStatus.hasWorkingTreeChanges &&
919957 gitStatus.aheadCount === 0 &&
920958 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">
922960 Branch is behind upstream. Pull/rebase before opening a PR.
923961 </p>
924962 )}
packages/contracts/src/git.test.ts+1-1
......@@ -21,6 +21,7 @@ describe("git contracts", () => {
2121 hasUpstream: false,
2222 aheadCount: 0,
2323 behindCount: 0,
24 openPr: null,
2425 });
2526 expect(parsed.branch).toBe("feature/git-actions");
2627 expect(parsed.hasWorkingTreeChanges).toBe(true);
......@@ -70,4 +71,3 @@ describe("git contracts", () => {
7071 expect(parsed.pr.number).toBe(123);
7172 });
7273});
73
packages/contracts/src/git.ts+9-1
......@@ -74,6 +74,15 @@ export const gitStatusResultSchema = z.object({
7474 hasUpstream: z.boolean(),
7575 aheadCount: z.number().int().nonnegative(),
7676 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(),
7786});
7887
7988export const gitStackedActionSchema = z.enum([
......@@ -127,4 +136,3 @@ export type GitStatusResult = z.infer<typeof gitStatusResultSchema>;
127136export type GitStackedAction = z.infer<typeof gitStackedActionSchema>;
128137export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>;
129138export type GitRunStackedActionResult = z.infer<typeof gitRunStackedActionResultSchema>;
130