OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / _compat-exec.js

107 lines · javascript ref:
/**
 * 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)
    })
  })
}