orca/src/main/git/runner.ts

1038 lines
35 KiB
TypeScript

/* eslint-disable max-lines -- Why: command routing, WSL translation, and
git/gh/glab wrappers must stay co-located so platform behavior remains
consistent across every repo-scoped subprocess call. */
/**
* Centralized git/gh/command runner with transparent WSL support.
*
* Why: When a repo lives on a WSL filesystem (UNC path like \\wsl.localhost\Ubuntu\...),
* native Windows binaries (git.exe, gh.exe, rg.exe) are either absent or extremely slow.
* This module detects WSL paths and routes command execution through `wsl.exe -d <distro>`
* with translated Linux paths, so every call site gets WSL support for free.
*/
import {
execFile,
execFileSync,
spawn,
type ChildProcess,
type ExecFileOptions,
type SpawnOptions
} from 'child_process'
import { withGitSpan } from '../observability/instrumentation'
import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl'
import { getSpawnArgsForWindows, isWindowsBatchScript, resolveWindowsCommand } from '../win32-utils'
// ─── Core resolution ────────────────────────────────────────────────
type ResolvedCommand = {
binary: string
args: string[]
cwd: string | undefined
/** Non-null when the command was routed through WSL. */
wsl: WslPathInfo | null
}
/**
* Translate any Windows-style paths in command arguments to Linux paths
* when the command will execute inside WSL.
*
* Why: callers like worktree-create pass Windows paths (e.g. the workspace
* directory) as git arguments. WSL git doesn't understand Windows paths,
* so we must translate them. WSL UNC paths (\\wsl.localhost\...) are
* converted to their native Linux form; regular Windows drive paths
* (C:\Users\...) are converted to /mnt/c/Users/...
*/
function translateArgsForWsl(args: string[]): string[] {
return args.map((arg) => {
// WSL UNC path → native linux path
const wslInfo = parseWslPath(arg)
if (wslInfo) {
return wslInfo.linuxPath
}
// Windows drive path (e.g. C:\Users\...) → /mnt/c/Users/...
const driveMatch = arg.match(/^([A-Za-z]):[/\\](.*)$/)
if (driveMatch) {
const driveLetter = driveMatch[1].toLowerCase()
const rest = driveMatch[2].replace(/\\/g, '/')
return `/mnt/${driveLetter}/${rest}`
}
return arg
})
}
function hasExplicitRepoArg(args: string[]): boolean {
for (let i = 0; i < args.length; i++) {
if (
(args[i] === '--repo' || args[i] === '-R') &&
typeof args[i + 1] === 'string' &&
args[i + 1].trim()
) {
return true
}
if (args[i].startsWith('--repo=') || args[i].startsWith('-R=')) {
return args[i].slice(args[i].indexOf('=') + 1).trim().length > 0
}
if (args[i].startsWith('-R') && args[i].length > 2) {
return args[i].slice(2).trim().length > 0
}
}
return false
}
function argsUseGhApiPlaceholders(args: string[]): boolean {
return args.some(
(arg) => arg.includes('{owner}') || arg.includes('{repo}') || arg.includes('{branch}')
)
}
function canRunGitHubCliWithoutRepoCwd(args: string[]): boolean {
if (hasExplicitRepoArg(args)) {
return true
}
if (args[0] === 'api') {
return !argsUseGhApiPlaceholders(args)
}
return args[0] === 'auth'
}
function isMissingCommandInWsl(stderr: string, command: string): boolean {
const s = stderr.toLowerCase()
const c = command.toLowerCase()
return s.includes(`${c}: command not found`) || s.includes(`${c}: not found`)
}
function canFallBackToHostGitHubCli(
command: 'gh',
args: string[],
resolved: ResolvedCommand,
stderr: string
): boolean {
return (
process.platform === 'win32' &&
resolved.wsl !== null &&
isMissingCommandInWsl(stderr, command) &&
canRunGitHubCliWithoutRepoCwd(args)
)
}
function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand {
return {
binary: command,
args,
// Why: host gh cannot use a WSL UNC cwd reliably. We only fall back
// for commands with explicit repo/API context, so no repo cwd is required.
cwd: undefined,
wsl: null
}
}
function resolveDefaultWslCli(command: 'gh' | 'glab', args: string[]): ResolvedCommand | null {
const distro = getDefaultWslDistro()
return distro ? resolveCommand(command, args, undefined, distro) : null
}
function isHostCommandMissing(err: unknown, command: 'gh' | 'glab'): boolean {
if (!err || typeof err !== 'object') {
return false
}
const e = err as { code?: unknown; message?: unknown; syscall?: unknown; path?: unknown }
if (e.code === 'ENOENT') {
return true
}
const message = typeof e.message === 'string' ? e.message.toLowerCase() : ''
return (
message.includes('enoent') &&
(message.includes(command) || e.path === command || e.syscall === 'spawn')
)
}
/**
* Given a command, its arguments, and a working directory, resolve whether
* the invocation should be routed through wsl.exe.
*
* Why `bash -c "cd ... && ..."` instead of `--cd`: wsl.exe's --cd flag
* does not work reliably when invoked via Node's execFile/spawn (it fails
* with ERROR_PATH_NOT_FOUND in some configurations). Using bash -c with
* an explicit cd is universally supported.
*/
function resolveCommand(
command: string,
args: string[],
cwd: string | undefined,
wslDistroOverride?: string
): ResolvedCommand {
if (process.platform !== 'win32') {
return { binary: command, args, cwd, wsl: null }
}
// Why: global gh callers (rate_limit, listAccessibleProjects) have no
// meaningful cwd to derive a WSL distro from. On WSL-only Windows setups,
// gh.exe isn't on the host PATH and the spawn fails with ENOENT. Allow
// callers to pass a distro hint so we can route through wsl.exe regardless.
// TODO(wsl-default-distro): the codebase currently has no persistent
// "default WSL distro" setting — distros are derived from individual repo
// paths. Until such a setting exists, global gh callers without an explicit
// override silently fall back to host gh.exe, which on WSL-only Windows
// installs will ENOENT. The wslDistroOverride parameter is the hook for
// wiring a future setting in without re-plumbing the runner.
const cwdWsl = cwd ? parseWslPath(cwd) : null
const wsl: WslPathInfo | null =
cwdWsl ?? (wslDistroOverride ? { distro: wslDistroOverride, linuxPath: '' } : null)
if (!wsl) {
return { binary: command, args, cwd, wsl: null }
}
const translatedArgs = translateArgsForWsl(args)
// Why: shell-escape each argument to prevent word splitting / glob expansion
// inside the bash -c string. Single quotes are safe for all chars except
// single quotes themselves, which we escape as '\'' (end quote, escaped
// literal, reopen quote).
const escapedArgs = translatedArgs.map((a) => `'${a.replace(/'/g, "'\\''")}'`)
// Why: when cwd is supplied as a WSL UNC path, prepend `cd <linuxPath> &&`
// so the command runs in the expected directory. When the caller only
// supplied a distro override (no cwd), skip the cd entirely — the gh CLI
// doesn't need a particular cwd for global calls like `api rate_limit`.
const shellCmd = cwdWsl
? `cd '${cwdWsl.linuxPath.replace(/'/g, "'\\''")}' && ${command} ${escapedArgs.join(' ')}`
: `${command} ${escapedArgs.join(' ')}`
return {
binary: 'wsl.exe',
args: ['-d', wsl.distro, '--', 'bash', '-c', shellCmd],
// Why: cwd is set to undefined because wsl.exe handles directory switching
// via the cd inside bash -c. Setting a UNC cwd on the Node process would
// be redundant and can cause issues with some Node internals.
cwd: undefined,
wsl
}
}
// ─── Git-specific runners ───────────────────────────────────────────
type GitExecOptions = {
cwd: string
encoding?: BufferEncoding | 'buffer'
maxBuffer?: number
timeout?: number
env?: NodeJS.ProcessEnv
}
type CommandExecOptions = {
cwd?: string
encoding?: BufferEncoding
maxBuffer?: number
timeout?: number
env?: NodeJS.ProcessEnv
signal?: AbortSignal
}
function isMissingCommandError(error: unknown): boolean {
return Boolean(
error && typeof error === 'object' && (error as { code?: unknown }).code === 'ENOENT'
)
}
function hasPathSeparator(command: string): boolean {
return command.includes('/') || command.includes('\\')
}
function shouldRetryWindowsCommandShim(error: unknown, resolved: ResolvedCommand): boolean {
return (
process.platform === 'win32' &&
resolved.wsl === null &&
isMissingCommandError(error) &&
!hasPathSeparator(resolved.binary) &&
!/\.[A-Za-z0-9]+$/.test(resolved.binary)
)
}
function createAbortError(): Error {
const error = new Error('The operation was aborted.')
error.name = 'AbortError'
return error
}
function killSpawnedCommandTree(child: ChildProcess): void {
const pid = child.pid
if (!pid || process.platform !== 'win32') {
child.kill()
return
}
try {
// Why: Windows package-manager CLIs are often .cmd shims. Killing only
// cmd.exe leaves the underlying node/npm/pnpm child running.
const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true
})
killer.on('error', () => child.kill())
killer.unref()
} catch {
child.kill()
}
}
type ExecFileCaptureOptions = Omit<ExecFileOptions, 'timeout'> & {
timeout?: number
}
function emptyExecFileOutput(options: ExecFileCaptureOptions): string | Buffer {
return options.encoding === 'buffer' ? Buffer.alloc(0) : ''
}
function isExecFileResultObject(
value: unknown
): value is { stdout: string | Buffer; stderr: string | Buffer } {
return (
value !== null &&
typeof value === 'object' &&
!Buffer.isBuffer(value) &&
'stdout' in value &&
'stderr' in value
)
}
function execFileCapture(
command: string,
args: string[],
options: ExecFileCaptureOptions
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> {
return new Promise((resolve, reject) => {
if (options.signal?.aborted) {
reject(createAbortError())
return
}
let settled = false
let child: ChildProcess | null = null
let timer: NodeJS.Timeout | null = null
const cleanup = (): void => {
if (timer) {
clearTimeout(timer)
timer = null
}
options.signal?.removeEventListener('abort', onAbort)
}
const finish = (
error: Error | null,
stdout: string | Buffer = emptyExecFileOutput(options),
stderr: string | Buffer = emptyExecFileOutput(options)
): void => {
if (settled) {
return
}
settled = true
cleanup()
if (error) {
const enriched = error as Error & { stdout?: string | Buffer; stderr?: string | Buffer }
enriched.stdout ??= stdout
enriched.stderr ??= stderr
reject(enriched)
return
}
resolve({ stdout, stderr })
}
const onAbort = (): void => {
if (child) {
killSpawnedCommandTree(child)
}
finish(createAbortError())
}
try {
child = execFile(
command,
args,
{
cwd: options.cwd,
encoding: options.encoding,
maxBuffer: options.maxBuffer,
env: options.env,
signal: options.signal
},
(error, stdout, stderr) => {
if (!error && stderr === undefined && isExecFileResultObject(stdout)) {
finish(null, stdout.stdout, stdout.stderr)
return
}
finish(error, stdout, stderr)
}
)
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)))
return
}
// Why: Node's native execFile timeout waits for the child to exit after
// signaling it. Some CLIs ignore that signal, so reject the UI operation
// on our own timer and kill the child only as best effort.
if (options.timeout && options.timeout > 0) {
timer = setTimeout(() => {
if (child) {
killSpawnedCommandTree(child)
}
finish(new Error(`${command} timed out.`))
}, options.timeout)
}
options.signal?.addEventListener('abort', onAbort, { once: true })
})
}
async function spawnCommandCapture(
command: string,
args: string[],
options: CommandExecOptions
): Promise<{ stdout: string; stderr: string }> {
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, args)
return new Promise((resolve, reject) => {
if (options.signal?.aborted) {
reject(createAbortError())
return
}
let settled = false
let stdout = ''
let stderr = ''
let stdoutBytes = 0
let stderrBytes = 0
const child = spawn(spawnCmd, spawnArgs, {
cwd: options.cwd,
env: options.env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
let timer: NodeJS.Timeout | null = null
const onAbort = (): void => {
killSpawnedCommandTree(child)
finish(createAbortError())
}
const cleanupListeners = (): void => {
if (timer) {
clearTimeout(timer)
timer = null
}
options.signal?.removeEventListener('abort', onAbort)
child.stdout?.off('data', onStdoutData)
child.stderr?.off('data', onStderrData)
child.off('error', onError)
child.off('close', onClose)
}
const finish = (error: Error | null): void => {
if (settled) {
return
}
settled = true
cleanupListeners()
if (error) {
reject(Object.assign(error, { stdout, stderr }))
return
}
resolve({ stdout, stderr })
}
timer = options.timeout
? setTimeout(() => {
killSpawnedCommandTree(child)
finish(new Error(`${command} timed out.`))
}, options.timeout)
: null
options.signal?.addEventListener('abort', onAbort, { once: true })
function onStdoutData(chunk: Buffer): void {
stdoutBytes += chunk.byteLength
if (options.maxBuffer && stdoutBytes > options.maxBuffer) {
killSpawnedCommandTree(child)
finish(new Error(`${command} stdout exceeded maxBuffer.`))
return
}
stdout += chunk.toString(options.encoding ?? 'utf-8')
}
function onStderrData(chunk: Buffer): void {
stderrBytes += chunk.byteLength
if (options.maxBuffer && stderrBytes > options.maxBuffer) {
killSpawnedCommandTree(child)
finish(new Error(`${command} stderr exceeded maxBuffer.`))
return
}
stderr += chunk.toString(options.encoding ?? 'utf-8')
}
function onError(error: Error): void {
finish(error)
}
function onClose(code: number | null): void {
if (code === 0) {
finish(null)
return
}
finish(new Error(`${command} exited with ${code}.`))
}
child.stdout?.on('data', onStdoutData)
child.stderr?.on('data', onStderrData)
child.on('error', onError)
child.on('close', onClose)
})
}
export function gitOptionalLocksDisabledEnv(
env: NodeJS.ProcessEnv = process.env
): NodeJS.ProcessEnv {
return {
...env,
GIT_OPTIONAL_LOCKS: '0'
}
}
/**
* Async git command execution. Drop-in replacement for
* `execFileAsync('git', args, { cwd, encoding, ... })`.
*/
export async function gitExecFileAsync(
args: string[],
options: GitExecOptions
): Promise<{ stdout: string; stderr: string }> {
// Why wrap here: the resolved binary path / WSL detection is internal
// detail; the span attributes track the user-visible `git <subcommand>
// <args…>` form so dashboards group cleanly by intent rather than by
// platform-conditional binary path.
return withGitSpan(
{ args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) },
async () => {
const resolved = resolveCommand('git', args, options.cwd)
const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
timeout: options.timeout,
env: options.env
})
return { stdout: stdout as string, stderr: stderr as string }
}
)
}
/**
* Async command execution with the same WSL cwd translation as repo-scoped git.
* Keep this for fixed binary+argv call sites; never pass shell fragments.
*/
export async function commandExecFileAsync(
command: string,
args: string[],
options: CommandExecOptions = {}
): Promise<{ stdout: string; stderr: string }> {
const resolved = resolveCommand(command, args, options.cwd)
const binary =
resolved.wsl === null ? resolveWindowsCommand(resolved.binary, options.env) : resolved.binary
if (isWindowsBatchScript(binary)) {
return spawnCommandCapture(binary, resolved.args, {
...options,
cwd: resolved.cwd
})
}
try {
const { stdout, stderr } = await execFileCapture(binary, resolved.args, {
cwd: resolved.cwd,
encoding: options.encoding ?? 'utf-8',
maxBuffer: options.maxBuffer,
timeout: options.timeout,
env: options.env,
signal: options.signal
})
return { stdout: stdout as string, stderr: stderr as string }
} catch (error) {
if (shouldRetryWindowsCommandShim(error, resolved)) {
return spawnCommandCapture(
resolveWindowsCommand(`${resolved.binary}.cmd`, options.env),
resolved.args,
{
...options,
cwd: resolved.cwd
}
)
}
throw error
}
}
/**
* Async git command execution that returns a Buffer.
* Used for reading binary blobs (git show).
*/
export async function gitExecFileAsyncBuffer(
args: string[],
options: { cwd: string; maxBuffer?: number }
): Promise<{ stdout: Buffer }> {
const resolved = resolveCommand('git', args, options.cwd)
const { stdout } = (await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: 'buffer',
maxBuffer: options.maxBuffer
})) as { stdout: Buffer }
return { stdout }
}
/**
* Sync git command execution. Drop-in replacement for
* `execFileSync('git', args, { cwd, encoding, ... })`.
*
* Returns trimmed stdout as a string.
*/
export function gitExecFileSync(
args: string[],
options: {
cwd: string
encoding?: BufferEncoding
stdio?: SpawnOptions['stdio']
}
): string {
const resolved = resolveCommand('git', args, options.cwd)
return execFileSync(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: options.encoding ?? 'utf-8',
stdio: options.stdio ?? ['pipe', 'pipe', 'pipe']
}) as string
}
/**
* Spawn a git child process. Drop-in replacement for
* `spawn('git', args, { cwd, stdio, ... })`.
*/
export function gitSpawn(args: string[], options: SpawnOptions & { cwd: string }): ChildProcess {
const resolved = resolveCommand('git', args, options.cwd)
return spawn(resolved.binary, resolved.args, {
...options,
cwd: resolved.cwd
})
}
// ─── gh CLI runners ─────────────────────────────────────────────────
// Why: non-repo-scoped gh calls (listAccessibleProjects, rate_limit, etc.)
// have no meaningful cwd. Allow it to be omitted so the one WSL-aware wrapper
// serves both repo-scoped and global callers and we stop having two spawn
// sites (the other one — a plain execFileAsync in project-view.ts — bypasses
// retry/backoff and any future quota tracker).
// Why: `wslDistro` is an explicit hint for global (cwd-less) gh callers on
// WSL-only Windows installs where gh.exe isn't on the host PATH. When set,
// resolveCommand routes the spawn through `wsl.exe -d <distro> -- gh ...`
// even without a UNC cwd to parse a distro from. Repo-scoped callers should
// keep using cwd — the distro derives from the path automatically there.
// Why: `idempotent` gates the transient-error retry. When undefined we
// auto-detect from argv (writes are detected by `-X POST/PATCH/PUT/DELETE`
// or a `query=mutation …` arg); callers can also pass an explicit override.
// A 5xx/socket reset after the request reaches GitHub but before the
// response returns is the canonical case where the server-side write
// succeeded; retrying would create a duplicate comment/issue/label addition.
// See bug-scan finding 1.
type GhExecOptions = Omit<GitExecOptions, 'cwd'> & {
cwd?: string
wslDistro?: string
idempotent?: boolean
}
const NON_IDEMPOTENT_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE'])
// `gh <noun> <verb>` write subcommands. Reads (view/list/status/checks)
// are absent on purpose so the default of "retry" stays for them.
const NON_IDEMPOTENT_GH_VERBS = new Set([
'create',
'edit',
'update',
'delete',
'close',
'reopen',
'merge',
'comment',
'review',
'ready',
'lock',
'unlock',
'pin',
'unpin',
'transfer',
'develop'
])
function argsLookIdempotent(args: string[]): boolean {
let explicitMethodSeen = false
let hasApiBodyField = false
let hasGraphQlQuery = false
const isGraphQlApi = args[0] === 'api' && args[1] === 'graphql'
for (let i = 0; i < args.length; i++) {
const a = args[i]
if (a === '-X' || a === '--method') {
explicitMethodSeen = true
const next = args[i + 1]
if (typeof next === 'string' && NON_IDEMPOTENT_METHODS.has(next.toUpperCase())) {
return false
}
}
// Single-token form `--method=POST` (gh accepts this).
if (a.startsWith('--method=')) {
explicitMethodSeen = true
const value = a.slice('--method='.length)
if (NON_IDEMPOTENT_METHODS.has(value.toUpperCase())) {
return false
}
}
// `gh api` auto-switches GET→POST when -f/-F/--field/--raw-field body
// fields are supplied without an explicit -X. Track those to classify
// such calls as non-idempotent.
if (a === '-f' || a === '-F' || a === '--field' || a === '--raw-field') {
hasApiBodyField = true
} else if (
a.startsWith('-f=') ||
a.startsWith('-F=') ||
a.startsWith('--field=') ||
a.startsWith('--raw-field=')
) {
hasApiBodyField = true
}
// `gh api graphql -f query=mutation(...){ ... }` — detect mutation queries
// so writes via the GraphQL endpoint also fail fast on transient errors.
if (a.startsWith('query=')) {
hasGraphQlQuery = true
const trimmed = a.slice('query='.length).trimStart().toLowerCase()
if (trimmed.startsWith('mutation')) {
return false
}
}
}
// `gh api ... -f foo=bar` with no explicit method: gh switches to POST.
// Treat as non-idempotent so a transient 5xx after the server applied
// the write doesn't retry and duplicate it. GraphQL reads are the exception:
// gh sends them as POST body fields, but a query operation is idempotent.
if (
args[0] === 'api' &&
hasApiBodyField &&
!explicitMethodSeen &&
!(isGraphQlApi && hasGraphQlQuery)
) {
return false
}
// `gh issue close`, `gh pr edit`, `gh pr merge`, etc. The first arg is the
// noun (issue/pr/repo/label/...) and the second is the verb. Defaulting
// `gh api` calls without an explicit -X to GET-equivalent (idempotent) is
// intentional: callers that POST through `gh api` set `-X POST`.
if (args.length >= 2 && args[0] !== 'api') {
if (NON_IDEMPOTENT_GH_VERBS.has(args[1])) {
return false
}
}
return true
}
/**
* Extract stderr 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: '' }
}
/**
* 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 m = stderr.match(/retry-after:\s*([^\r\n]+)/i)
if (!m) {
return null
}
const raw = m[1].trim()
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())
}
/**
* Classify whether a gh execFile rejection is worth retrying.
*
* Why: gh surfaces HTTP status in stderr as "HTTP 504", "HTTP 502", etc.
* Network resets and DNS hiccups also show up as stderr substrings. We retry
* those and 429 (rate-limited) — but only 429s without an explicit
* Retry-After (the caller is better off propagating so the UI can show the
* actual wait time). The primary-rate-limit 403 branch is NOT retried: those
* require the user to back off for minutes, which is not transient.
*/
export function isTransientGhError(stderr: string): boolean {
const s = stderr.toLowerCase()
if (
s.includes('http 500') ||
s.includes('http 502') ||
s.includes('http 503') ||
s.includes('http 504') ||
s.includes('econnreset') ||
s.includes('etimedout') ||
s.includes('socket hang up')
) {
return true
}
// 429 without Retry-After: retry. With Retry-After: propagate.
if (s.includes('http 429')) {
return parseRetryAfterMs(stderr) === null
}
return false
}
// Why: total of 3 attempts (original + 2 retries) with 250ms → 1s backoff.
// These are standard "transient 5xx" values. Longer waits push past user
// patience for an interactive action; shorter waits would hammer the same
// unhealthy upstream that just failed. The array length defines retry count;
// total attempts = length + 1.
const GH_RETRY_DELAYS_MS = [250, 1000] as const
// Why: the upstream Retry-After header is server-suggested but unbounded —
// GitHub has been observed to send tens-of-seconds values on rare incidents,
// and a malicious or misconfigured proxy could send anything. Cap the wait
// at 30s so a single transient gh call can never block the IPC main thread
// for longer than the user's patience budget for an interactive action.
const GH_RETRY_AFTER_MAX_MS = 30_000
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* Async gh CLI execution. Drop-in replacement for
* `execFileAsync('gh', args, { cwd, encoding, ... })`.
*
* Retries transient 5xx / 429 (without Retry-After) / network-reset failures
* with exponential backoff. Non-transient errors (auth, 404, rate-limit 403,
* validation, 429-with-Retry-After) fail fast on the first attempt.
*/
export async function ghExecFileAsync(
args: string[],
options: GhExecOptions = {}
): Promise<{ stdout: string; stderr: string }> {
let resolved = resolveCommand('gh', args, options.cwd, options.wslDistro)
let lastError: unknown
let attemptedHostFallback = false
let attemptedDefaultWslFallback = false
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
try {
const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
timeout: options.timeout,
env: options.env
})
return { stdout: stdout as string, stderr: stderr as string }
} catch (err) {
lastError = err
const { stderr } = extractExecError(err)
if (
process.platform === 'win32' &&
!attemptedDefaultWslFallback &&
resolved.wsl === null &&
!options.cwd &&
!options.wslDistro &&
isHostCommandMissing(err, 'gh')
) {
const wslResolved = resolveDefaultWslCli('gh', args)
if (wslResolved) {
// Why: WSL-only Windows installs have no gh.exe on the host PATH, but
// global calls like rate_limit/auth do not carry a repo cwd to route by.
resolved = wslResolved
attemptedDefaultWslFallback = true
attempt = -1
continue
}
}
if (!attemptedHostFallback && canFallBackToHostGitHubCli('gh', args, resolved, stderr)) {
resolved = resolveHostGitHubCli('gh', args)
attemptedHostFallback = true
attempt = -1
continue
}
const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length
// Why: only retry idempotent calls. A 5xx/socket reset can arrive
// after the server already applied a POST/PATCH/PUT/DELETE; retrying
// would duplicate the write (e.g. double-post a comment, double-add
// a label). When the caller doesn't say, we auto-detect from argv —
// explicit `-X <method>` and GraphQL `query=mutation …` are treated
// as non-idempotent. See bug-scan finding 1.
const idempotent = options.idempotent ?? argsLookIdempotent(args)
if (idempotent && !isLastAttempt && isTransientGhError(stderr)) {
// Why: when the upstream surfaced a Retry-After (e.g. on a transient
// 5xx that GitHub explicitly recommends backing off for), honor it
// instead of using our default backoff — sleeping less than the
// server suggests just earns another failure and burns our retry
// budget. Cap at GH_RETRY_AFTER_MAX_MS so a pathologically large
// hint can't block IPC for minutes; if the real wait is longer, the
// attempt will fail again and the error will propagate to the UI
// where the user can see it.
const retryAfterMs = parseRetryAfterMs(stderr)
const delayMs =
retryAfterMs !== null
? Math.min(retryAfterMs, GH_RETRY_AFTER_MAX_MS)
: GH_RETRY_DELAYS_MS[attempt]
await sleep(delayMs)
continue
}
throw err
}
}
// Unreachable: the loop either returns or throws. Here for TS exhaustiveness.
throw lastError
}
// ─── glab CLI runner ────────────────────────────────────────────────
// Why: parallel to gh CLI runner above. GitLab support is added by
// cloning gh's surface rather than abstracting both behind a generic
// runner — keeping them as parallel implementations matches the
// project's clone-and-adapt approach for new providers and avoids
// touching the working gh path. Reuses the shared retry/transient
// helpers since HTTP-status- and TCP-error-based classification is
// provider-agnostic.
type GlabExecOptions = Omit<GitExecOptions, 'cwd'> & {
cwd?: string
wslDistro?: string
idempotent?: boolean
}
/**
* Async glab CLI execution. Drop-in replacement for
* `execFileAsync('glab', args, { cwd, encoding, ... })`.
*
* Retry policy mirrors ghExecFileAsync.
*/
export async function glabExecFileAsync(
args: string[],
options: GlabExecOptions = {}
): Promise<{ stdout: string; stderr: string }> {
let resolved = resolveCommand('glab', args, options.cwd, options.wslDistro)
let lastError: unknown
let attemptedDefaultWslFallback = false
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
try {
const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
timeout: options.timeout,
env: options.env
})
return { stdout: stdout as string, stderr: stderr as string }
} catch (err) {
lastError = err
const { stderr } = extractExecError(err)
if (
process.platform === 'win32' &&
!attemptedDefaultWslFallback &&
resolved.wsl === null &&
!options.cwd &&
!options.wslDistro &&
isHostCommandMissing(err, 'glab')
) {
const wslResolved = resolveDefaultWslCli('glab', args)
if (wslResolved) {
// Why: mirror gh's WSL-only fallback for global GitLab project/auth calls.
resolved = wslResolved
attemptedDefaultWslFallback = true
attempt = -1
continue
}
}
const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length
// Why: mirror gh's write-safety gate. A transient error after GitLab
// applies a POST/PATCH/PUT/DELETE must not create duplicate comments,
// issues, or merge actions through an automatic retry.
const idempotent = options.idempotent ?? argsLookIdempotent(args)
if (idempotent && !isLastAttempt && isTransientGhError(stderr)) {
const retryAfterMs = parseRetryAfterMs(stderr)
const delayMs =
retryAfterMs !== null
? Math.min(retryAfterMs, GH_RETRY_AFTER_MAX_MS)
: GH_RETRY_DELAYS_MS[attempt]
await sleep(delayMs)
continue
}
throw err
}
}
throw lastError
}
// ─── Generic command runner (for rg, etc.) ──────────────────────────
/**
* Spawn any command with WSL awareness.
* Used for non-git binaries like `rg` that also need WSL routing.
*/
export function wslAwareSpawn(
command: string,
args: string[],
options: SpawnOptions & { cwd?: string }
): ChildProcess {
const resolved = resolveCommand(command, args, options.cwd)
return spawn(resolved.binary, resolved.args, {
...options,
cwd: resolved.cwd
})
}
// ─── Path translation helpers ───────────────────────────────────────
/**
* Translate absolute Linux paths in git output back to Windows UNC paths.
*
* Why: when git runs inside WSL, paths in output (e.g. `git worktree list`)
* are Linux-native (/home/user/repo). The rest of Orca needs Windows UNC
* paths (\\wsl.localhost\Ubuntu\home\user\repo) to read files via Node fs.
*/
export function translateWslOutputPaths(output: string, originalCwd: string): string {
const wsl = parseWslPath(originalCwd)
if (!wsl) {
return output
}
// Replace absolute Linux paths that start with / and look like filesystem
// paths in structured git output (e.g. "worktree /home/user/repo/feature")
return output.replace(/(?<=worktree )(\/.+)$/gm, (_match, linuxPath: string) =>
toWindowsWslPath(linuxPath, wsl.distro)
)
}
/**
* Get the WSL info for a path, if applicable. Convenience re-export so
* consumers don't need to import from wsl.ts directly.
*/
export { parseWslPath, toLinuxPath, toWindowsWslPath, isWslPath } from '../wsl'