/* 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 ` * 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 &&` // 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 & { 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 // ` 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 -- 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 & { cwd?: string wslDistro?: string idempotent?: boolean } const NON_IDEMPOTENT_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']) // `gh ` 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: * " 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 { 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 ` 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 & { 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'