| author | |
| committer | |
| log | 6fc632f9a162abcfaa8ed1bdc659ecce526f26ed |
| tree | e529cdb586d928c9ecfe7f6c50ffa95429c0d7f9 |
| parent | ae296e59a427ca67bca9ed9e43990adbb371d2e7 |
- 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 behavior14 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 | }); |
| 439 | 439 | ||
| 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 @@ |
| 1 | import { spawn } from "node:child_process"; | ||
| 2 | import fs from "node:fs"; | 1 | import fs from "node:fs"; |
| 3 | import os from "node:os"; | 2 | import os from "node:os"; |
| 4 | import path from "node:path"; | 3 | import 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"; |
| 19 | import { type ProcessRunResult, runProcess } from "./processRunner"; | ||
| 20 | 20 | ||
| 21 | export interface TerminalCommandInput { | 21 | export 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 | } |
| 27 | 27 | ||
| 28 | export interface TerminalCommandResult { | 28 | export type TerminalCommandResult = ProcessRunResult; |
| 29 | stdout: string; | ||
| 30 | stderr: string; | ||
| 31 | code: number | null; | ||
| 32 | signal: NodeJS.Signals | null; | ||
| 33 | timedOut: boolean; | ||
| 34 | } | ||
| 35 | 29 | ||
| 36 | export interface GitStatusDetails extends GitStatusResult { | 30 | export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> { |
| 37 | upstreamRef: string | null; | 31 | upstreamRef: string | null; |
| 38 | } | 32 | } |
| 39 | 33 | ||
| ... | @@ -60,84 +54,26 @@ interface RunGitOptions { | ... | @@ -60,84 +54,26 @@ interface RunGitOptions { |
| 60 | allowNonZeroExit?: boolean | undefined; | 54 | allowNonZeroExit?: boolean | undefined; |
| 61 | } | 55 | } |
| 62 | 56 | ||
| 63 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | 57 | interface ExecuteGitOptions { |
| 64 | 58 | timeoutMs?: number | undefined; | |
| 65 | function 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 | } |
| 92 | 62 | ||
| 93 | /** Spawn git directly with an argv array — no shell, no quoting needed. */ | 63 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; |
| 94 | function 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 | }); | ||
| 101 | 64 | ||
| 102 | const maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES; | 65 | /** Spawn git directly with an argv array — no shell, no quoting needed. */ |
| 103 | let stdout = ""; | 66 | function 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, { |
| 109 | 72 | 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 | } |
| 143 | 79 | ||
| ... | @@ -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 | } |
| 208 | 143 | ||
| 144 | async 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 | |||
| 209 | export class GitCoreService { | 168 | export 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 | } |
| 221 | 181 | ||
| ... | @@ -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 | } |
| 354 | 314 | ||
| 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 | } |
| 358 | 432 | ||
| 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 | } |
| 364 | 438 | ||
| 439 | const defaultGitCoreService = new GitCoreService(); | ||
| 440 | |||
| 365 | export async function runTerminalCommand( | 441 | export 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]; |
| 376 | 452 | ||
| 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 | |||
| 436 | export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> { | ||
| 437 | const result = await runGit(["branch", "--no-color"], input.cwd, 10_000); | ||
| 438 | 460 | ||
| 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 | } |
| 468 | 464 | ||
| 469 | const branches = result.stdout | 465 | 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 | }); | ||
| 487 | 470 | ||
| 488 | return { branches, isRepo: true }; | 471 | export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> { |
| 472 | return defaultGitCoreService.listBranches(input); | ||
| 489 | } | 473 | } |
| 490 | 474 | ||
| 491 | export async function createGitWorktree( | 475 | export 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 | } |
| 515 | 480 | ||
| 516 | export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> { | 481 | export 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 | } |
| 523 | 484 | ||
| 524 | export async function createGitBranch(input: GitCreateBranchInput): Promise<void> { | 485 | export 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 | } |
| 531 | 488 | ||
| 532 | export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> { | 489 | export 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 | } |
| 539 | 492 | ||
| 540 | export async function initGitRepo(input: GitInitInput): Promise<void> { | 493 | export 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 | }); |
| 169 | 169 | ||
| 170 | describe("GitManager", () => { | 170 | describe("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"; |
| 6 | import { | 6 | import { |
| 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"; |
| 20 | import { CodexTextGenerator } from "./codexTextGenerator"; | 21 | import { CodexTextGenerator } from "./codexTextGenerator"; |
| 21 | import { GitCoreService } from "./git"; | 22 | import { GitCoreService } from "./git"; |
| 22 | import { type ProcessRunOptions, runProcess } from "./processRunner"; | 23 | import { |
| 24 | type ProcessRunOptions, | ||
| 25 | type ProcessRunResult, | ||
| 26 | runProcess, | ||
| 27 | } from "./processRunner"; | ||
| 23 | 28 | ||
| 24 | const GH_CLI_TIMEOUT_MS = 30_000; | 29 | const GH_CLI_TIMEOUT_MS = 30_000; |
| 25 | 30 | ||
| ... | @@ -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 | } |
| 157 | 156 | ||
| 157 | function 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 | |||
| 158 | export class GitManager { | 173 | export 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 | } |
| 168 | 183 | ||
| 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 | } |
| 172 | 209 | ||
| 173 | async runStackedAction( | 210 | async runStackedAction( |
| ... | @@ -413,24 +450,23 @@ export class GitManager { | ... | @@ -413,24 +450,23 @@ export class GitManager { |
| 413 | } | 450 | } |
| 414 | 451 | ||
| 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 | } |
| 426 | 455 | ||
| 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 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { runProcess } from "./processRunner"; | ||
| 4 | |||
| 5 | describe("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 | } |
| 11 | 12 | ||
| 12 | export interface ProcessRunResult { | 13 | export 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 | } |
| 19 | 22 | ||
| 20 | function commandLabel(command: string, args: readonly string[]): string { | 23 | function commandLabel(command: string, args: readonly string[]): string { |
| ... | @@ -79,6 +82,34 @@ function normalizeBufferError( | ... | @@ -79,6 +82,34 @@ function normalizeBufferError( |
| 79 | 82 | ||
| 80 | const DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024; | 83 | const DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024; |
| 81 | 84 | ||
| 85 | function 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 | |||
| 82 | export async function runProcess( | 113 | export 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"; | ||
| 89 | 121 | ||
| 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 | }; |
| 180 | 242 | ||
| 181 | finalize(() => { | 243 | finalize(() => { |
apps/server/src/ptyAdapter.test.ts created+54| ... | @@ -0,0 +1,54 @@ | ||
| 1 | import fs from "node:fs"; | ||
| 2 | import os from "node:os"; | ||
| 3 | import path from "node:path"; | ||
| 4 | |||
| 5 | import { afterEach, describe, expect, it } from "vitest"; | ||
| 6 | |||
| 7 | import { ensureNodePtySpawnHelperExecutable } from "./ptyAdapter"; | ||
| 8 | |||
| 9 | function fileMode(filePath: string): number { | ||
| 10 | return fs.statSync(filePath).mode & 0o777; | ||
| 11 | } | ||
| 12 | |||
| 13 | describe("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 @@ |
| 1 | import fs from "node:fs"; | ||
| 2 | import { createRequire } from "node:module"; | ||
| 3 | import path from "node:path"; | ||
| 4 | |||
| 1 | import * as nodePty from "node-pty"; | 5 | import * as nodePty from "node-pty"; |
| 2 | 6 | ||
| 3 | export interface PtyExitEvent { | 7 | export 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 | } |
| 28 | 32 | ||
| 33 | const requireForNodePty = createRequire(import.meta.url); | ||
| 34 | let didEnsureSpawnHelperExecutable = false; | ||
| 35 | |||
| 36 | function 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 | |||
| 63 | export 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 | |||
| 29 | class NodePtyProcess implements PtyProcess { | 84 | class NodePtyProcess implements PtyProcess { |
| 30 | constructor(private readonly process: nodePty.IPty) {} | 85 | constructor(private readonly process: nodePty.IPty) {} |
| 31 | 86 | ||
| ... | @@ -67,12 +122,16 @@ class NodePtyProcess implements PtyProcess { | ... | @@ -67,12 +122,16 @@ class NodePtyProcess implements PtyProcess { |
| 67 | 122 | ||
| 68 | export class NodePtyAdapter implements PtyAdapter { | 123 | export 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 { |
| 62 | class FakePtyAdapter implements PtyAdapter { | 62 | class 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; |
| 66 | 67 | ||
| 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 | }); |
| 119 | 124 | ||
| 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", () => { |
| 266 | 274 | ||
| 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 | } |
| 59 | 59 | ||
| 60 | function 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 | |||
| 74 | function 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 | |||
| 86 | function 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 | |||
| 110 | function 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 | |||
| 60 | function capHistory(history: string, maxLines: number): string { | 122 | function 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(); |
| 272 | 334 | ||
| 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 | |||
| 69 | function 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 | } |
| 67 | 130 | ||
| 68 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | 131 | function 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 []; |
| 278 | 341 | ||
| 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 = |
| 282 | 346 | !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 | } | ||
| 332 | 352 | ||
| 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 | <button | 914 | <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 actions | 931 | 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 | <button | 935 | <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 | }); |
| 78 | 87 | ||
| 79 | export const gitStackedActionSchema = z.enum([ | 88 | export 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>; |
| 127 | export type GitStackedAction = z.infer<typeof gitStackedActionSchema>; | 136 | export type GitStackedAction = z.infer<typeof gitStackedActionSchema>; |
| 128 | export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>; | 137 | export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>; |
| 129 | export type GitRunStackedActionResult = z.infer<typeof gitRunStackedActionResultSchema>; | 138 | export type GitRunStackedActionResult = z.infer<typeof gitRunStackedActionResultSchema>; |
| 130 |