| 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 | 438 | }); |
| 439 | 439 | |
| 440 | 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 | 460 | it("reports status details and dirty state", async () => { |
| 442 | 461 | await using tmp = await makeTmpDir(); |
| 443 | 462 | await initRepoWithCommit(tmp.path); |
apps/server/src/git.ts+187-238| ... | ... | @@ -1,4 +1,3 @@ |
| 1 | import { spawn } from "node:child_process"; | |
| 2 | 1 | import fs from "node:fs"; |
| 3 | 2 | import os from "node:os"; |
| 4 | 3 | import path from "node:path"; |
| ... | ... | @@ -17,6 +16,7 @@ import { |
| 17 | 16 | type GitStatusInput, |
| 18 | 17 | type GitStatusResult, |
| 19 | 18 | } from "@t3tools/contracts"; |
| 19 | import { type ProcessRunResult, runProcess } from "./processRunner"; | |
| 20 | 20 | |
| 21 | 21 | export interface TerminalCommandInput { |
| 22 | 22 | command: string; |
| ... | ... | @@ -25,15 +25,9 @@ export interface TerminalCommandInput { |
| 25 | 25 | maxOutputBytes?: number; |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | export interface TerminalCommandResult { | |
| 29 | stdout: string; | |
| 30 | stderr: string; | |
| 31 | code: number | null; | |
| 32 | signal: NodeJS.Signals | null; | |
| 33 | timedOut: boolean; | |
| 34 | } | |
| 28 | export type TerminalCommandResult = ProcessRunResult; | |
| 35 | 29 | |
| 36 | export interface GitStatusDetails extends GitStatusResult { | |
| 30 | export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> { | |
| 37 | 31 | upstreamRef: string | null; |
| 38 | 32 | } |
| 39 | 33 | |
| ... | ... | @@ -60,84 +54,26 @@ interface RunGitOptions { |
| 60 | 54 | allowNonZeroExit?: boolean | undefined; |
| 61 | 55 | } |
| 62 | 56 | |
| 63 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | |
| 64 | ||
| 65 | function 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 | }; | |
| 57 | interface ExecuteGitOptions { | |
| 58 | timeoutMs?: number | undefined; | |
| 59 | allowNonZeroExit?: boolean | undefined; | |
| 60 | fallbackErrorMessage?: string | undefined; | |
| 91 | 61 | } |
| 92 | 62 | |
| 93 | /** Spawn git directly with an argv array — no shell, no quoting needed. */ | |
| 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 | }); | |
| 63 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | |
| 101 | 64 | |
| 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. */ | |
| 66 | function runGit( | |
| 67 | args: readonly string[], | |
| 68 | cwd: string, | |
| 69 | timeoutMs = 30_000, | |
| 70 | ): Promise<ProcessRunResult> { | |
| 71 | return runProcess("git", args, { | |
| 72 | cwd, | |
| 73 | timeoutMs, | |
| 74 | allowNonZeroExit: true, | |
| 75 | maxBufferBytes: DEFAULT_MAX_OUTPUT_BYTES, | |
| 76 | outputMode: "truncate", | |
| 141 | 77 | }); |
| 142 | 78 | } |
| 143 | 79 | |
| ... | ... | @@ -162,11 +98,10 @@ function normalizeGitSpawnError(cwd: string, error: unknown): Error { |
| 162 | 98 | if (!(error instanceof Error)) { |
| 163 | 99 | return new Error("Failed to run git command."); |
| 164 | 100 | } |
| 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")) { | |
| 170 | 105 | return new Error("Git is required but not available on PATH."); |
| 171 | 106 | } |
| 172 | 107 | return new Error(`Failed to run git command: ${error.message}`); |
| ... | ... | @@ -206,6 +141,30 @@ async function runGitOrThrow( |
| 206 | 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 | 168 | export class GitCoreService { |
| 210 | 169 | async status(raw: GitStatusInput): Promise<GitStatusResult> { |
| 211 | 170 | const input = gitStatusInputSchema.parse(raw); |
| ... | ... | @@ -216,6 +175,7 @@ export class GitCoreService { |
| 216 | 175 | hasUpstream: details.hasUpstream, |
| 217 | 176 | aheadCount: details.aheadCount, |
| 218 | 177 | behindCount: details.behindCount, |
| 178 | openPr: null, | |
| 219 | 179 | }); |
| 220 | 180 | } |
| 221 | 181 | |
| ... | ... | @@ -352,16 +312,132 @@ export class GitCoreService { |
| 352 | 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 | 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 | 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 | 435 | return result.stdout; |
| 362 | 436 | } |
| 363 | 437 | } |
| 364 | 438 | |
| 439 | const defaultGitCoreService = new GitCoreService(); | |
| 440 | ||
| 365 | 441 | export async function runTerminalCommand( |
| 366 | 442 | input: TerminalCommandInput, |
| 367 | 443 | ): Promise<TerminalCommandResult> { |
| ... | ... | @@ -374,173 +450,46 @@ export async function runTerminalCommand( |
| 374 | 450 | const args = |
| 375 | 451 | process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command]; |
| 376 | 452 | |
| 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", | |
| 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) { | |
| 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; | |
| 467 | 463 | } |
| 468 | 464 | |
| 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 | } | |
| 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 | 475 | export async function createGitWorktree( |
| 492 | 476 | input: GitCreateWorktreeInput, |
| 493 | 477 | ): 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); | |
| 514 | 479 | } |
| 515 | 480 | |
| 516 | 481 | export 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); | |
| 522 | 483 | } |
| 523 | 484 | |
| 524 | 485 | export 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); | |
| 530 | 487 | } |
| 531 | 488 | |
| 532 | 489 | export 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); | |
| 538 | 491 | } |
| 539 | 492 | |
| 540 | 493 | export 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); | |
| 546 | 495 | } |
apps/server/src/gitManager.test.ts+59| ... | ... | @@ -168,6 +168,65 @@ afterEach(() => { |
| 168 | 168 | }); |
| 169 | 169 | |
| 170 | 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 | 230 | it("creates a commit when working tree is dirty", async () => { |
| 172 | 231 | const repoDir = makeTempDir("t3code-git-manager-"); |
| 173 | 232 | await initRepo(repoDir); |
apps/server/src/gitManager.ts+56-20| ... | ... | @@ -6,6 +6,7 @@ import path from "node:path"; |
| 6 | 6 | import { |
| 7 | 7 | gitRunStackedActionInputSchema, |
| 8 | 8 | gitRunStackedActionResultSchema, |
| 9 | gitStatusInputSchema, | |
| 9 | 10 | type GitRunStackedActionInput, |
| 10 | 11 | type GitRunStackedActionResult, |
| 11 | 12 | type GitStatusInput, |
| ... | ... | @@ -19,7 +20,11 @@ import type { |
| 19 | 20 | } from "./coreServices"; |
| 20 | 21 | import { CodexTextGenerator } from "./codexTextGenerator"; |
| 21 | 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 | 29 | const GH_CLI_TIMEOUT_MS = 30_000; |
| 25 | 30 | |
| ... | ... | @@ -28,13 +33,7 @@ interface GitManagerDeps { |
| 28 | 33 | command: string, |
| 29 | 34 | args: readonly string[], |
| 30 | 35 | 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>; | |
| 38 | 37 | textGenerator?: TextGenerationService; |
| 39 | 38 | gitCore?: GitCoreService; |
| 40 | 39 | } |
| ... | ... | @@ -155,6 +154,22 @@ function normalizeGitHubAuthError(error: unknown): Error | undefined { |
| 155 | 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 | 173 | export class GitManager { |
| 159 | 174 | private readonly run: NonNullable<GitManagerDeps["runProcess"]>; |
| 160 | 175 | private readonly textGenerator: TextGenerationService; |
| ... | ... | @@ -167,7 +182,29 @@ export class GitManager { |
| 167 | 182 | } |
| 168 | 183 | |
| 169 | 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 | 210 | async runStackedAction( |
| ... | ... | @@ -413,24 +450,23 @@ export class GitManager { |
| 413 | 450 | } |
| 414 | 451 | |
| 415 | 452 | 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); | |
| 425 | 454 | } |
| 426 | 455 | |
| 427 | 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 | 465 | try { |
| 429 | const result = await this.run("gh", args, { | |
| 466 | return await this.run("gh", args, { | |
| 430 | 467 | cwd, |
| 431 | 468 | timeoutMs: GH_CLI_TIMEOUT_MS, |
| 432 | 469 | }); |
| 433 | return result.stdout; | |
| 434 | 470 | } catch (error) { |
| 435 | 471 | throw ( |
| 436 | 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 | 7 | stdin?: string | undefined; |
| 8 | 8 | allowNonZeroExit?: boolean | undefined; |
| 9 | 9 | maxBufferBytes?: number | undefined; |
| 10 | outputMode?: "error" | "truncate" | undefined; | |
| 10 | 11 | } |
| 11 | 12 | |
| 12 | 13 | export interface ProcessRunResult { |
| ... | ... | @@ -15,6 +16,8 @@ export interface ProcessRunResult { |
| 15 | 16 | code: number | null; |
| 16 | 17 | signal: NodeJS.Signals | null; |
| 17 | 18 | timedOut: boolean; |
| 19 | stdoutTruncated?: boolean | undefined; | |
| 20 | stderrTruncated?: boolean | undefined; | |
| 18 | 21 | } |
| 19 | 22 | |
| 20 | 23 | function commandLabel(command: string, args: readonly string[]): string { |
| ... | ... | @@ -79,6 +82,34 @@ function normalizeBufferError( |
| 79 | 82 | |
| 80 | 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 | 113 | export async function runProcess( |
| 83 | 114 | command: string, |
| 84 | 115 | args: readonly string[], |
| ... | ... | @@ -86,6 +117,7 @@ export async function runProcess( |
| 86 | 117 | ): Promise<ProcessRunResult> { |
| 87 | 118 | const timeoutMs = options.timeoutMs ?? 60_000; |
| 88 | 119 | const maxBufferBytes = options.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; |
| 120 | const outputMode = options.outputMode ?? "error"; | |
| 89 | 121 | |
| 90 | 122 | return new Promise<ProcessRunResult>((resolve, reject) => { |
| 91 | 123 | const child = spawn(command, args, { |
| ... | ... | @@ -98,6 +130,8 @@ export async function runProcess( |
| 98 | 130 | let stderr = ""; |
| 99 | 131 | let stdoutBytes = 0; |
| 100 | 132 | let stderrBytes = 0; |
| 133 | let stdoutTruncated = false; | |
| 134 | let stderrTruncated = false; | |
| 101 | 135 | let timedOut = false; |
| 102 | 136 | let settled = false; |
| 103 | 137 | let forceKillTimer: ReturnType<typeof setTimeout> | null = null; |
| ... | ... | @@ -131,15 +165,41 @@ export async function runProcess( |
| 131 | 165 | stream: "stdout" | "stderr", |
| 132 | 166 | chunk: Buffer | string, |
| 133 | 167 | ): 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; | |
| 136 | 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 | 185 | stdout += text; |
| 138 | 186 | stdoutBytes += byteLength; |
| 139 | 187 | if (stdoutBytes > maxBufferBytes) { |
| 140 | 188 | return normalizeBufferError(command, args, "stdout", maxBufferBytes); |
| 141 | 189 | } |
| 142 | 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 | 203 | stderr += text; |
| 144 | 204 | stderrBytes += byteLength; |
| 145 | 205 | if (stderrBytes > maxBufferBytes) { |
| ... | ... | @@ -176,6 +236,8 @@ export async function runProcess( |
| 176 | 236 | code, |
| 177 | 237 | signal, |
| 178 | 238 | timedOut, |
| 239 | stdoutTruncated, | |
| 240 | stderrTruncated, | |
| 179 | 241 | }; |
| 180 | 242 | |
| 181 | 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 | import fs from "node:fs"; | |
| 2 | import { createRequire } from "node:module"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 1 | 5 | import * as nodePty from "node-pty"; |
| 2 | 6 | |
| 3 | 7 | export interface PtyExitEvent { |
| ... | ... | @@ -26,6 +30,57 @@ export interface PtyAdapter { |
| 26 | 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 | 84 | class NodePtyProcess implements PtyProcess { |
| 30 | 85 | constructor(private readonly process: nodePty.IPty) {} |
| 31 | 86 | |
| ... | ... | @@ -67,12 +122,16 @@ class NodePtyProcess implements PtyProcess { |
| 67 | 122 | |
| 68 | 123 | export class NodePtyAdapter implements PtyAdapter { |
| 69 | 124 | spawn(input: PtySpawnInput): PtyProcess { |
| 125 | ensureNodePtySpawnHelperExecutable(); | |
| 70 | 126 | const ptyProcess = nodePty.spawn(input.shell, [], { |
| 71 | 127 | cwd: input.cwd, |
| 72 | 128 | cols: input.cols, |
| 73 | 129 | rows: input.rows, |
| 74 | 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 | 136 | return new NodePtyProcess(ptyProcess); |
| 78 | 137 | } |
apps/server/src/terminalManager.test.ts+41-2| ... | ... | @@ -62,10 +62,15 @@ class FakePtyProcess implements PtyProcess { |
| 62 | 62 | class FakePtyAdapter implements PtyAdapter { |
| 63 | 63 | readonly spawnInputs: PtySpawnInput[] = []; |
| 64 | 64 | readonly processes: FakePtyProcess[] = []; |
| 65 | readonly spawnFailures: Error[] = []; | |
| 65 | 66 | private nextPid = 9000; |
| 66 | 67 | |
| 67 | 68 | spawn(input: PtySpawnInput): PtyProcess { |
| 68 | 69 | this.spawnInputs.push(input); |
| 70 | const failure = this.spawnFailures.shift(); | |
| 71 | if (failure) { | |
| 72 | throw failure; | |
| 73 | } | |
| 69 | 74 | const process = new FakePtyProcess(this.nextPid++); |
| 70 | 75 | this.processes.push(process); |
| 71 | 76 | return process; |
| ... | ... | @@ -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 | 129 | const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-")); |
| 122 | 130 | tempDirs.push(logsDir); |
| 123 | 131 | const ptyAdapter = new FakePtyAdapter(); |
| ... | ... | @@ -125,7 +133,7 @@ describe("TerminalManager", () => { |
| 125 | 133 | logsDir, |
| 126 | 134 | ptyAdapter, |
| 127 | 135 | historyLineLimit, |
| 128 | shellResolver: () => "/bin/bash", | |
| 136 | shellResolver: options.shellResolver ?? (() => "/bin/bash"), | |
| 129 | 137 | }); |
| 130 | 138 | return { logsDir, ptyAdapter, manager }; |
| 131 | 139 | } |
| ... | ... | @@ -266,4 +274,35 @@ describe("TerminalManager", () => { |
| 266 | 274 | |
| 267 | 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 | 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 | 122 | function capHistory(history: string, maxLines: number): string { |
| 61 | 123 | if (history.length === 0) return history; |
| 62 | 124 | const hasTrailingNewline = history.endsWith("\n"); |
| ... | ... | @@ -270,16 +332,43 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 270 | 332 | session.exitSignal = null; |
| 271 | 333 | session.updatedAt = new Date().toISOString(); |
| 272 | 334 | |
| 273 | const shell = this.shellResolver(); | |
| 274 | 335 | let ptyProcess: PtyProcess | null = null; |
| 336 | let startedShell: string | null = null; | |
| 275 | 337 | 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 | ||
| 283 | 372 | session.process = ptyProcess; |
| 284 | 373 | session.pid = ptyProcess.pid; |
| 285 | 374 | session.status = "running"; |
| ... | ... | @@ -318,6 +407,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 318 | 407 | this.logger.error("failed to start terminal", { |
| 319 | 408 | threadId: session.threadId, |
| 320 | 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 | 524 | hasUpstream: false, |
| 525 | 525 | aheadCount: 0, |
| 526 | 526 | behindCount: 0, |
| 527 | openPr: null, | |
| 527 | 528 | }), |
| 528 | 529 | runStackedAction: vi.fn(), |
| 529 | 530 | }; |
| ... | ... | @@ -547,6 +548,7 @@ describe("WebSocket Server", () => { |
| 547 | 548 | hasUpstream: false, |
| 548 | 549 | aheadCount: 0, |
| 549 | 550 | behindCount: 0, |
| 551 | openPr: null, | |
| 550 | 552 | }); |
| 551 | 553 | expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" }); |
| 552 | 554 | }); |
apps/web/src/components/ChatView.tsx+98-60| ... | ... | @@ -63,6 +63,69 @@ interface GitActionMenuItem { |
| 63 | 63 | id: GitStackedAction; |
| 64 | 64 | label: string; |
| 65 | 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 | 131 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| ... | ... | @@ -277,65 +340,34 @@ export default function ChatView() { |
| 277 | 340 | if (!gitStatus) return []; |
| 278 | 341 | |
| 279 | 342 | const hasBranch = gitStatus.branch !== null; |
| 343 | const hasOpenPr = gitStatus.openPr !== null; | |
| 280 | 344 | 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); | |
| 332 | 352 | |
| 333 | const openPrDisabled = !canPushOrPr || gitStatus.behindCount > 0; | |
| 334 | 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 | 367 | id: "commit_push_pr", |
| 337 | label: "Open PR", | |
| 338 | disabled: openPrDisabled, | |
| 368 | label: "View PR", | |
| 369 | disabled: !canViewPr, | |
| 370 | icon: "pr", | |
| 339 | 371 | }, |
| 340 | 372 | ]; |
| 341 | 373 | }, [gitBaseDisabled, gitStatus]); |
| ... | ... | @@ -882,15 +914,20 @@ export default function ChatView() { |
| 882 | 914 | <button |
| 883 | 915 | type="button" |
| 884 | 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 | 923 | disabled={!gitStatus || isGitActionRunning} |
| 887 | 924 | > |
| 888 | 925 | {isGitActionRunning ? "Running..." : "Git actions"} |
| 889 | 926 | <span aria-hidden="true">▾</span> |
| 890 | 927 | </button> |
| 891 | 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"> | |
| 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"> | |
| 894 | 931 | Git actions |
| 895 | 932 | </p> |
| 896 | 933 | {gitActionMenuItems.map((item) => { |
| ... | ... | @@ -898,13 +935,14 @@ export default function ChatView() { |
| 898 | 935 | <button |
| 899 | 936 | key={item.id} |
| 900 | 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 | 939 | disabled={item.disabled} |
| 903 | 940 | onClick={() => { |
| 904 | 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 | 946 | </button> |
| 909 | 947 | ); |
| 910 | 948 | })} |
| ... | ... | @@ -918,7 +956,7 @@ export default function ChatView() { |
| 918 | 956 | !gitStatus.hasWorkingTreeChanges && |
| 919 | 957 | gitStatus.aheadCount === 0 && |
| 920 | 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 | 960 | Branch is behind upstream. Pull/rebase before opening a PR. |
| 923 | 961 | </p> |
| 924 | 962 | )} |
packages/contracts/src/git.test.ts+1-1| ... | ... | @@ -21,6 +21,7 @@ describe("git contracts", () => { |
| 21 | 21 | hasUpstream: false, |
| 22 | 22 | aheadCount: 0, |
| 23 | 23 | behindCount: 0, |
| 24 | openPr: null, | |
| 24 | 25 | }); |
| 25 | 26 | expect(parsed.branch).toBe("feature/git-actions"); |
| 26 | 27 | expect(parsed.hasWorkingTreeChanges).toBe(true); |
| ... | ... | @@ -70,4 +71,3 @@ describe("git contracts", () => { |
| 70 | 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 | 74 | hasUpstream: z.boolean(), |
| 75 | 75 | aheadCount: z.number().int().nonnegative(), |
| 76 | 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 | 88 | export const gitStackedActionSchema = z.enum([ |
| ... | ... | @@ -127,4 +136,3 @@ export type GitStatusResult = z.infer<typeof gitStatusResultSchema>; |
| 127 | 136 | export type GitStackedAction = z.infer<typeof gitStackedActionSchema>; |
| 128 | 137 | export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>; |
| 129 | 138 | export type GitRunStackedActionResult = z.infer<typeof gitRunStackedActionResultSchema>; |
| 130 |