This commit is contained in:
Dax Raad
2026-02-25 01:38:58 -05:00
parent fc6e7934bd
commit 3c6c74457d
4 changed files with 294 additions and 72 deletions

View File

@@ -1,63 +1,35 @@
import { $ } from "bun"
import { buffer } from "node:stream/consumers"
import { Flag } from "../flag/flag"
import { Process } from "./process"
export interface GitResult {
exitCode: number
text(): string | Promise<string>
stdout: Buffer | ReadableStream<Uint8Array>
stderr: Buffer | ReadableStream<Uint8Array>
text(): string
stdout: Buffer
stderr: Buffer
}
/**
* Run a git command.
*
* Uses Bun's lightweight `$` shell by default. When the process is running
* as an ACP client, child processes inherit the parent's stdin pipe which
* carries protocol data on Windows this causes git to deadlock. In that
* case we fall back to `Process.spawn` with `stdin: "ignore"`.
* Uses Process helpers with stdin ignored to avoid protocol pipe inheritance
* issues in embedded/client environments.
*/
export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
if (Flag.OPENCODE_CLIENT === "acp") {
try {
const proc = Process.spawn(["git", ...args], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
cwd: opts.cwd,
env: opts.env ? { ...process.env, ...opts.env } : process.env,
})
// Read output concurrently with exit to avoid pipe buffer deadlock
if (!proc.stdout || !proc.stderr) {
throw new Error("Process output not available")
}
const [exitCode, out, err] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
return {
exitCode,
text: () => out.toString(),
stdout: out,
stderr: err,
}
} catch (error) {
const stderr = Buffer.from(error instanceof Error ? error.message : String(error))
return {
exitCode: 1,
text: () => "",
stdout: Buffer.alloc(0),
stderr,
}
}
}
const env = opts.env ? { ...process.env, ...opts.env } : undefined
let cmd = $`git ${args}`.quiet().nothrow().cwd(opts.cwd)
if (env) cmd = cmd.env(env)
const result = await cmd
return {
exitCode: result.exitCode,
text: () => result.text(),
stdout: result.stdout,
stderr: result.stderr,
}
return Process.run(["git", ...args], {
cwd: opts.cwd,
env: opts.env,
stdin: "ignore",
nothrow: true,
})
.then((result) => ({
exitCode: result.code,
text: () => result.stdout.toString(),
stdout: result.stdout,
stderr: result.stderr,
}))
.catch((error) => ({
exitCode: 1,
text: () => "",
stdout: Buffer.alloc(0),
stderr: Buffer.from(error instanceof Error ? error.message : String(error)),
}))
}

View File

@@ -1,4 +1,5 @@
import { spawn as launch, type ChildProcess } from "child_process"
import { buffer } from "node:stream/consumers"
export namespace Process {
export type Stdio = "inherit" | "pipe" | "ignore"
@@ -14,58 +15,112 @@ export namespace Process {
timeout?: number
}
export interface RunOptions extends Omit<Options, "stdout" | "stderr"> {
nothrow?: boolean
}
export interface Result {
code: number
stdout: Buffer
stderr: Buffer
}
export class RunFailedError extends Error {
readonly cmd: string[]
readonly code: number
readonly stdout: Buffer
readonly stderr: Buffer
constructor(cmd: string[], code: number, stdout: Buffer, stderr: Buffer) {
const text = stderr.toString().trim()
super(
text
? `Command failed with code ${code}: ${cmd.join(" ")}\n${text}`
: `Command failed with code ${code}: ${cmd.join(" ")}`,
)
this.name = "ProcessRunFailedError"
this.cmd = [...cmd]
this.code = code
this.stdout = stdout
this.stderr = stderr
}
}
export type Child = ChildProcess & { exited: Promise<number> }
export function spawn(cmd: string[], options: Options = {}): Child {
export function spawn(cmd: string[], opts: Options = {}): Child {
if (cmd.length === 0) throw new Error("Command is required")
options.abort?.throwIfAborted()
opts.abort?.throwIfAborted()
const proc = launch(cmd[0], cmd.slice(1), {
cwd: options.cwd,
env: options.env === null ? {} : options.env ? { ...process.env, ...options.env } : undefined,
stdio: [options.stdin ?? "ignore", options.stdout ?? "ignore", options.stderr ?? "ignore"],
cwd: opts.cwd,
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
})
let aborted = false
let closed = false
let timer: ReturnType<typeof setTimeout> | undefined
const abort = () => {
if (aborted) return
if (closed) return
if (proc.exitCode !== null || proc.signalCode !== null) return
aborted = true
closed = true
proc.kill(options.kill ?? "SIGTERM")
proc.kill(opts.kill ?? "SIGTERM")
const timeout = options.timeout ?? 5_000
if (timeout <= 0) return
timer = setTimeout(() => {
proc.kill("SIGKILL")
}, timeout)
const ms = opts.timeout ?? 5_000
if (ms <= 0) return
timer = setTimeout(() => proc.kill("SIGKILL"), ms)
}
const exited = new Promise<number>((resolve, reject) => {
const done = () => {
options.abort?.removeEventListener("abort", abort)
opts.abort?.removeEventListener("abort", abort)
if (timer) clearTimeout(timer)
}
proc.once("exit", (exitCode, signal) => {
proc.once("exit", (code, signal) => {
done()
resolve(exitCode ?? (signal ? 1 : 0))
resolve(code ?? (signal ? 1 : 0))
})
proc.once("error", (error) => {
done()
reject(error)
})
})
if (options.abort) {
options.abort.addEventListener("abort", abort, { once: true })
if (options.abort.aborted) abort()
if (opts.abort) {
opts.abort.addEventListener("abort", abort, { once: true })
if (opts.abort.aborted) abort()
}
const child = proc as Child
child.exited = exited
return child
}
export async function run(cmd: string[], opts: RunOptions = {}): Promise<Result> {
const proc = spawn(cmd, {
cwd: opts.cwd,
env: opts.env,
stdin: opts.stdin,
abort: opts.abort,
kill: opts.kill,
timeout: opts.timeout,
stdout: "pipe",
stderr: "pipe",
})
if (!proc.stdout || !proc.stderr) throw new Error("Process output not available")
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
const out = {
code,
stdout,
stderr,
}
if (out.code === 0 || opts.nothrow) return out
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
}
}