diff --git a/nix/package.nix b/nix/package.nix index 1ef702bf2..5f7783bcd 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,7 +42,7 @@ buildNpmPackage rec { # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # CI auto-updates this when package-lock.json changes (see .github/workflows/). - npmDepsHash = "sha256-URkLpcPEB530mm6w87YZ/ggyKIYHcHsQCfzA9f9xBZU="; + npmDepsHash = "sha256-bLM+tKHQiiu2ggZeYKFAO6OQFNGVxzXgxNQf+vNpTUQ="; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). diff --git a/package-lock.json b/package-lock.json index 26f28539a..280d4c25b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35406,6 +35406,7 @@ "node-pty": "1.2.0-beta.11", "onnxruntime-node": "^1.23.0", "openai": "^4.20.0", + "p-limit": "^7.3.0", "pi-acp": "^0.0.24", "pino": "^10.2.0", "pino-pretty": "^13.1.3", @@ -35681,6 +35682,21 @@ "node": ">= 0.6" } }, + "packages/server/node_modules/p-limit": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", + "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/server/node_modules/raw-body": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", @@ -35778,6 +35794,18 @@ "node": ">= 0.6" } }, + "packages/server/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/server/node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 63384bb64..404e4b37c 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -258,7 +258,8 @@ export function AgentList({ if (!actionAgent || !actionClient) { return; } - void actionClient.archiveAgent(actionAgent.id); + // Timeout errors are swallowed — the daemon will still process the archive + void actionClient.archiveAgent(actionAgent.id).catch(() => {}); setActionAgent(null); }, [actionAgent, actionClient]); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d17f10ada..af6897336 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1291,7 +1291,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) }); } - void archiveAgent({ serverId: normalizedServerId, agentId }); + // Errors (e.g. timeout) are handled by the mutation's onSettled callback + void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {}); }); }, [archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey], diff --git a/packages/server/package.json b/packages/server/package.json index 23a3a5d5e..e0b9b5120 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -82,6 +82,7 @@ "node-pty": "1.2.0-beta.11", "onnxruntime-node": "^1.23.0", "openai": "^4.20.0", + "p-limit": "^7.3.0", "pi-acp": "^0.0.24", "pino": "^10.2.0", "pino-pretty": "^13.1.3", diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts index b683cd38a..356f8cf15 100644 --- a/packages/server/src/server/checkout-diff-manager.test.ts +++ b/packages/server/src/server/checkout-diff-manager.test.ts @@ -1,19 +1,10 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -const { execMock, getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, watchCalls } = - vi.hoisted(() => { +const { getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, watchCalls } = vi.hoisted( + () => { const hoistedWatchCalls: Array<{ path: string; close: ReturnType }> = []; return { - execMock: vi.fn( - ( - _command: string, - _options: unknown, - callback: (error: null, result: { stdout: string; stderr: string }) => void, - ) => { - callback(null, { stdout: "/tmp/repo\n", stderr: "" }); - }, - ), getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })), resolveCheckoutGitDirMock: vi.fn(async () => "/tmp/repo/.git"), readdirMock: vi.fn(async (directory: string) => { @@ -40,10 +31,17 @@ const { execMock, getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, w }), watchCalls: hoistedWatchCalls, }; - }); + }, +); -vi.mock("child_process", () => ({ - exec: execMock, +vi.mock("../utils/run-git-command.js", () => ({ + runGitCommand: vi.fn(async () => ({ + stdout: "/tmp/repo\n", + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })), })); vi.mock("node:fs/promises", async () => { @@ -88,7 +86,6 @@ describe("CheckoutDiffManager Linux watchers", () => { beforeEach(() => { watchCalls.length = 0; - execMock.mockClear(); getCheckoutDiffMock.mockClear(); resolveCheckoutGitDirMock.mockClear(); readdirMock.mockClear(); diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 8a5246cd0..0274fff22 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -1,16 +1,13 @@ import { watch, type FSWatcher } from "node:fs"; import { readdir } from "node:fs/promises"; import { join } from "node:path"; -import { exec } from "child_process"; -import { promisify } from "util"; import type pino from "pino"; import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js"; import { getCheckoutDiff } from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; +import { runGitCommand } from "../utils/run-git-command.js"; import { READ_ONLY_GIT_ENV, resolveCheckoutGitDir, toCheckoutError } from "./checkout-git-utils.js"; -const execAsync = promisify(exec); - const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150; const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000; @@ -177,10 +174,13 @@ export class CheckoutDiffManager { private async resolveCheckoutWatchRoot(cwd: string): Promise { try { - const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", { - cwd, - env: READ_ONLY_GIT_ENV, - }); + const { stdout } = await runGitCommand( + ["rev-parse", "--path-format=absolute", "--show-toplevel"], + { + cwd, + env: READ_ONLY_GIT_ENV, + }, + ); const root = stdout.trim(); return root.length > 0 ? root : null; } catch { diff --git a/packages/server/src/server/checkout-git-utils.ts b/packages/server/src/server/checkout-git-utils.ts index a27460e1b..9c2c2e1e3 100644 --- a/packages/server/src/server/checkout-git-utils.ts +++ b/packages/server/src/server/checkout-git-utils.ts @@ -1,12 +1,9 @@ -import { exec } from "child_process"; -import { promisify } from "util"; import { MergeConflictError, MergeFromBaseConflictError, NotGitRepoError, } from "../utils/checkout-git.js"; - -const execAsync = promisify(exec); +import { runGitCommand } from "../utils/run-git-command.js"; export const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { ...process.env, @@ -22,7 +19,7 @@ export type CheckoutErrorPayload = { export async function resolveCheckoutGitDir(cwd: string): Promise { try { - const { stdout } = await execAsync("git rev-parse --absolute-git-dir", { + const { stdout } = await runGitCommand(["rev-parse", "--absolute-git-dir"], { cwd, env: READ_ONLY_GIT_ENV, }); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 07f29fc88..62508470a 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -1,8 +1,6 @@ -import { execFile } from "node:child_process"; import { watch, type FSWatcher } from "node:fs"; import { readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; -import { promisify } from "node:util"; import type pino from "pino"; import type { CheckoutContext } from "../utils/checkout-git.js"; import { @@ -13,10 +11,9 @@ import { resolveGhPath, resolveAbsoluteGitDir, } from "../utils/checkout-git.js"; +import { runGitCommand } from "../utils/run-git-command.js"; import { normalizeWorkspaceId } from "./workspace-registry-model.js"; -const execFileAsync = promisify(execFile); - const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; @@ -587,11 +584,12 @@ function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { } async function runGitFetch(cwd: string): Promise { - await execFileAsync("git", ["fetch", "origin", "--prune"], { + await runGitCommand(["fetch", "origin", "--prune"], { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", }, + timeout: 120_000, }); } diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index afeec0c29..874cbfa88 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -1,10 +1,9 @@ import { v4 as uuidv4 } from "uuid"; import type { Logger } from "pino"; -import { exec } from "node:child_process"; -import { promisify } from "node:util"; import { sep } from "node:path"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; +import { runGitCommand } from "../utils/run-git-command.js"; import { createWorktree, getWorktreeTerminalSpecs, @@ -50,7 +49,6 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: "0", }; -const execAsync = promisify(exec); const worktreeSetupEligibility = new WeakMap(); type MiddleTruncationAccumulator = { @@ -195,7 +193,7 @@ async function findExistingPaseoWorktreeBySlug(options: CreateAgentWorktreeOptio } async function resolveBranchNameForWorktreePath(worktreePath: string): Promise { - const { stdout } = await execAsync("git branch --show-current", { + const { stdout } = await runGitCommand(["branch", "--show-current"], { cwd: worktreePath, env: READ_ONLY_GIT_ENV, }); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index c1974b567..637a95f4c 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -1,5 +1,3 @@ -import { exec } from "node:child_process"; -import { promisify } from "node:util"; import type { Logger } from "pino"; import { v4 as uuidv4 } from "uuid"; @@ -36,9 +34,8 @@ import { validateBranchSlug, type WorktreeConfig, } from "../utils/worktree.js"; +import { runGitCommand } from "../utils/run-git-command.js"; import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js"; - -const execAsync = promisify(exec); const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/; export type NormalizedGitOptions = { @@ -154,7 +151,7 @@ export async function buildAgentSessionConfig( if (normalized.createNewBranch) { targetBranch = normalized.newBranchName!; } else { - const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { + const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], { cwd, env: READ_ONLY_GIT_ENV, }); diff --git a/packages/server/src/utils/checkout-git-batching.test.ts b/packages/server/src/utils/checkout-git-batching.test.ts index 7ef40a386..1d2445bc1 100644 --- a/packages/server/src/utils/checkout-git-batching.test.ts +++ b/packages/server/src/utils/checkout-git-batching.test.ts @@ -20,7 +20,9 @@ vi.mock("child_process", async () => { normalizedArgs[0] === "diff" && normalizedArgs.includes("HEAD") && !normalizedArgs.includes("--numstat") && - !normalizedArgs.includes("--no-index"); + !normalizedArgs.includes("--no-index") && + !normalizedArgs.includes("--shortstat") && + !normalizedArgs.includes("--name-status"); if (isTrackedTextDiff) { spawnCounters.trackedTextDiffCalls += 1; } diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index b862666ad..10d4a972a 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,5 +1,4 @@ -import { exec, execFile } from "child_process"; -import { spawnProcess } from "./spawn.js"; +import { execFile } from "child_process"; import { promisify } from "util"; import { resolve, dirname, basename } from "path"; import { existsSync, realpathSync } from "fs"; @@ -8,17 +7,16 @@ import { TTLCache } from "@isaacs/ttlcache"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js"; import { findExecutable } from "./executable.js"; +import { runGitCommand } from "./run-git-command.js"; import { isPaseoOwnedWorktreeCwd } from "./worktree.js"; import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js"; -const execAsync = promisify(exec); const execFileAsync = promisify(execFile); const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: "0", }; -const SMALL_OUTPUT_MAX_BUFFER = 20 * 1024 * 1024; // 20MB const DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS = 30_000; const PULL_REQUEST_STATUS_CACHE_MAX = 1_000; @@ -63,91 +61,6 @@ export function __setGhPathForTests(path: string | null): void { cachedGhPath = path; } -async function execGit( - command: string, - options: { cwd: string; env?: NodeJS.ProcessEnv }, -): Promise<{ stdout: string; stderr: string }> { - return execAsync(command, { ...options, maxBuffer: SMALL_OUTPUT_MAX_BUFFER }); -} - -type LimitedTextResult = { - text: string; - truncated: boolean; - exitCode: number | null; - signal: NodeJS.Signals | null; -}; - -async function spawnLimitedText(params: { - cmd: string; - args: string[]; - cwd: string; - env?: NodeJS.ProcessEnv; - maxBytes: number; - acceptExitCodes?: number[]; -}): Promise { - const accept = new Set(params.acceptExitCodes ?? [0]); - - return new Promise((resolvePromise, rejectPromise) => { - const child = spawnProcess(params.cmd, params.args, { - cwd: params.cwd, - env: params.env, - stdio: ["ignore", "pipe", "pipe"], - }); - - const stdoutChunks: Buffer[] = []; - let stdoutBytes = 0; - let truncated = false; - - const stop = () => { - if (child.killed) return; - try { - child.kill("SIGKILL"); - } catch { - // ignore - } - }; - - child.stdout!.on("data", (chunk: Buffer) => { - if (truncated) return; - stdoutBytes += chunk.length; - if (stdoutBytes > params.maxBytes) { - truncated = true; - stop(); - return; - } - stdoutChunks.push(chunk); - }); - - // We don't buffer stderr (it can be large too). Keep it minimal for debugging. - let stderrPreview = ""; - child.stderr!.on("data", (chunk: Buffer) => { - if (stderrPreview.length > 2048) return; - stderrPreview += chunk.toString("utf8"); - }); - - child.on("error", (error) => { - rejectPromise(error); - }); - - child.on("close", (code, signal) => { - if (code !== null && !accept.has(code) && !truncated) { - rejectPromise( - new Error( - `Command failed: ${params.cmd} ${params.args.join(" ")} (code ${code})\n${stderrPreview}`, - ), - ); - return; - } - resolvePromise({ - text: Buffer.concat(stdoutChunks).toString("utf8"), - truncated, - exitCode: code, - signal, - }); - }); - }); -} - type CheckoutFileChange = { path: string; oldPath?: string; @@ -187,8 +100,13 @@ interface GitRef { } async function listGitRefs(cwd: string, refPrefix: string): Promise { - const { stdout } = await execGit( - `git for-each-ref --sort=-committerdate --format="%(refname)%09%(committerdate:unix)" ${refPrefix}`, + const { stdout } = await runGitCommand( + [ + "for-each-ref", + "--sort=-committerdate", + "--format=%(refname)%09%(committerdate:unix)", + refPrefix, + ], { cwd, env: READ_ONLY_GIT_ENV }, ); return stdout @@ -290,17 +208,12 @@ async function listCheckoutFileChanges( ): Promise { const changes: CheckoutFileChange[] = []; - const { stdout: nameStatusOut } = await execFileAsync( - "git", + const { stdout: nameStatusOut } = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace, extra: ["--name-status", ref], }), - { - cwd, - env: READ_ONLY_GIT_ENV, - maxBuffer: SMALL_OUTPUT_MAX_BUFFER, - }, + { cwd, env: READ_ONLY_GIT_ENV }, ); for (const line of nameStatusOut .split("\n") @@ -337,10 +250,13 @@ async function listCheckoutFileChanges( }); } - const { stdout: untrackedOut } = await execGit("git ls-files --others --exclude-standard", { - cwd, - env: READ_ONLY_GIT_ENV, - }); + const { stdout: untrackedOut } = await runGitCommand( + ["ls-files", "--others", "--exclude-standard"], + { + cwd, + env: READ_ONLY_GIT_ENV, + }, + ); for (const file of untrackedOut .split("\n") .map((l) => l.trim()) @@ -375,10 +291,9 @@ async function readGitFileContentAtRef( path: string, ): Promise { try { - const { stdout } = await execFileAsync("git", ["show", `${ref}:${path}`], { + const { stdout } = await runGitCommand(["show", `${ref}:${path}`], { cwd, env: READ_ONLY_GIT_ENV, - maxBuffer: SMALL_OUTPUT_MAX_BUFFER, }); return stdout; } catch { @@ -388,7 +303,7 @@ async function readGitFileContentAtRef( async function tryResolveMergeBase(cwd: string, baseRef: string): Promise { try { - const { stdout } = await execGit(`git merge-base ${baseRef} HEAD`, { + const { stdout } = await runGitCommand(["merge-base", baseRef, "HEAD"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -428,20 +343,21 @@ async function getTrackedNumstatByPath( ref: string, ignoreWhitespace = false, ): Promise> { - const result = await spawnLimitedText({ - cmd: "git", - args: buildGitDiffArgs({ + const result = await runGitCommand( + buildGitDiffArgs({ ignoreWhitespace, extra: ["--numstat", ref], }), - cwd, - env: READ_ONLY_GIT_ENV, - maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES, - acceptExitCodes: [0], - }); + { + cwd, + env: READ_ONLY_GIT_ENV, + maxOutputBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES, + acceptExitCodes: [0], + }, + ); const stats = new Map(); - const lines = result.text + const lines = result.stdout .split("\n") .map((line) => line.trim()) .filter(Boolean); @@ -637,14 +553,14 @@ function isGitError(error: unknown): boolean { async function requireGitRepo(cwd: string): Promise { try { - await execAsync("git rev-parse --git-dir", { cwd, env: READ_ONLY_GIT_ENV }); + await runGitCommand(["rev-parse", "--git-dir"], { cwd, env: READ_ONLY_GIT_ENV }); } catch (error) { throw new NotGitRepoError(cwd); } } export async function getCurrentBranch(cwd: string): Promise { - const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { + const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -654,10 +570,13 @@ export async function getCurrentBranch(cwd: string): Promise { async function getWorktreeRoot(cwd: string): Promise { try { - const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", { - cwd, - env: READ_ONLY_GIT_ENV, - }); + const { stdout } = await runGitCommand( + ["rev-parse", "--path-format=absolute", "--show-toplevel"], + { + cwd, + env: READ_ONLY_GIT_ENV, + }, + ); const root = stdout.trim(); return root.length > 0 ? root : null; } catch { @@ -666,8 +585,8 @@ async function getWorktreeRoot(cwd: string): Promise { } export async function getMainRepoRoot(cwd: string): Promise { - const { stdout: commonDirOut } = await execAsync( - "git rev-parse --path-format=absolute --git-common-dir", + const { stdout: commonDirOut } = await runGitCommand( + ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd, env: READ_ONLY_GIT_ENV }, ); const commonDir = commonDirOut.trim(); @@ -677,7 +596,7 @@ export async function getMainRepoRoot(cwd: string): Promise { return dirname(normalized); } - const { stdout: worktreeOut } = await execAsync("git worktree list --porcelain", { + const { stdout: worktreeOut } = await runGitCommand(["worktree", "list", "--porcelain"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -743,7 +662,7 @@ export function parseWorktreeList(output: string): GitWorktreeEntry[] { async function getWorktreePathForBranch(cwd: string, branchName: string): Promise { try { - const { stdout } = await execAsync("git worktree list --porcelain", { + const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -766,8 +685,9 @@ export async function renameCurrentBranch( throw new Error("Cannot rename branch in detached HEAD state"); } - await execAsync(`git branch -m "${newName}"`, { + await runGitCommand(["branch", "-m", newName], { cwd, + timeout: 120_000, }); const currentBranch = await getCurrentBranch(cwd); @@ -800,7 +720,7 @@ async function getConfiguredBaseRefForCwd( } async function isWorkingTreeDirty(cwd: string): Promise { - const { stdout } = await execAsync("git status --porcelain", { + const { stdout } = await runGitCommand(["status", "--porcelain"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -809,7 +729,7 @@ async function isWorkingTreeDirty(cwd: string): Promise { export async function getOriginRemoteUrl(cwd: string): Promise { try { - const { stdout } = await execAsync("git config --get remote.origin.url", { + const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -827,7 +747,7 @@ export async function hasOriginRemote(cwd: string): Promise { export async function resolveAbsoluteGitDir(cwd: string): Promise { try { - const { stdout } = await execAsync("git rev-parse --absolute-git-dir", { + const { stdout } = await runGitCommand(["rev-parse", "--absolute-git-dir"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -850,7 +770,7 @@ async function abortGitPullConflictState(cwd: string): Promise { if (existsSync(mergeHeadPath)) { try { - await execAsync("git merge --abort", { cwd }); + await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 }); } catch { // ignore } @@ -858,7 +778,7 @@ async function abortGitPullConflictState(cwd: string): Promise { if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) { try { - await execAsync("git rebase --abort", { cwd }); + await runGitCommand(["rebase", "--abort"], { cwd, timeout: 120_000 }); } catch { // ignore } @@ -867,10 +787,13 @@ async function abortGitPullConflictState(cwd: string): Promise { export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise { try { - const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", { - cwd: repoRoot, - env: READ_ONLY_GIT_ENV, - }); + const { stdout } = await runGitCommand( + ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], + { + cwd: repoRoot, + env: READ_ONLY_GIT_ENV, + }, + ); const ref = stdout.trim(); if (ref) { // Prefer a local branch name (e.g. "main") over the remote-tracking ref (e.g. "origin/main") @@ -880,7 +803,7 @@ export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise< ? remoteShort.slice("origin/".length) : remoteShort; try { - await execAsync(`git show-ref --verify --quiet refs/heads/${localName}`, { + await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${localName}`], { cwd: repoRoot, env: READ_ONLY_GIT_ENV, }); @@ -893,7 +816,7 @@ export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise< // ignore } - const { stdout } = await execAsync("git branch --format='%(refname:short)'", { + const { stdout } = await runGitCommand(["branch", "--format=%(refname:short)"], { cwd: repoRoot, env: READ_ONLY_GIT_ENV, }); @@ -922,7 +845,7 @@ function normalizeLocalBranchRefName(input: string): string { async function doesGitRefExist(cwd: string, fullRef: string): Promise { try { - await execAsync(`git show-ref --verify --quiet ${fullRef}`, { + await runGitCommand(["show-ref", "--verify", "--quiet", fullRef], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -953,8 +876,8 @@ async function resolveBestComparisonBaseRef( // Both exist: choose the ref with more unique commits compared to the other. try { - const { stdout } = await execAsync( - `git rev-list --left-right --count ${normalizedBaseRef}...origin/${normalizedBaseRef}`, + const { stdout } = await runGitCommand( + ["rev-list", "--left-right", "--count", `${normalizedBaseRef}...origin/${normalizedBaseRef}`], { cwd, env: READ_ONLY_GIT_ENV }, ); const [localOnlyRaw, originOnlyRaw] = stdout.trim().split(/\s+/); @@ -980,8 +903,8 @@ async function getAheadBehind( return null; } const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, normalizedBaseRef); - const { stdout } = await execAsync( - `git rev-list --left-right --count ${comparisonBaseRef}...${currentBranch}`, + const { stdout } = await runGitCommand( + ["rev-list", "--left-right", "--count", `${comparisonBaseRef}...${currentBranch}`], { cwd, env: READ_ONLY_GIT_ENV }, ); const [behindRaw, aheadRaw] = stdout.trim().split(/\s+/); @@ -998,15 +921,15 @@ async function getAheadOfOrigin(cwd: string, currentBranch: string): Promise 0) { - const trackedDiffResult = await spawnLimitedText({ - cmd: "git", - args: buildGitDiffArgs({ + const trackedDiffResult = await runGitCommand( + buildGitDiffArgs({ ignoreWhitespace, extra: [refForDiff, "--", ...trackedDiffPaths], }), - cwd, - env: READ_ONLY_GIT_ENV, - maxBytes: TOTAL_DIFF_MAX_BYTES, - }); - trackedDiffText = trackedDiffResult.text; + { + cwd, + env: READ_ONLY_GIT_ENV, + maxOutputBytes: TOTAL_DIFF_MAX_BYTES, + }, + ); + trackedDiffText = trackedDiffResult.stdout; trackedDiffTruncated = trackedDiffResult.truncated; appendDiff(trackedDiffText); if (trackedDiffTruncated) { @@ -1598,10 +1523,11 @@ export async function commitChanges( ): Promise { await requireGitRepo(cwd); if (options.addAll ?? true) { - await execFileAsync("git", ["add", "-A"], { cwd }); + await runGitCommand(["add", "-A"], { cwd, timeout: 120_000 }); } - await execFileAsync("git", ["-c", "commit.gpgsign=false", "commit", "-m", options.message], { + await runGitCommand(["-c", "commit.gpgsign=false", "commit", "-m", options.message], { cwd, + timeout: 120_000, }); } @@ -1640,16 +1566,23 @@ export async function mergeToBase( const originalBranch = await getCurrentBranch(operationCwd); const mode = options.mode ?? "merge"; try { - await execAsync(`git checkout ${normalizedBaseRef}`, { cwd: operationCwd }); + await runGitCommand(["checkout", normalizedBaseRef], { + cwd: operationCwd, + timeout: 120_000, + }); if (mode === "squash") { - await execAsync(`git merge --squash ${currentBranch}`, { cwd: operationCwd }); + await runGitCommand(["merge", "--squash", currentBranch], { + cwd: operationCwd, + timeout: 120_000, + }); const message = options.commitMessage ?? `Squash merge ${currentBranch} into ${normalizedBaseRef}`; - await execFileAsync("git", ["-c", "commit.gpgsign=false", "commit", "-m", message], { + await runGitCommand(["-c", "commit.gpgsign=false", "commit", "-m", message], { cwd: operationCwd, + timeout: 120_000, }); } else { - await execAsync(`git merge ${currentBranch}`, { cwd: operationCwd }); + await runGitCommand(["merge", currentBranch], { cwd: operationCwd, timeout: 120_000 }); } } catch (error) { const errorDetails = @@ -1658,9 +1591,9 @@ export async function mergeToBase( : String(error); try { const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([ - execAsync("git diff --name-only --diff-filter=U", { cwd: operationCwd }), - execAsync("git ls-files -u", { cwd: operationCwd }), - execAsync("git status --porcelain", { cwd: operationCwd }), + runGitCommand(["diff", "--name-only", "--diff-filter=U"], { cwd: operationCwd }), + runGitCommand(["ls-files", "-u"], { cwd: operationCwd }), + runGitCommand(["status", "--porcelain"], { cwd: operationCwd }), ]); const statusConflicts = statusOutput.stdout .split("\n") @@ -1684,7 +1617,7 @@ export async function mergeToBase( conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails); if (conflictDetected) { try { - await execAsync("git merge --abort", { cwd: operationCwd }); + await runGitCommand(["merge", "--abort"], { cwd: operationCwd, timeout: 120_000 }); } catch { // ignore } @@ -1705,7 +1638,10 @@ export async function mergeToBase( } finally { if (isSameCheckout && originalBranch && originalBranch !== normalizedBaseRef) { try { - await execAsync(`git checkout ${originalBranch}`, { cwd: operationCwd }); + await runGitCommand(["checkout", originalBranch], { + cwd: operationCwd, + timeout: 120_000, + }); } catch { // ignore } @@ -1735,7 +1671,7 @@ export async function mergeFromBase( const requireCleanTarget = options.requireCleanTarget ?? true; if (requireCleanTarget) { - const { stdout } = await execAsync("git status --porcelain", { + const { stdout } = await runGitCommand(["status", "--porcelain"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -1751,7 +1687,7 @@ export async function mergeFromBase( } try { - await execAsync(`git merge ${bestBaseRef}`, { cwd }); + await runGitCommand(["merge", bestBaseRef], { cwd, timeout: 120_000 }); } catch (error) { const errorDetails = error instanceof Error @@ -1759,9 +1695,9 @@ export async function mergeFromBase( : String(error); try { const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([ - execAsync("git diff --name-only --diff-filter=U", { cwd }), - execAsync("git ls-files -u", { cwd }), - execAsync("git status --porcelain", { cwd }), + runGitCommand(["diff", "--name-only", "--diff-filter=U"], { cwd }), + runGitCommand(["ls-files", "-u"], { cwd }), + runGitCommand(["status", "--porcelain"], { cwd }), ]); const statusConflicts = statusOutput.stdout .split("\n") @@ -1785,7 +1721,7 @@ export async function mergeFromBase( conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails); if (conflictDetected) { try { - await execAsync("git merge --abort", { cwd }); + await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 }); } catch { // ignore } @@ -1817,7 +1753,7 @@ export async function pullCurrentBranch(cwd: string): Promise { throw new Error("Remote 'origin' is not configured."); } try { - await execAsync("git pull", { cwd }); + await runGitCommand(["pull"], { cwd, timeout: 120_000 }); } catch (error) { await abortGitPullConflictState(cwd); throw error; @@ -1834,7 +1770,7 @@ export async function pushCurrentBranch(cwd: string): Promise { if (!hasRemote) { throw new Error("Remote 'origin' is not configured."); } - await execAsync(`git push -u origin ${currentBranch}`, { cwd }); + await runGitCommand(["push", "-u", "origin", currentBranch], { cwd, timeout: 120_000 }); } export interface CreatePullRequestOptions { @@ -1892,7 +1828,7 @@ function isGhAuthError(error: unknown): boolean { async function resolveGitHubRepo(cwd: string): Promise { try { - const { stdout } = await execAsync("git config --get remote.origin.url", { + const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -1954,7 +1890,7 @@ export async function createPullRequest( throw new Error(`Base ref mismatch: expected ${base}, got ${options.base}`); } - await execAsync(`git push -u origin ${head}`, { cwd }); + await runGitCommand(["push", "-u", "origin", head], { cwd, timeout: 120_000 }); const ghEnv: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0" }; const args = ["api", "-X", "POST", `repos/${repo}/pulls`, "-f", `title=${options.title}`]; diff --git a/packages/server/src/utils/run-git-command.test.ts b/packages/server/src/utils/run-git-command.test.ts new file mode 100644 index 000000000..022e75dfb --- /dev/null +++ b/packages/server/src/utils/run-git-command.test.ts @@ -0,0 +1,402 @@ +import { EventEmitter } from "node:events"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +interface FakeSpawnBehavior { + delayMs?: number; + emitError?: Error; + exitCode?: number | null; + stderrData?: Buffer | string; + stdoutData?: Buffer | string; +} + +interface FakeSpawnController { + activeCount: number; + nextPid: number; + peakActiveCount: number; + processes: FakeChildProcess[]; + queue: FakeSpawnBehavior[]; + reset: () => void; +} + +const fakeSpawnController = vi.hoisted(() => ({ + activeCount: 0, + nextPid: 1000, + peakActiveCount: 0, + processes: [], + queue: [], + reset() { + for (const process of this.processes) { + process.dispose(); + } + + this.activeCount = 0; + this.nextPid = 1000; + this.peakActiveCount = 0; + this.processes = []; + this.queue = []; + }, +})); + +class FakeChildProcess extends EventEmitter { + public readonly pid: number; + public readonly stderr = new EventEmitter(); + public readonly stdout = new EventEmitter(); + public killed = false; + public killSignals: NodeJS.Signals[] = []; + + private readonly behavior: FakeSpawnBehavior; + private readonly timers: NodeJS.Timeout[] = []; + private closed = false; + + public constructor(behavior: FakeSpawnBehavior) { + super(); + this.behavior = behavior; + this.pid = fakeSpawnController.nextPid; + fakeSpawnController.nextPid += 1; + + fakeSpawnController.activeCount += 1; + fakeSpawnController.peakActiveCount = Math.max( + fakeSpawnController.peakActiveCount, + fakeSpawnController.activeCount, + ); + + this.scheduleLifecycle(); + } + + public kill(signal: NodeJS.Signals = "SIGTERM"): boolean { + if (this.closed) return false; + + this.killed = true; + this.killSignals.push(signal); + this.clearTimers(); + this.schedule(() => { + this.finishClose({ + exitCode: null, + signal, + }); + }, 0); + return true; + } + + public dispose(): void { + this.clearTimers(); + this.closed = true; + } + + private scheduleLifecycle(): void { + const stdoutData = this.behavior.stdoutData; + if (stdoutData !== undefined) { + this.schedule(() => { + if (this.closed) return; + this.stdout.emit("data", stdoutData); + }, 0); + } + + const stderrData = this.behavior.stderrData; + if (stderrData !== undefined) { + this.schedule(() => { + if (this.closed) return; + this.stderr.emit("data", stderrData); + }, 0); + } + + if (this.behavior.emitError) { + this.schedule(() => { + if (this.closed) return; + this.finishError(this.behavior.emitError); + }, this.behavior.delayMs ?? 0); + return; + } + + this.schedule(() => { + this.finishClose({ + exitCode: this.behavior.exitCode ?? 0, + signal: null, + }); + }, this.behavior.delayMs ?? 0); + } + + private finishClose({ + exitCode, + signal, + }: { + exitCode: number | null; + signal: NodeJS.Signals | null; + }): void { + if (this.closed) return; + + this.closed = true; + this.clearTimers(); + fakeSpawnController.activeCount -= 1; + this.emit("close", exitCode, signal); + } + + private finishError(error: Error): void { + if (this.closed) return; + + this.closed = true; + this.clearTimers(); + fakeSpawnController.activeCount -= 1; + this.emit("error", error); + } + + private schedule(callback: () => void, delayMs: number): void { + const timer = setTimeout(callback, delayMs); + this.timers.push(timer); + } + + private clearTimers(): void { + for (const timer of this.timers) { + clearTimeout(timer); + } + this.timers.length = 0; + } +} + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + + return { + ...actual, + spawn: vi.fn(() => { + const behavior = fakeSpawnController.queue.shift() ?? {}; + const child = new FakeChildProcess(behavior); + fakeSpawnController.processes.push(child); + return child as unknown as ReturnType; + }), + }; +}); + +function enqueueSpawnBehaviors(...behaviors: FakeSpawnBehavior[]): void { + fakeSpawnController.queue.push(...behaviors); +} + +async function loadRunGitCommand(concurrency: number) { + vi.resetModules(); + vi.stubEnv("PASEO_GIT_CONCURRENCY", String(concurrency)); + return import("./run-git-command.js"); +} + +describe("runGitCommand", () => { + beforeEach(() => { + fakeSpawnController.reset(); + }); + + afterEach(() => { + fakeSpawnController.reset(); + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it("throttles concurrent git commands to the configured limit", async () => { + const { runGitCommand } = await loadRunGitCommand(2); + + enqueueSpawnBehaviors(...Array.from({ length: 16 }, () => ({ delayMs: 25 }))); + + await Promise.all( + Array.from({ length: 16 }, () => + runGitCommand(["rev-parse", "--show-toplevel"], { + cwd: process.cwd(), + }), + ), + ); + + expect(fakeSpawnController.peakActiveCount).toBe(2); + expect(fakeSpawnController.activeCount).toBe(0); + }); + + it("kills timed out processes and releases the limiter slot", async () => { + const { runGitCommand } = await loadRunGitCommand(1); + + enqueueSpawnBehaviors({ delayMs: 5_000 }, { delayMs: 0 }); + + await expect( + runGitCommand(["status"], { + cwd: process.cwd(), + timeout: 100, + }), + ).rejects.toThrow("Git command timed out after 100ms: git status"); + + expect(fakeSpawnController.processes[0]?.killed).toBe(true); + expect(fakeSpawnController.processes[0]?.killSignals).toEqual(["SIGKILL"]); + + await expect( + runGitCommand(["rev-parse", "--show-toplevel"], { + cwd: process.cwd(), + }), + ).resolves.toMatchObject({ + exitCode: 0, + truncated: false, + }); + }); + + it("resolves truncated stdout, caps output, and kills the child process", async () => { + const { runGitCommand } = await loadRunGitCommand(1); + + enqueueSpawnBehaviors({ + delayMs: 5_000, + stdoutData: "x".repeat(1_000), + }); + + const result = await runGitCommand(["log", "--all", "--oneline"], { + cwd: process.cwd(), + maxOutputBytes: 100, + }); + + expect(result.truncated).toBe(true); + expect(result.stdout.length).toBeLessThanOrEqual(100); + expect(result.stderr).toBe(""); + expect(fakeSpawnController.processes[0]?.killed).toBe(true); + expect(fakeSpawnController.processes[0]?.killSignals).toEqual(["SIGKILL"]); + }); + + it("rejects process errors and frees the limiter for the next command", async () => { + const { runGitCommand } = await loadRunGitCommand(1); + + enqueueSpawnBehaviors( + { emitError: new Error("spawn exploded") }, + { delayMs: 0, stdoutData: "ok" }, + ); + + await expect( + runGitCommand(["status"], { + cwd: process.cwd(), + }), + ).rejects.toThrow("spawn exploded"); + + await expect( + runGitCommand(["status"], { + cwd: process.cwd(), + }), + ).resolves.toMatchObject({ + exitCode: 0, + stdout: "ok", + truncated: false, + }); + }); + + it("rejects non-zero exit codes that are not accepted and frees the slot", async () => { + const { runGitCommand } = await loadRunGitCommand(1); + + enqueueSpawnBehaviors( + { + delayMs: 0, + exitCode: 1, + stderrData: "fatal: nope\n", + }, + { delayMs: 0, stdoutData: "ok" }, + ); + + await expect( + runGitCommand(["status"], { + cwd: process.cwd(), + }), + ).rejects.toThrow(/Git command failed: git status \(exit code: 1, signal: none\)/); + + await expect( + runGitCommand(["status"], { + cwd: process.cwd(), + }), + ).resolves.toMatchObject({ + exitCode: 0, + stdout: "ok", + }); + }); + + it("resolves accepted non-zero exit codes", async () => { + const { runGitCommand } = await loadRunGitCommand(1); + + enqueueSpawnBehaviors({ + delayMs: 0, + exitCode: 1, + stderrData: "fatal: but allowed\n", + }); + + await expect( + runGitCommand(["status"], { + acceptExitCodes: [0, 1], + cwd: process.cwd(), + }), + ).resolves.toMatchObject({ + exitCode: 1, + signal: null, + truncated: false, + }); + }); + + it("releases concurrency slots after timeouts so later commands can run", async () => { + const { runGitCommand } = await loadRunGitCommand(2); + + enqueueSpawnBehaviors({ delayMs: 5_000 }, { delayMs: 5_000 }); + + const firstBatch = await Promise.allSettled([ + runGitCommand(["status"], { cwd: process.cwd(), timeout: 100 }), + runGitCommand(["rev-parse", "--show-toplevel"], { + cwd: process.cwd(), + timeout: 100, + }), + ]); + + expect(firstBatch[0].status).toBe("rejected"); + expect(firstBatch[1].status).toBe("rejected"); + expect(fakeSpawnController.processes[0]?.killSignals).toEqual(["SIGKILL"]); + expect(fakeSpawnController.processes[1]?.killSignals).toEqual(["SIGKILL"]); + + enqueueSpawnBehaviors( + { delayMs: 0, stdoutData: "third" }, + { delayMs: 0, stdoutData: "fourth" }, + ); + + await expect( + Promise.all([ + runGitCommand(["status"], { cwd: process.cwd() }), + runGitCommand(["status"], { cwd: process.cwd() }), + ]), + ).resolves.toEqual([ + expect.objectContaining({ exitCode: 0, stdout: "third" }), + expect.objectContaining({ exitCode: 0, stdout: "fourth" }), + ]); + }); + + it("releases concurrency slots after truncation so later commands can run", async () => { + const { runGitCommand } = await loadRunGitCommand(2); + + enqueueSpawnBehaviors( + { delayMs: 5_000, stdoutData: "a".repeat(1_000) }, + { delayMs: 5_000, stdoutData: "b".repeat(1_000) }, + ); + + const firstBatch = await Promise.all([ + runGitCommand(["log", "--all", "--oneline"], { + cwd: process.cwd(), + maxOutputBytes: 100, + }), + runGitCommand(["log", "--all", "--oneline"], { + cwd: process.cwd(), + maxOutputBytes: 100, + }), + ]); + + expect(firstBatch).toEqual([ + expect.objectContaining({ truncated: true }), + expect.objectContaining({ truncated: true }), + ]); + expect(fakeSpawnController.processes[0]?.killSignals).toEqual(["SIGKILL"]); + expect(fakeSpawnController.processes[1]?.killSignals).toEqual(["SIGKILL"]); + + enqueueSpawnBehaviors( + { delayMs: 0, stdoutData: "third" }, + { delayMs: 0, stdoutData: "fourth" }, + ); + + await expect( + Promise.all([ + runGitCommand(["status"], { cwd: process.cwd() }), + runGitCommand(["status"], { cwd: process.cwd() }), + ]), + ).resolves.toEqual([ + expect.objectContaining({ exitCode: 0, stdout: "third", truncated: false }), + expect.objectContaining({ exitCode: 0, stdout: "fourth", truncated: false }), + ]); + }); +}); diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts new file mode 100644 index 000000000..c0f48db7b --- /dev/null +++ b/packages/server/src/utils/run-git-command.ts @@ -0,0 +1,140 @@ +import pLimit from "p-limit"; +import { spawnProcess } from "./spawn.js"; + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_OUTPUT_BYTES = 20 * 1024 * 1024; // 20MB +const DEFAULT_STDERR_LIMIT = 2048; + +const gitConcurrency = parseInt(process.env.PASEO_GIT_CONCURRENCY ?? "8", 10) || 8; +const gitLimit = pLimit(gitConcurrency); + +export interface GitCommandOptions { + cwd: string; + env?: NodeJS.ProcessEnv; + timeout?: number; + maxOutputBytes?: number; + acceptExitCodes?: number[]; +} + +export interface GitCommandResult { + stdout: string; + stderr: string; + truncated: boolean; + exitCode: number | null; + signal: NodeJS.Signals | null; +} + +export function runGitCommand( + args: string[], + options: GitCommandOptions, +): Promise { + return gitLimit( + () => + new Promise((resolve, reject) => { + const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS; + const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + const acceptExitCodes = options.acceptExitCodes ?? [0]; + const command = formatGitCommand(args); + + const child = spawnProcess("git", args, { + cwd: options.cwd, + env: options.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + let settled = false; + let truncated = false; + let stdoutBytes = 0; + let stderrBytes = 0; + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + callback(); + }; + + const timer = setTimeout(() => { + const error = new Error(`Git command timed out after ${timeout}ms: ${command}`); + child.kill("SIGKILL"); + settle(() => reject(error)); + }, timeout); + + child.stdout!.on("data", (chunk: Buffer | string) => { + if (settled || truncated) return; + + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const remainingBytes = maxOutputBytes - stdoutBytes; + + if (remainingBytes <= 0) { + truncated = true; + child.kill("SIGKILL"); + return; + } + + if (buffer.length > remainingBytes) { + stdoutChunks.push(buffer.subarray(0, remainingBytes)); + stdoutBytes += remainingBytes; + truncated = true; + child.kill("SIGKILL"); + return; + } + + stdoutChunks.push(buffer); + stdoutBytes += buffer.length; + }); + + child.stderr!.on("data", (chunk: Buffer | string) => { + if (settled || stderrBytes >= DEFAULT_STDERR_LIMIT) return; + + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const remainingBytes = DEFAULT_STDERR_LIMIT - stderrBytes; + + if (buffer.length > remainingBytes) { + stderrChunks.push(buffer.subarray(0, remainingBytes)); + stderrBytes += remainingBytes; + return; + } + + stderrChunks.push(buffer); + stderrBytes += buffer.length; + }); + + child.on("error", (error) => { + settle(() => reject(error)); + }); + + child.on("close", (exitCode, signal) => { + const result: GitCommandResult = { + stdout: Buffer.concat(stdoutChunks).toString("utf8"), + stderr: Buffer.concat(stderrChunks).toString("utf8"), + truncated, + exitCode, + signal, + }; + + if (!truncated && !acceptExitCodes.includes(exitCode ?? -1)) { + const stderrPreview = result.stderr.trim() || "(no stderr)"; + const truncationNote = result.truncated ? " (stdout truncated)" : ""; + + settle(() => + reject( + new Error( + `Git command failed: ${command}${truncationNote} (exit code: ${String(exitCode)}, signal: ${signal ?? "none"})\n${stderrPreview}`, + ), + ), + ); + return; + } + + settle(() => resolve(result)); + }); + }), + ); +} + +function formatGitCommand(args: string[]): string { + return ["git", ...args].join(" "); +} diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 35ce3671a..38d74268c 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -12,6 +12,7 @@ import { writePaseoWorktreeMetadata, writePaseoWorktreeRuntimeMetadata, } from "./worktree-metadata.js"; +import { runGitCommand } from "./run-git-command.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; interface PaseoConfig { @@ -377,10 +378,13 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise< } catch { // Fallback: best-effort resolve toplevel (will be the worktree root in typical cases) try { - const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", { - cwd: worktreePath, - env: READ_ONLY_GIT_ENV, - }); + const { stdout } = await runGitCommand( + ["rev-parse", "--path-format=absolute", "--show-toplevel"], + { + cwd: worktreePath, + env: READ_ONLY_GIT_ENV, + }, + ); const topLevel = stdout.trim(); if (topLevel) { return normalizePathForOwnership(topLevel); @@ -438,8 +442,9 @@ export async function runWorktreeSetupCommands(options: { if (result.exitCode !== 0) { if (options.cleanupOnFailure) { try { - await execAsync(`git worktree remove "${options.worktreePath}" --force`, { + await runGitCommand(["worktree", "remove", options.worktreePath, "--force"], { cwd: options.worktreePath, + timeout: 120_000, }); } catch { rmSync(options.worktreePath, { recursive: true, force: true }); @@ -457,7 +462,7 @@ export async function runWorktreeSetupCommands(options: { async function resolveBranchNameForWorktreePath(worktreePath: string): Promise { try { - const { stdout } = await execAsync("git branch --show-current", { + const { stdout } = await runGitCommand(["branch", "--show-current"], { cwd: worktreePath, env: READ_ONLY_GIT_ENV, }); @@ -560,10 +565,13 @@ export async function runWorktreeTeardownCommands(options: { * This is where refs, objects, etc. are stored. */ export async function getGitCommonDir(cwd: string): Promise { - const { stdout } = await execAsync("git rev-parse --path-format=absolute --git-common-dir", { - cwd, - env: READ_ONLY_GIT_ENV, - }); + const { stdout } = await runGitCommand( + ["rev-parse", "--path-format=absolute", "--git-common-dir"], + { + cwd, + env: READ_ONLY_GIT_ENV, + }, + ); const commonDir = stdout.trim(); if (!commonDir) { throw new Error("Not in a git repository"); @@ -793,7 +801,7 @@ export async function listPaseoWorktrees({ paseoHome?: string; }): Promise { const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome); - const { stdout } = await execAsync("git worktree list --porcelain", { + const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -824,10 +832,13 @@ export async function resolvePaseoWorktreeRootForCwd( let worktreeRoot: string | null = null; try { - const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", { - cwd, - env: READ_ONLY_GIT_ENV, - }); + const { stdout } = await runGitCommand( + ["rev-parse", "--path-format=absolute", "--show-toplevel"], + { + cwd, + env: READ_ONLY_GIT_ENV, + }, + ); const trimmed = stdout.trim(); worktreeRoot = trimmed.length > 0 ? trimmed : null; } catch { @@ -890,8 +901,9 @@ export async function deletePaseoWorktree({ worktreePath: resolvedWorktree, }); - await execAsync(`git worktree remove "${resolvedWorktree}" --force`, { + await runGitCommand(["worktree", "remove", resolvedWorktree, "--force"], { cwd, + timeout: 120_000, }); if (existsSync(resolvedWorktree)) { @@ -927,11 +939,11 @@ export async function createWorktree({ // Resolve the base branch - prefer origin/{branch}, then fall back to local let resolvedBaseBranch = normalizedBaseBranch; try { - await execAsync(`git rev-parse --verify origin/${normalizedBaseBranch}`, { cwd }); + await runGitCommand(["rev-parse", "--verify", `origin/${normalizedBaseBranch}`], { cwd }); resolvedBaseBranch = `origin/${normalizedBaseBranch}`; } catch { try { - await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd }); + await runGitCommand(["rev-parse", "--verify", normalizedBaseBranch], { cwd }); } catch { throw new Error(`Base branch not found: ${normalizedBaseBranch}`); } @@ -946,7 +958,9 @@ export async function createWorktree({ // Check if branch already exists let branchExists = false; try { - await execAsync(`git show-ref --verify --quiet refs/heads/${branchName}`, { cwd }); + await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { + cwd, + }); branchExists = true; } catch { branchExists = false; @@ -963,7 +977,9 @@ export async function createWorktree({ let suffix = 1; while (true) { try { - await execAsync(`git show-ref --verify --quiet refs/heads/${newBranchName}`, { cwd }); + await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${newBranchName}`], { + cwd, + }); // Branch exists, try with suffix newBranchName = `${candidateBranch}-${suffix}`; suffix++; @@ -980,8 +996,10 @@ export async function createWorktree({ pathSuffix++; } - const command = `git worktree add "${finalWorktreePath}" -b "${newBranchName}" "${base}"`; - await execAsync(command, { cwd }); + await runGitCommand(["worktree", "add", finalWorktreePath, "-b", newBranchName, base], { + cwd, + timeout: 120_000, + }); worktreePath = normalizePathForOwnership(finalWorktreePath); writePaseoWorktreeMetadata(worktreePath, { baseRefName: normalizedBaseBranch });