Files
orca/src/main/git/exec-error.ts
T
Jinjing 775a932651 fix(git): distinguish binary absence from missing cwd on spawn ENOENT (#20798)
* fix(repos): preserve unknown Git availability

* fix(git): distinguish binary absence from missing cwd on spawn ENOENT

Node reports ENOENT for both a missing git binary and a missing working directory
during spawn. The fix checks specifically for spawn syscall, then verifies the cwd
exists to disambiguate. This prevents reporting "no Git" when the error is actually
a missing working directory. Centralizes probe logic in a reusable function; other
failures cause rejection so callers preserve the unknown status instead of collapsing
to false.
2026-09-15 00:22:15 -07:00

125 lines
4.0 KiB
TypeScript

/**
* Pure helpers for reading diagnostics out of a subprocess (git/gh/glab)
* rejection. Kept in a standalone module (not `runner.ts`) so lightweight
* consumers — e.g. PR-refresh error classification — can import them without
* pulling in the runner's heavy execution machinery, and so tests that mock
* `./runner` still get the real implementations.
*/
/**
* Extract stderr/stdout from an execFile rejection.
*
* Why: Node's execFile rejects with an Error that has `.stdout` and `.stderr`
* fields populated separately from `.message`. Reading `err.message` alone is
* unreliable — it can truncate stderr or omit it entirely depending on Node
* version and maxBuffer behavior. We prefer the explicit fields and fall
* back to `.message` only when neither is present.
*/
export function extractExecError(err: unknown): { stderr: string; stdout: string } {
if (err && typeof err === 'object') {
const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown }
const stderr =
typeof e.stderr === 'string'
? e.stderr
: Buffer.isBuffer(e.stderr)
? e.stderr.toString('utf-8')
: ''
const stdout =
typeof e.stdout === 'string'
? e.stdout
: Buffer.isBuffer(e.stdout)
? e.stdout.toString('utf-8')
: ''
if (stderr || stdout) {
return { stderr, stdout }
}
if (typeof e.message === 'string') {
return { stderr: e.message, stdout: '' }
}
}
return { stderr: String(err), stdout: '' }
}
/** Recognizes spawn ENOENT; callers must separately rule out a missing cwd. */
export function isMissingCommandBinaryError(err: unknown): boolean {
return Boolean(
err &&
typeof err === 'object' &&
'code' in err &&
err.code === 'ENOENT' &&
'syscall' in err &&
typeof err.syscall === 'string' &&
err.syscall.startsWith('spawn ')
)
}
/**
* Detect a Retry-After hint in gh stderr and return the suggested delay in ms,
* or null when the response includes no Retry-After.
*
* Why: gh forwards response headers when verbose, and prints "Retry-After:
* <seconds>" in error output for primary rate-limit 429s. When present, the
* caller is better served by propagating the error so the UI can surface the
* real wait time — retrying on our own 250ms cadence just earns another 429
* and burns the retry budget. Also supports HTTP-date Retry-After values.
*/
export function parseRetryAfterMs(stderr: string): number | null {
const raw = findRetryAfterHeaderValue(stderr)
if (raw === null) {
return null
}
if (/^\d+$/.test(raw)) {
const seconds = Number(raw)
return Number.isFinite(seconds) ? seconds * 1000 : null
}
const ts = Date.parse(raw)
if (Number.isNaN(ts)) {
return null
}
return Math.max(0, ts - Date.now())
}
function findRetryAfterHeaderValue(stderr: string): string | null {
const headerIndex = indexOfAsciiIgnoreCase(stderr, 'retry-after:', 0)
if (headerIndex === -1) {
return null
}
let valueStart = headerIndex + 'retry-after:'.length
while (valueStart < stderr.length) {
const code = stderr.charCodeAt(valueStart)
if (code !== 9 && code !== 32) {
break
}
valueStart++
}
let valueEnd = valueStart
while (valueEnd < stderr.length) {
const code = stderr.charCodeAt(valueEnd)
if (code === 10 || code === 13) {
break
}
valueEnd++
}
const value = stderr.slice(valueStart, valueEnd).trim()
return value.length > 0 ? value : null
}
function indexOfAsciiIgnoreCase(value: string, search: string, fromIndex: number): number {
const lastStart = value.length - search.length
for (let index = Math.max(0, fromIndex); index <= lastStart; index++) {
let matches = true
for (let offset = 0; offset < search.length; offset++) {
const code = value.charCodeAt(index + offset)
const normalizedCode = code >= 65 && code <= 90 ? code + 32 : code
if (normalizedCode !== search.charCodeAt(offset)) {
matches = false
break
}
}
if (matches) {
return index
}
}
return -1
}