/**
* Cross-runtime exec helpers.
*
* bare-subprocess 6.0.0 exports `spawn` + `spawnSync` but NOT `execSync`
* or `execFileSync` (Bare keeps the subprocess API minimal). Node's
* child_process has all four. This module provides execSync/execFileSync
* with drop-in semantics, implemented on top of spawnSync — which exists
* in both runtimes.
*
* Drop-in semantics:
* - Throws on non-zero exit (status + signal preserved on err)
* - Returns stdout Buffer/string when stdio:'pipe' (default)
* - Returns null when stdio:'inherit'
* - Honours { cwd, env, encoding, maxBuffer } as spawnSync does
*
* Use these instead of child_process.execSync / execFileSync so the
* call sites work on both Bare and Node without per-site branching.
*
* `execFile` (async) is the NON-blocking counterpart. Prefer it anywhere
* the caller runs inside the daemon's event loop and may fire many spawns
* in a row: spawnSync blocks the loop for the whole child lifetime, so a
* sweep over N repos stalls every in-flight HTTP request until it ends.
*/
import { spawn, spawnSync } from '#node:child_process'
function _check (cmd, result) {
if (result.error) throw result.error
if (result.status !== 0) {
const err = new Error('Command failed: ' + cmd)
err.status = result.status
err.signal = result.signal
err.stderr = result.stderr
err.stdout = result.stdout
throw err
}
}
export function execSync (cmd, opts = {}) {
const result = spawnSync('sh', ['-c', cmd], { stdio: 'pipe', ...opts })
_check(cmd, result)
return opts.stdio === 'inherit' ? null : result.stdout
}
export function execFileSync (file, args = [], opts = {}) {
// execFileSync's signature is (file, args?, opts?). If `args` is
// omitted and opts is passed positionally, the caller's array form
// shifts. Match Node's behaviour: detect when args is the opts object.
if (args && !Array.isArray(args) && typeof args === 'object') {
opts = args
args = []
}
const result = spawnSync(file, args, { stdio: 'pipe', ...opts })
_check(file + ' ' + args.join(' '), result)
return opts.stdio === 'inherit' ? null : result.stdout
}
/**
* Async execFile — same throw-on-nonzero contract as execFileSync, but
* yields to the event loop while the child runs. Resolves with the stdout
* Buffer (callers .toString() it, matching the sync helper's return).
*
* Signature-compatible with execFileSync(file, args?, opts?) so a call site
* can switch by adding `await`. An injected SYNC git shim still works at
* such a call site, since `await` on a plain value resolves immediately.
*/
export function execFile (file, args = [], opts = {}) {
if (args && !Array.isArray(args) && typeof args === 'object') {
opts = args
args = []
}
return new Promise((resolve, reject) => {
let proc
try {
// stdin MUST be 'ignore', never 'pipe'. bare-subprocess creates a
// writable Pipe for every 'pipe' slot, but only tracks slots i>0 in
// `_closing` — nothing ever closes stdin, so each spawn with stdio:'pipe'
// leaks that pipe. Measured: 84.4 KB per spawn, growing linearly and
// never plateauing; with stdin ignored it flattens after warm-up.
// (spawnSync is unaffected — it nulls stdio[0] instead of piping it,
// which is why the previous blocking implementation never leaked.)
// These callers never write to stdin.
proc = spawn(file, args, { stdio: ['ignore', 'pipe', 'pipe'], ...opts })
} catch (err) {
return reject(err)
}
const out = []
const errOut = []
// Drain BOTH pipes — an undrained stderr can wedge a chatty child.
if (proc.stdout) proc.stdout.on('data', (c) => out.push(c))
if (proc.stderr) proc.stderr.on('data', (c) => errOut.push(c))
proc.on('error', (err) => reject(err))
proc.on('close', (status, signal) => {
const stdout = Buffer.concat(out)
if (status !== 0) {
const err = new Error('Command failed: ' + file + ' ' + args.join(' '))
err.status = status
err.signal = signal
err.stdout = stdout
err.stderr = Buffer.concat(errOut)
return reject(err)
}
resolve(stdout)
})
})
}