/* 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 'node:child_process' import { StringDecoder } from 'node:string_decoder' import { withGitSpan } from '../observability/instrumentation' import { recordSubprocessSpawn } from '../diagnostics/main-thread-churn-probe' import { classifyGhRateLimitBucket, createGhRateLimitBlockedError, getGhRateLimitBlockedUntilMs, isGhPrimaryRateLimitStderr, isGhRateLimitProbe, notifyGhPrimaryRateLimit } from './gh-rate-limit-breaker' import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl' import { addWslEnvKeys } from '../wsl-env' import { getSpawnArgsForWindows, isWindowsBatchScript, resolveWindowsCommand } from '../win32-utils' import { buildWslLoginShellCommand, escapeWslShCommandForWindows, quotePosixShell } from '../../shared/wsl-login-shell-command' import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale' // ─── Core resolution ──────────────────────────────────────────────── // `LANGUAGE=en LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8` — env-assignment prefix for // WSL-routed git, where spawn env cannot cross the wsl.exe boundary. Values are // shell-safe unquoted (alnum, dot, dash, underscore only). const GIT_OUTPUT_LOCALE_SHELL_PREFIX = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV) .map(([key, value]) => `${key}=${value}`) .join(' ') 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(translateArgForWsl) } function translateArgForWsl(arg: string): string { // 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, options: { useWslLoginShell?: boolean } = {} ): 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: env set on wsl.exe stays on the Windows side (WSLENV forwards only // named vars), so the untranslated-output locale must ride the command // string for git stderr parsers to work inside the distro (issue #7808). const localePrefix = command === 'git' ? `${GIT_OUTPUT_LOCALE_SHELL_PREFIX} ` : '' const escapedCommand = quotePosixShell(command) // 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(quotePosixShell) // 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 linuxCwd = cwdWsl?.linuxPath ?? (cwd && wslDistroOverride ? translateArgForWsl(cwd) : null) const shellCmd = linuxCwd ? `cd ${quotePosixShell(linuxCwd)} && ${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}` : `${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}` if (options.useWslLoginShell) { return { binary: 'wsl.exe', args: [ '-d', wsl.distro, '--', 'sh', '-lc', escapeWslShCommandForWindows(buildWslLoginShellCommand(shellCmd)) ], cwd: undefined, wsl } } 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 ─────────────────────────────────────────── // Why: Node's execFile only honors maxBuffer when it is a number — passing // `undefined` (which happens whenever a caller omits the option) disables the // cap entirely, so a command that prints more than V8's ~512MB max string // length crashes the main process uncatchably inside execFile's exit handler // (Array.join over the buffered chunks). Apply this floor so no git call can // ever buffer without a bound. Matches the relay's MAX_GIT_BUFFER. export const DEFAULT_GIT_MAX_BUFFER = 10 * 1024 * 1024 type GitExecOptions = { cwd: string encoding?: BufferEncoding | 'buffer' maxBuffer?: number timeout?: number stdin?: string env?: NodeJS.ProcessEnv signal?: AbortSignal wslDistro?: string useConfiguredSshCommandForNetwork?: boolean } 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 stdin?: string } 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 { const spawnStartedAt = performance.now() child = execFile( command, args, { cwd: options.cwd, encoding: options.encoding, maxBuffer: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER, 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) } ) recordSubprocessSpawn(command, args, performance.now() - spawnStartedAt) } catch (error) { finish(error instanceof Error ? error : new Error(String(error))) return } child.once('error', (error) => finish(error)) if (options.stdin !== undefined) { child.stdin?.end(options.stdin) } // 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 spawnStartedAt = performance.now() const child = spawn(spawnCmd, spawnArgs, { cwd: options.cwd, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) recordSubprocessSpawn(spawnCmd, spawnArgs, performance.now() - spawnStartedAt) 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' } } /** * Append git config entries through the GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n / * GIT_CONFIG_VALUE_n env protocol (git >= 2.31), composing with any count * already present in `env` so we never clobber config a caller injected the * same way. */ export function appendGitConfigEnv( env: NodeJS.ProcessEnv, entries: readonly (readonly [key: string, value: string])[] ): NodeJS.ProcessEnv { const parsed = Number.parseInt(env.GIT_CONFIG_COUNT ?? '', 10) const base = Number.isInteger(parsed) && parsed > 0 ? parsed : 0 const next = { ...env } entries.forEach(([key, value], index) => { next[`GIT_CONFIG_KEY_${base + index}`] = key next[`GIT_CONFIG_VALUE_${base + index}`] = value }) next.GIT_CONFIG_COUNT = String(base + entries.length) return next } /** * Pin Orca-spawned git to untranslated English output so stderr/progress * parsers keep working under any user locale (issue #7808; see * UNTRANSLATED_GIT_OUTPUT_ENV for the full rationale). Terminal git is * untouched. Injected by every git runner in this module; WSL-routed spawns * get the same values via GIT_OUTPUT_LOCALE_SHELL_PREFIX instead. */ export function untranslatedGitOutputEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { return { ...env, ...UNTRANSLATED_GIT_OUTPUT_ENV } } const GIT_CONFIG_WSLENV_KEY_RE = /^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/ export function promptGuardGitEnv( env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform ): NodeJS.ProcessEnv { const next = appendGitConfigEnv( { ...untranslatedGitOutputEnv(env), GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: env.GIT_ASKPASS ?? '', SSH_ASKPASS: env.SSH_ASKPASS ?? '', // Why: Git Credential Manager ignores GIT_TERMINAL_PROMPT / GIT_ASKPASS and // pops a GUI on first auth — the Windows worktree-create hang (STA-1292). // `never` suppresses the prompt while still serving cached credentials. GCM_INTERACTIVE: 'never' }, // Why: disable only the *interactive* credential prompt, NOT the helper // itself — an empty credential.helper would break cached-credential auth for // private repos. Harmless on macOS/Linux (no GCM) and on the SSH path. [ ['credential.interactive', 'false'], ['credential.guiPrompt', 'false'] ] ) if (platform === 'win32') { // Why: wsl.exe only imports env vars named in WSLENV, so WSL-routed git // would never see the guard — and WSL setups commonly use the Windows // credential manager as git's helper, which pops the same OAuth window // (issue #7652). Askpass vars are deliberately not forwarded: a Windows // askpass path is meaningless inside the distro. GIT_CONFIG_* must be // forwarded all-or-nothing (including caller-preexisting entries): a // GIT_CONFIG_COUNT that crosses without every KEY_n/VALUE_n below it makes // git error out on "bogus count". addWslEnvKeys(next, [ 'GIT_TERMINAL_PROMPT', 'GCM_INTERACTIVE', ...Object.keys(next).filter((key) => GIT_CONFIG_WSLENV_KEY_RE.test(key)) ]) } return next } /** * Credential-prompt guard for a general-purpose shell environment (terminal * PTYs, hook scripts): everything promptGuardGitEnv does EXCEPT the issue-7808 * locale pins. Those exist so Orca can parse stderr of git it spawns itself; * forcing LC_ALL/LANG/LANGUAGE onto a user's shell would change the locale of * every child process, not just git's. */ export function promptGuardShellEnv( env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform ): NodeJS.ProcessEnv { const next = promptGuardGitEnv(env, platform) for (const key of Object.keys(UNTRANSLATED_GIT_OUTPUT_ENV)) { if (env[key] === undefined) { delete next[key] } else { next[key] = env[key] } } return next } /** * Force git to be non-interactive so it fails fast instead of blocking forever * on a prompt. Without this, a git read-path call (status, worktree list, …) * that hits an auth/credential prompt or an SSH host-key confirmation hangs on * stdin with no terminal to answer it; on the headless `serve` runtime those * stuck calls pile up and the runtime stops answering all clients (issue #5308). * * - GIT_TERMINAL_PROMPT=0: git refuses to prompt for credentials and errors out. * - GIT_ASKPASS / SSH_ASKPASS: emptied when unset so no GUI/askpass helper can * pop a prompt and block. A caller-provided askpass is preserved on purpose — * custom askpass setups commonly *serve* credentials non-interactively, and * blanking them would break those fetches. * - GIT_SSH_COMMAND BatchMode=yes: SSH fails instead of waiting on an * interactive password/host-key prompt. BatchMode does NOT change host trust * (an unknown host still errors, it just won't hang). Only added when the * caller hasn't set its own GIT_SSH_COMMAND. */ export function nonInteractiveGitEnv( env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform ): NodeJS.ProcessEnv { const next = promptGuardGitEnv(env, platform) if (!next.GIT_SSH_COMMAND) { next.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes' if (platform === 'win32') { // Why: forward across the WSL boundary only when we set the value — // plain `ssh` resolves inside the distro, whereas a caller's // Windows-specific GIT_SSH_COMMAND must not leak into Linux git. addWslEnvKeys(next, ['GIT_SSH_COMMAND']) } } return next } type GitSshPolicyMode = | 'default' | 'explicit-env' | 'fallback' | 'configured-openssh' | 'configured-wrapper-passthrough' const CORE_SSH_COMMAND_PROBE_TIMEOUT_MS = 2500 function commandBasename(command: string): string { const pieces = command.split(/[\\/]+/) return pieces.at(-1)?.toLowerCase() ?? command.toLowerCase() } function isMergeableOpenSshCommand(command: string): boolean { const basename = commandBasename(command) return basename === 'ssh' || basename === 'ssh.exe' } function shellTokenize(command: string): string[] | null { const tokens: string[] = [] let current = '' let quote: "'" | '"' | null = null let escaped = false for (let i = 0; i < command.length; i++) { const char = command[i] if (escaped) { current += char escaped = false continue } if (char === '\\') { const next = command[i + 1] if (next && /[\s'"\\]/.test(next)) { escaped = true } else { current += char } continue } if (quote) { if (char === quote) { quote = null } else { current += char } continue } if (char === "'" || char === '"') { quote = char continue } if (/\s/.test(char)) { if (current) { tokens.push(current) current = '' } continue } if (';&|<>()`'.includes(char)) { return null } current += char } if (escaped || quote) { return null } if (current) { tokens.push(current) } return tokens } function shellQuoteToken(token: string): string { return /^[A-Za-z0-9_@%+=:,./~-]+$/.test(token) ? token : quotePosixShell(token) } function containsShellExpansionSyntax(command: string): boolean { return command.includes('$') } function withoutBatchModeOptions(tokens: string[]): string[] { const next: string[] = [] for (let i = 0; i < tokens.length; i++) { const token = tokens[i] const lower = token.toLowerCase() if (lower === '-o') { const option = tokens[i + 1]?.toLowerCase() if (option?.startsWith('batchmode')) { i += 1 continue } } if (lower.startsWith('-obatchmode')) { continue } next.push(token) } return next } function buildOpenSshBatchModeCommand(configuredCommand: string): string | null { if (containsShellExpansionSyntax(configuredCommand)) { return null } const tokens = shellTokenize(configuredCommand) if (!tokens || tokens.length === 0 || !isMergeableOpenSshCommand(tokens[0])) { return null } return [...withoutBatchModeOptions(tokens), '-o', 'BatchMode=yes'].map(shellQuoteToken).join(' ') } async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise<{ env: NodeJS.ProcessEnv mode: GitSshPolicyMode }> { const promptEnv = promptGuardGitEnv(options.env) if (promptEnv.GIT_SSH_COMMAND) { return { env: promptEnv, mode: 'explicit-env' } } const resolved = resolveCommand( 'git', ['config', '--get', 'core.sshCommand'], options.cwd, options.wslDistro, { useWslLoginShell: Boolean(options.wslDistro) } ) let configuredCommand = '' try { const { stdout } = await execFileCapture(resolved.binary, resolved.args, { cwd: resolved.cwd, encoding: 'utf-8', maxBuffer: DEFAULT_GIT_MAX_BUFFER, timeout: CORE_SSH_COMMAND_PROBE_TIMEOUT_MS, env: promptEnv, signal: options.signal }) configuredCommand = String(stdout).trim() } catch { configuredCommand = '' } if (!configuredCommand) { return { env: { ...promptEnv, GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }, mode: 'fallback' } } const batchModeCommand = buildOpenSshBatchModeCommand(configuredCommand) if (!batchModeCommand) { // Why: custom wrappers are executable user policy; rewriting their argv is // riskier than relying on prompt guards plus the caller's target timeout. return { env: promptEnv, mode: 'configured-wrapper-passthrough' } } return { env: { ...promptEnv, GIT_SSH_COMMAND: batchModeCommand }, mode: 'configured-openssh' } } /** * 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, options.wslDistro, { useWslLoginShell: Boolean(options.wslDistro) }) const policy = options.useConfiguredSshCommandForNetwork ? await buildNetworkSshPolicyEnv(options) : { env: nonInteractiveGitEnv(options.env), mode: 'default' as const } let result: { stdout: string | Buffer; stderr: string | Buffer } try { result = await execFileCapture(resolved.binary, resolved.args, { cwd: resolved.cwd, encoding: (options.encoding ?? 'utf-8') as BufferEncoding, maxBuffer: options.maxBuffer, timeout: options.timeout, stdin: options.stdin, // Why: never let a git read-path call block on an interactive prompt // (issue #5308) — fail fast instead of hanging the runtime. env: policy.env, signal: options.signal }) } catch (error) { if (options.useConfiguredSshCommandForNetwork && error && typeof error === 'object') { Object.assign(error, { gitSshPolicyMode: policy.mode }) } throw error } const { stdout, stderr } = result 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; wslDistro?: string } ): Promise<{ stdout: Buffer }> { const resolved = resolveCommand('git', args, options.cwd, options.wslDistro, { useWslLoginShell: Boolean(options.wslDistro) }) const { stdout } = (await execFileCapture(resolved.binary, resolved.args, { cwd: resolved.cwd, encoding: 'buffer', maxBuffer: options.maxBuffer, env: untranslatedGitOutputEnv() })) as { stdout: Buffer } return { stdout } } /** Result of a streamed git command. `stoppedEarly` is true when the caller's * onStdout hook asked to stop and the child was killed before exiting. */ export type GitStreamResult = { stoppedEarly: boolean } type GitStreamOptions = { cwd: string env?: NodeJS.ProcessEnv wslDistro?: string signal?: AbortSignal /** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */ maxBuffer?: number /** * Called for each decoded stdout chunk as it arrives. Return true to stop: * the child is killed and the promise resolves with stoppedEarly=true. This * lets a streaming parser bail out (e.g. once an entry limit is reached) * without ever buffering the full output. */ onStdout: (chunk: string) => boolean | void } /** * Stream a git command's stdout incrementally instead of buffering it whole. * * Why: status on a repo with an enormous un-ignored folder can emit more output * than fits in a single string, crashing the process when buffered. Streaming * lets the parser count entries as they arrive and stop git the moment a limit * is crossed, so memory stays bounded. Built on gitSpawn so WSL routing is * preserved. stderr is bounded; a non-zero exit rejects (unless we stopped it). */ export async function gitStreamStdout( args: string[], options: GitStreamOptions ): Promise { const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER return withGitSpan({ args, cwd: options.cwd }, async () => { return new Promise((resolve, reject) => { if (options.signal?.aborted) { reject(createAbortError()) return } const child = gitSpawn(args, { cwd: options.cwd, env: nonInteractiveGitEnv(options.env), stdio: ['ignore', 'pipe', 'pipe'], wslDistro: options.wslDistro, windowsHide: true }) let settled = false let stoppedEarly = false let stdoutBytes = 0 let stderr = '' let stderrBytes = 0 // Why: decode statefully so a multibyte UTF-8 character split across two // chunks (common with non-ASCII filenames) isn't corrupted into // replacement characters and mis-parsed. const stdoutDecoder = new StringDecoder('utf8') const stderrDecoder = new StringDecoder('utf8') const cleanup = (): void => { child.stdout?.off('data', onStdoutData) child.stderr?.off('data', onStderrData) child.off('error', onError) child.off('close', onClose) options.signal?.removeEventListener('abort', onAbort) // Flush any bytes the decoders were holding for an incomplete sequence. stdoutDecoder.end() stderrDecoder.end() } const finish = (error: Error | null): void => { if (settled) { return } settled = true cleanup() if (error) { reject(Object.assign(error, { stderr })) return } resolve({ stoppedEarly }) } function onStdoutData(chunk: Buffer): void { stdoutBytes += chunk.byteLength if (stdoutBytes > maxBuffer) { killSpawnedCommandTree(child) finish(new Error('git stdout exceeded maxBuffer.')) return } const decoded = stdoutDecoder.write(chunk) if (decoded.length === 0) { return } // Why: the parser callback is caller-supplied; a throw here would escape // the stream event handler and crash the main process (the exact failure // mode this streaming path exists to prevent). Convert it to a rejection. let shouldStop: boolean | void try { shouldStop = options.onStdout(decoded) } catch (error) { killSpawnedCommandTree(child) finish(error instanceof Error ? error : new Error(String(error))) return } if (shouldStop === true) { // Why: parser hit its limit. Kill git and resolve cleanly — the // partial output we already parsed is the intended result. stoppedEarly = true killSpawnedCommandTree(child) finish(null) } } function onStderrData(chunk: Buffer): void { stderrBytes += chunk.byteLength if (stderrBytes > maxBuffer) { killSpawnedCommandTree(child) finish(new Error('git stderr exceeded maxBuffer.')) return } stderr += stderrDecoder.write(chunk) } function onError(error: Error): void { finish(error) } function onClose(code: number | null): void { if (stoppedEarly || code === 0) { finish(null) return } finish(new Error(`git exited with ${code}: ${stderr}`)) } function onAbort(): void { killSpawnedCommandTree(child) finish(createAbortError()) } child.stdout?.on('data', onStdoutData) child.stderr?.on('data', onStderrData) child.on('error', onError) child.on('close', onClose) options.signal?.addEventListener('abort', onAbort, { once: true }) if (options.signal?.aborted) { onAbort() } }) }) } // Why: sync git calls run on the Electron main thread. Local git is normally // fast, but a repo on a dead network drive / cloud-placeholder path can hang // git on filesystem timeouts for minutes with no timeout set — the leading // explanation for issue #7225's 127s "Not Responding" freeze. Callers needing // longer operations should use the async runners instead. const GIT_EXEC_SYNC_TIMEOUT_MS = 15_000 /** * 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'] timeout?: number } ): string { const resolved = resolveCommand('git', args, options.cwd) const spawnStartedAt = performance.now() try { return execFileSync(resolved.binary, resolved.args, { cwd: resolved.cwd, encoding: options.encoding ?? 'utf-8', env: untranslatedGitOutputEnv(), stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'], timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS }) as string } finally { // Sync exec holds the main thread for its whole duration, so the entire // call is main-thread block time — the cost issue #7576 flags. recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) } } /** * Spawn a git child process. Drop-in replacement for * `spawn('git', args, { cwd, stdio, ... })`. */ export function gitSpawn( args: string[], options: SpawnOptions & { cwd: string; wslDistro?: string } ): ChildProcess { const { wslDistro, ...spawnOptions } = options const resolved = resolveCommand('git', args, options.cwd, wslDistro, { useWslLoginShell: Boolean(wslDistro) }) const spawnStartedAt = performance.now() const child = spawn(resolved.binary, resolved.args, { ...spawnOptions, env: untranslatedGitOutputEnv(spawnOptions.env ?? process.env), cwd: resolved.cwd }) recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) return child } // ─── 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 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 } /** * 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 const DEFAULT_GH_EXEC_TIMEOUT_MS = 30_000 async function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } function defaultGhExecTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { const raw = env.ORCA_GH_EXEC_TIMEOUT_MS if (!raw) { return DEFAULT_GH_EXEC_TIMEOUT_MS } const parsed = Number(raw) return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_GH_EXEC_TIMEOUT_MS } function nonInteractiveGhEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { return { ...env, GH_PROMPT_DISABLED: env.GH_PROMPT_DISABLED ?? '1' } } /** * 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 }> { // Why: while a bucket's primary rate limit is exhausted, every spawn would // return the same 403 — fail fast without paying the subprocess cost. The // rate_limit probe itself is exempt so the breaker can learn the reset time. const rateLimitBucket = classifyGhRateLimitBucket(args) if (!isGhRateLimitProbe(args)) { const blockedUntilMs = getGhRateLimitBlockedUntilMs(rateLimitBucket) if (blockedUntilMs !== null) { throw createGhRateLimitBlockedError(rateLimitBucket, blockedUntilMs) } } 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, // Why: GitHub detail IPC powers PR cards, Tasks, and URL worktree // creation; one stuck gh child must fail visibly, not wedge every lane. timeout: options.timeout ?? defaultGhExecTimeoutMs(options.env), env: nonInteractiveGhEnv(options.env) }) return { stdout: stdout as string, stderr: stderr as string } } catch (err) { lastError = err const { stderr } = extractExecError(err) if (isGhPrimaryRateLimitStderr(stderr)) { notifyGhPrimaryRateLimit(rateLimitBucket) } 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 allowDefaultWslFallback?: boolean } /** * Async glab CLI execution. Drop-in replacement for * `execFileAsync('glab', args, { cwd, encoding, ... })`. * * Retry policy mirrors ghExecFileAsync. */ /** * glab's `--hostname` flag rejects a host that carries a port * ("error parsing --hostname: invalid hostname"). A self-hosted GitLab on a * non-default port (e.g. `gitlab.example.com:8443`) must instead be selected * via the `GITLAB_HOST` env var, which accepts `host:port`. Translate any * `--hostname host:port` pair into `GITLAB_HOST` so every call site (`api`, * `auth status`, …) works against ported self-hosted instances. Port-less * `--hostname` values are left untouched. * * @internal exported for tests. */ export function redirectPortedHostnameToEnv( args: string[], options: GlabExecOptions ): { args: string[]; options: GlabExecOptions } { const i = args.indexOf('--hostname') if (i === -1 || i + 1 >= args.length) { return { args, options } } const host = args[i + 1] if (!/^[^/\s]+:\d+$/.test(host)) { return { args, options } } return { args: [...args.slice(0, i), ...args.slice(i + 2)], options: { ...options, env: { ...(options.env ?? process.env), GITLAB_HOST: host } } } } export async function glabExecFileAsync( args: string[], options: GlabExecOptions = {} ): Promise<{ stdout: string; stderr: string }> { ;({ args, options } = redirectPortedHostnameToEnv(args, options)) 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 && options.allowDefaultWslFallback !== false && 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; wslDistro?: string; useWslLoginShell?: boolean } ): ChildProcess { const { wslDistro, useWslLoginShell, ...spawnOptions } = options const resolved = resolveCommand(command, args, options.cwd, wslDistro, { useWslLoginShell }) const spawnStartedAt = performance.now() const child = spawn(resolved.binary, resolved.args, { ...spawnOptions, cwd: resolved.cwd }) recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) return child } // ─── 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, options: { wslDistro?: string } = {} ): string { const wsl = parseWslPath(originalCwd) const distro = wsl?.distro ?? options.wslDistro if (!distro) { 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, 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'