From 822087c8ec4fd2d9649bc4e065ad6c9070a16baa Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:34:11 -0700 Subject: [PATCH] refactor(git): split runner.ts into focused command-runner modules (#16395) * refactor(git): split runner.ts into focused command-runner modules * chore(ratchets): repoint child_process and wsl.exe allowlists at the split modules --------- Co-authored-by: Neil --- config/max-lines-baseline.txt | 1 - src/main/git/command-runner/abort-error.ts | 5 + .../git/command-runner/command-exec-file.ts | 68 + .../git/command-runner/exec-file-capture.ts | 198 ++ src/main/git/command-runner/gh-exec-file.ts | 175 ++ src/main/git/command-runner/gh-host-args.ts | 83 + src/main/git/command-runner/gh-idempotency.ts | 80 + .../git/command-runner/gh-retry-policy.ts | 65 + .../command-runner/git-command-resolution.ts | 107 + src/main/git/command-runner/git-exec-file.ts | 221 ++ .../git/command-runner/git-exec-options.ts | 17 + .../git/command-runner/git-process-env.ts | 74 + src/main/git/command-runner/git-spawn.ts | 50 + .../git/command-runner/git-ssh-policy-env.ts | 170 ++ .../git/command-runner/git-stream-stdout.ts | 220 ++ .../github-cli-host-fallback.ts | 93 + src/main/git/command-runner/glab-exec-file.ts | 111 + .../command-runner/spawn-command-capture.ts | 109 + .../spawned-command-tree-kill.ts | 53 + .../windows-host-git-environment.ts | 92 + .../git/command-runner/wsl-aware-spawn.ts | 26 + .../command-runner/wsl-command-resolution.ts | 186 ++ .../command-runner/wsl-path-translation.ts | 50 + src/main/git/runner.ts | 2197 +---------------- .../__fixtures__/wsl-invocation-allowlist.txt | 2 +- src/main/wsl/wsl-invocation-boundary.test.ts | 2 +- .../child-process-import-allowlist.txt | 12 +- 27 files changed, 2299 insertions(+), 2168 deletions(-) create mode 100644 src/main/git/command-runner/abort-error.ts create mode 100644 src/main/git/command-runner/command-exec-file.ts create mode 100644 src/main/git/command-runner/exec-file-capture.ts create mode 100644 src/main/git/command-runner/gh-exec-file.ts create mode 100644 src/main/git/command-runner/gh-host-args.ts create mode 100644 src/main/git/command-runner/gh-idempotency.ts create mode 100644 src/main/git/command-runner/gh-retry-policy.ts create mode 100644 src/main/git/command-runner/git-command-resolution.ts create mode 100644 src/main/git/command-runner/git-exec-file.ts create mode 100644 src/main/git/command-runner/git-exec-options.ts create mode 100644 src/main/git/command-runner/git-process-env.ts create mode 100644 src/main/git/command-runner/git-spawn.ts create mode 100644 src/main/git/command-runner/git-ssh-policy-env.ts create mode 100644 src/main/git/command-runner/git-stream-stdout.ts create mode 100644 src/main/git/command-runner/github-cli-host-fallback.ts create mode 100644 src/main/git/command-runner/glab-exec-file.ts create mode 100644 src/main/git/command-runner/spawn-command-capture.ts create mode 100644 src/main/git/command-runner/spawned-command-tree-kill.ts create mode 100644 src/main/git/command-runner/windows-host-git-environment.ts create mode 100644 src/main/git/command-runner/wsl-aware-spawn.ts create mode 100644 src/main/git/command-runner/wsl-command-resolution.ts create mode 100644 src/main/git/command-runner/wsl-path-translation.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 1bc578d1632..664bb4c28b3 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -17,7 +17,6 @@ inline src/main/codex-accounts/runtime-home-service.ts inline src/main/codex-accounts/service.ts inline src/main/codex/hook-service.ts inline src/main/daemon/daemon-init.ts -inline src/main/git/runner.ts inline src/main/git/worktree.ts inline src/main/github/project-view.ts inline src/main/index.ts diff --git a/src/main/git/command-runner/abort-error.ts b/src/main/git/command-runner/abort-error.ts new file mode 100644 index 00000000000..ca77430c986 --- /dev/null +++ b/src/main/git/command-runner/abort-error.ts @@ -0,0 +1,5 @@ +export function createAbortError(): Error { + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + return error +} diff --git a/src/main/git/command-runner/command-exec-file.ts b/src/main/git/command-runner/command-exec-file.ts new file mode 100644 index 00000000000..aa3e18a3a70 --- /dev/null +++ b/src/main/git/command-runner/command-exec-file.ts @@ -0,0 +1,68 @@ +import { isWindowsBatchScript, resolveWindowsCommand } from '../../win32-utils' +import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution' +import { execFileCapture } from './exec-file-capture' +import { spawnCommandCapture, type CommandExecOptions } from './spawn-command-capture' + +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) + ) +} + +/** + * 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 { wslDistro, ...execOptions } = options + const resolved = resolveCommand(command, args, options.cwd, wslDistro) + const binary = + resolved.wsl === null ? resolveWindowsCommand(resolved.binary, options.env) : resolved.binary + if (isWindowsBatchScript(binary)) { + return spawnCommandCapture(binary, resolved.args, { + ...execOptions, + cwd: resolved.cwd + }) + } + try { + const { stdout, stderr } = await execFileCapture(binary, resolved.args, { + cwd: resolved.cwd, + encoding: execOptions.encoding ?? 'utf-8', + maxBuffer: execOptions.maxBuffer, + timeout: execOptions.timeout, + env: execOptions.env, + signal: execOptions.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, + { + ...execOptions, + cwd: resolved.cwd + } + ) + } + throw error + } +} diff --git a/src/main/git/command-runner/exec-file-capture.ts b/src/main/git/command-runner/exec-file-capture.ts new file mode 100644 index 00000000000..96812284c34 --- /dev/null +++ b/src/main/git/command-runner/exec-file-capture.ts @@ -0,0 +1,198 @@ +import { execFile, type ChildProcess, type ExecFileOptions } from 'node:child_process' +import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe' +import { endSubprocessStdin } from '../../../shared/subprocess-stdin-write' +import { runProcess } from '../../../shared/child-process/run-process' +import type { WslProcessGroupTermination } from '../wsl-process-group-termination' +import { createAbortError } from './abort-error' +import { killSpawnedCommandTree } from './spawned-command-tree-kill' +import { DEFAULT_GIT_MAX_BUFFER } from './git-exec-options' + +type ExecFileCaptureOptions = Omit & { + timeout?: number + stdin?: string + terminationBarrier?: boolean +} + +const GIT_TERMINATION_BARRIER_FALLBACK_TIMEOUT_MS = 2_147_000_000 + +export async function execFileCaptureToTermination( + command: string, + args: string[], + options: ExecFileCaptureOptions, + termination?: WslProcessGroupTermination +): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> { + const result = await runProcess({ + program: command, + args, + cwd: typeof options.cwd === 'string' ? options.cwd : undefined, + env: options.env, + timeoutMs: options.timeout ?? GIT_TERMINATION_BARRIER_FALLBACK_TIMEOUT_MS, + maxOutputBytes: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER, + signal: options.signal, + terminationBarrier: termination ?? true, + ...(options.stdin === undefined ? {} : { input: options.stdin }) + }) + const stdout = options.encoding === 'buffer' ? Buffer.from(result.stdout) : result.stdout + const cleanStderr = termination?.stripControlOutput(result.stderr) ?? result.stderr + const stderr = options.encoding === 'buffer' ? Buffer.from(cleanStderr) : cleanStderr + if (result.code === 0 && !result.timedOut && !options.signal?.aborted) { + return { stdout, stderr } + } + const error = new Error( + result.timedOut + ? `${command} timed out.` + : options.signal?.aborted + ? 'The operation was aborted.' + : cleanStderr.trim() || `${command} exited with ${result.code}.` + ) + if (options.signal?.aborted) { + error.name = 'AbortError' + } + throw Object.assign(error, { + code: result.code, + killed: result.timedOut || result.signal !== null || options.signal?.aborted === true, + signal: result.signal, + stdout, + stderr + }) +} + +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 + ) +} + +export 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 terminating = 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 (settled || terminating) { + return + } + terminating = true + const abortError = createAbortError() + if (!child) { + terminating = false + finish(abortError) + return + } + void killSpawnedCommandTree(child).then(() => { + terminating = false + finish(abortError) + }) + } + + try { + const spawnStartedAt = performance.now() + // Why: our abort listener owns tree cleanup; Node's signal handler could kill wsl.exe before taskkill sees its children. + child = execFile( + command, + args, + { + cwd: options.cwd, + // Why: git.exe is console-subsystem and Orca's main process owns no + // console, so every spawn without this flashes a conhost that takes + // foreground. Git runs on nearly every interaction (#14543). + windowsHide: true, + encoding: options.encoding, + maxBuffer: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER, + env: options.env + }, + (error, stdout, stderr) => { + if (terminating) { + return + } + 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) => { + if (!terminating) { + finish(error) + } + }) + + if (options.stdin !== undefined) { + endSubprocessStdin(child.stdin, options.stdin) + } + + // Why: Node's timeout waits forever on signal-ignoring CLIs; enforce our own deadline with bounded tree cleanup. + if (options.timeout && options.timeout > 0) { + timer = setTimeout(() => { + if (settled || terminating) { + return + } + terminating = true + const timeoutError = new Error(`${command} timed out.`) + if (!child) { + terminating = false + finish(timeoutError) + return + } + void killSpawnedCommandTree(child).then(() => { + terminating = false + finish(timeoutError) + }) + }, options.timeout) + } + options.signal?.addEventListener('abort', onAbort, { once: true }) + }) +} diff --git a/src/main/git/command-runner/gh-exec-file.ts b/src/main/git/command-runner/gh-exec-file.ts new file mode 100644 index 00000000000..b8f13be5d5e --- /dev/null +++ b/src/main/git/command-runner/gh-exec-file.ts @@ -0,0 +1,175 @@ +import { + classifyGhRateLimitBucket, + createGhRateLimitBlockedError, + getGhRateLimitBlockedUntilMs, + ghRateLimitScopeKey, + isGhPrimaryRateLimitStderr, + isGhRateLimitProbe, + notifyGhPrimaryRateLimit, + type GhRateLimitBucket +} from '../gh-rate-limit-breaker' +import { extractExecError, parseRetryAfterMs } from '../exec-error' +import { + resolveCommand, + resolveDefaultWslCli, + type ResolvedCommand +} from './wsl-command-resolution' +import { + canFallBackToHostGitHubCli, + isHostCommandMissing, + resolveHostGitHubCli +} from './github-cli-host-fallback' +import { execFileCapture } from './exec-file-capture' +import type { GitExecOptions } from './git-exec-options' +import { argsLookIdempotent } from './gh-idempotency' +import { applyGhHostToArgs, explicitGhHostname, explicitGhRepoHostname } from './gh-host-args' +import { + defaultGhExecTimeoutMs, + isTransientGhError, + sleep, + GH_RETRY_AFTER_MAX_MS, + GH_RETRY_DELAYS_MS +} from './gh-retry-policy' + +// `cwd?` omitted for non-repo-scoped gh calls (rate_limit, listAccessibleProjects) so one WSL-aware wrapper serves both. +// `wslDistro?` routes global cwd-less gh through `wsl.exe -d ` on WSL-only Windows where gh.exe isn't on host PATH. +// `idempotent?` gates transient-error retry (auto-detected from argv); retrying a write that already reached GitHub would duplicate it. +export type GhExecOptions = Omit & { + cwd?: string + wslDistro?: string + idempotent?: boolean + // Why: `gh api` and `--repo OWNER/REPO` shorthand resolve against gh's + // default host, not the repo's remote. Carrying the host here lets the + // runner qualify every spawn once, so call sites can't silently fall back + // to github.com for GHES repos; it also scopes the rate-limit breaker. + host?: string +} + +function nonInteractiveGhEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return { + ...env, + GH_PROMPT_DISABLED: env.GH_PROMPT_DISABLED ?? '1' + } +} + +function ghRateLimitScope( + args: readonly string[], + options: GhExecOptions, + resolved: ResolvedCommand +): string { + const runtime = resolved.wsl ? `wsl:${resolved.wsl.distro.toLowerCase()}` : 'native' + // Why: an explicit argv hostname controls the actual gh request even when + // GH_HOST or options.host disagree, so breaker state must follow that host. + const host = + explicitGhHostname(args) ?? + options.host ?? + explicitGhRepoHostname(args) ?? + options.env?.GH_HOST ?? + process.env.GH_HOST ?? + 'github.com' + return ghRateLimitScopeKey(runtime, host) +} + +function assertGhRateLimitScopeAvailable( + args: readonly string[], + options: GhExecOptions, + resolved: ResolvedCommand, + bucket: GhRateLimitBucket, + exemptProbe: boolean +): void { + if (exemptProbe) { + return + } + const blockedUntilMs = getGhRateLimitBlockedUntilMs( + bucket, + Date.now(), + ghRateLimitScope(args, options, resolved) + ) + if (blockedUntilMs !== null) { + throw createGhRateLimitBlockedError(bucket, blockedUntilMs) + } +} + +/** + * 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; other errors fail fast. + */ +export async function ghExecFileAsync( + args: string[], + options: GhExecOptions = {} +): Promise<{ stdout: string; stderr: string }> { + // Why: retry safety must reflect the original call even when fallbacks replace the resolved command. + const idempotent = options.idempotent ?? argsLookIdempotent(args) + args = applyGhHostToArgs(args, options.host) + let resolved = resolveCommand('gh', args, options.cwd, options.wslDistro) + // Why: while a bucket is rate-limited every spawn returns 403 — fail fast; the probe is exempt so the breaker can learn the reset. + // Why: scope by runtime and host so unrelated github.com, GHES, and WSL quotas cannot block each other. + const rateLimitBucket = classifyGhRateLimitBucket(args) + const rateLimitProbe = isGhRateLimitProbe(args) + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) + 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: bound gh so one stuck child fails visibly instead of wedging the IPC lane. + timeout: options.timeout ?? defaultGhExecTimeoutMs(options.env), + env: nonInteractiveGhEnv(options.env), + signal: options.signal + }) + return { stdout: stdout as string, stderr: stderr as string } + } catch (err) { + lastError = err + const { stderr } = extractExecError(err) + if (isGhPrimaryRateLimitStderr(stderr)) { + notifyGhPrimaryRateLimit(rateLimitBucket, ghRateLimitScope(args, options, resolved)) + } + 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 host gh.exe, and global calls (rate_limit/auth) carry no cwd to route by. + resolved = wslResolved + attemptedDefaultWslFallback = true + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) + attempt = -1 + continue + } + } + if (!attemptedHostFallback && canFallBackToHostGitHubCli('gh', args, resolved, stderr)) { + resolved = resolveHostGitHubCli('gh', args) + attemptedHostFallback = true + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) + attempt = -1 + continue + } + const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length + if (idempotent && !isLastAttempt && isTransientGhError(stderr)) { + // Why: honor the server's Retry-After over our backoff (a shorter sleep just re-fails); cap so a huge hint can't stall IPC. + const retryAfterMs = parseRetryAfterMs(stderr) + const delayMs = + retryAfterMs !== null + ? Math.min(retryAfterMs, GH_RETRY_AFTER_MAX_MS) + : GH_RETRY_DELAYS_MS[attempt] + await sleep(delayMs, options.signal) + continue + } + throw err + } + } + // Unreachable: the loop either returns or throws. Here for TS exhaustiveness. + throw lastError +} diff --git a/src/main/git/command-runner/gh-host-args.ts b/src/main/git/command-runner/gh-host-args.ts new file mode 100644 index 00000000000..37191bab6ee --- /dev/null +++ b/src/main/git/command-runner/gh-host-args.ts @@ -0,0 +1,83 @@ +function hasGhHostnameFlag(args: readonly string[]): boolean { + return args.some((arg) => arg === '--hostname' || arg.startsWith('--hostname=')) +} + +function hostQualifiedGhRepoValue(value: string, host: string): string { + // URLs and already-qualified HOST/OWNER/REPO values pass through untouched. + if (value.includes('://') || value.split('/').length !== 2) { + return value + } + return `${host}/${value}` +} + +/** + * Host-qualify a gh invocation from `options.host`: `--hostname` for `api` + * calls, `HOST/OWNER/REPO` for `--repo`/`-R` shorthand. SSH-backed repos run + * gh with no cwd, so this is their only host signal (#8312). + * + * @internal exported for tests. + */ +export function applyGhHostToArgs(args: string[], host?: string): string[] { + if (!host) { + return args + } + let result = args + if (result[0] === 'api' && !hasGhHostnameFlag(result)) { + result = ['api', '--hostname', host, ...result.slice(1)] + } + // Why: bare OWNER/REPO shorthand resolves against gh's default host — GH_HOST + // when set — so github.com must be qualified too, not just GHES, or a + // process-level GH_HOST redirects pinned github.com commands. + // Combined short forms (`-Ra/b`, `-R=a/b`) are deliberately not rewritten: + // no call site uses them, and prefix-matching `-R` corrupts free-text values + // of other flags (e.g. a --title that happens to start with `-R`). + const qualified: string[] = [] + for (let i = 0; i < result.length; i += 1) { + const arg = result[i] + if (arg === '--repo' || arg === '-R') { + qualified.push(arg) + const value = result[i + 1] + if (value !== undefined) { + qualified.push(hostQualifiedGhRepoValue(value, host)) + i += 1 + } + continue + } + if (arg.startsWith('--repo=')) { + qualified.push(`--repo=${hostQualifiedGhRepoValue(arg.slice('--repo='.length), host)}`) + continue + } + qualified.push(arg) + } + return qualified +} + +export function explicitGhHostname(args: readonly string[]): string | undefined { + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--hostname') { + const value = args[i + 1]?.trim() + return value || undefined + } + if (args[i].startsWith('--hostname=')) { + const value = args[i].slice('--hostname='.length).trim() + return value || undefined + } + } + return undefined +} + +export function explicitGhRepoHostname(args: readonly string[]): string | undefined { + for (let i = 0; i < args.length; i += 1) { + let value: string | undefined + if (args[i] === '--repo' || args[i] === '-R') { + value = args[i + 1] + } else if (args[i].startsWith('--repo=')) { + value = args[i].slice('--repo='.length) + } + const parts = value?.trim().split('/') + if (parts?.length === 3 && parts.every(Boolean)) { + return parts[0] + } + } + return undefined +} diff --git a/src/main/git/command-runner/gh-idempotency.ts b/src/main/git/command-runner/gh-idempotency.ts new file mode 100644 index 00000000000..de24f11c33c --- /dev/null +++ b/src/main/git/command-runner/gh-idempotency.ts @@ -0,0 +1,80 @@ +const NON_IDEMPOTENT_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']) +// `gh ` write subcommands; reads are absent on purpose so they keep retrying. +const NON_IDEMPOTENT_GH_VERBS = new Set([ + 'create', + 'edit', + 'update', + 'delete', + 'close', + 'reopen', + 'merge', + 'comment', + 'review', + 'ready', + 'lock', + 'unlock', + 'pin', + 'unpin', + 'transfer', + 'develop' +]) + +export 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-POSTs when -f/-F/--field body fields are given without -X; track them. + 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 + } + // Detect GraphQL `query=mutation(…)` so endpoint writes 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 -X auto-POSTs → non-idempotent; GraphQL query bodies are the exception (still reads). + if ( + args[0] === 'api' && + hasApiBodyField && + !explicitMethodSeen && + !(isGraphQlApi && hasGraphQlQuery) + ) { + return false + } + // `gh ` writes (args[1]); `gh api` without -X defaults to idempotent GET, so it's excluded here. + if (args.length >= 2 && args[0] !== 'api') { + if (NON_IDEMPOTENT_GH_VERBS.has(args[1])) { + return false + } + } + return true +} diff --git a/src/main/git/command-runner/gh-retry-policy.ts b/src/main/git/command-runner/gh-retry-policy.ts new file mode 100644 index 00000000000..b3b06a1a88e --- /dev/null +++ b/src/main/git/command-runner/gh-retry-policy.ts @@ -0,0 +1,65 @@ +import { parseRetryAfterMs } from '../exec-error' +import { createAbortError } from './abort-error' + +/** + * Classify whether a gh execFile rejection is worth retrying. + * + * Why: gh surfaces HTTP status as stderr substrings ("HTTP 504", econnreset, …). + * Retry 5xx/network resets and 429 only without Retry-After (propagate those so + * the UI can show the wait); primary-rate-limit 403 is never 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: 3 attempts total (250ms → 1s backoff); array length defines retry count (total attempts = length + 1). +export const GH_RETRY_DELAYS_MS = [250, 1000] as const + +// Why: Retry-After is unbounded and untrusted; cap at 30s so a gh call can't block the IPC thread indefinitely. +export const GH_RETRY_AFTER_MAX_MS = 30_000 +const DEFAULT_GH_EXEC_TIMEOUT_MS = 30_000 + +export async function sleep(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) { + throw createAbortError() + } + await new Promise((resolve, reject) => { + const timer = setTimeout(finish, ms) + const onAbort = (): void => finish(createAbortError()) + function finish(error?: Error): void { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + if (error) { + reject(error) + } else { + resolve() + } + } + signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + +export 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 +} diff --git a/src/main/git/command-runner/git-command-resolution.ts b/src/main/git/command-runner/git-command-resolution.ts new file mode 100644 index 00000000000..9c53658c159 --- /dev/null +++ b/src/main/git/command-runner/git-command-resolution.ts @@ -0,0 +1,107 @@ +import { parseWslPath } from '../../wsl' +import { isWslDirectGitReadCommand } from '../wsl-direct-git-read-commands' +import { + disableWslGitReadEnvironment, + getWslGitReadEnvironment, + invalidateWslGitReadEnvironment, + peekWslGitReadEnvironment +} from '../wsl-git-read-environment' +import { usesHostGitForWslLinkedWorktree } from '../wsl-linked-worktree-git-routing' +import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution' +import type { GitExecOptions } from './git-exec-options' + +function wslDistroForCommand(cwd: string | undefined, override?: string): string | null { + if (process.platform !== 'win32') { + return null + } + return (cwd ? parseWslPath(cwd)?.distro : undefined) ?? override ?? null +} + +export function resolveGitCommand( + args: string[], + options: GitExecOptions, + forceLoginShell = false, + captureLoginShellOutput = false +): ResolvedCommand { + if (usesHostGitForWslLinkedWorktree(options.cwd, options.wslDistro)) { + // Why: WSL Git resolves a Windows-authored linked-worktree pointer relative to cwd. + return { binary: 'git', args, cwd: options.cwd, wsl: null, wslMode: null } + } + if (!forceLoginShell && shouldAttemptWslDirectGit(args, options)) { + const distro = wslDistroForCommand(options.cwd, options.wslDistro) + const environment = distro ? peekWslGitReadEnvironment(distro) : undefined + if (environment) { + return resolveCommand('git', args, options.cwd, options.wslDistro, { + wslGitReadEnvironment: environment, + env: options.env, + terminationBarrier: options.terminationBarrier + }) + } + if (distro) { + void getWslGitReadEnvironment(distro) + } + } + return resolveGitCommandWithoutProbe(args, options, captureLoginShellOutput) +} + +function shouldAttemptWslDirectGit(args: string[], options: GitExecOptions): boolean { + return Boolean( + process.platform === 'win32' && + // Why either: callers can still opt in explicitly, but a plain read no + // longer has to -- it needs nothing the login shell provides, and routing + // it through one is what exposes callers to the shell's rc output. + (options.preferWslDirectGit || isWslDirectGitReadCommand(args)) && + !options.useConfiguredSshCommandForNetwork && + !Object.entries(options.env ?? {}).some( + ([key, value]) => + key.startsWith('GIT_') && key !== 'GIT_OPTIONAL_LOCKS' && value !== process.env[key] + ) && + options.wslDistro + ) +} + +export function resolveGitCommandWithoutProbe( + args: string[], + options: GitExecOptions, + captureLoginShellOutput = false +): ResolvedCommand { + return resolveCommand('git', args, options.cwd, options.wslDistro, { + useWslLoginShell: Boolean(options.wslDistro), + captureLoginShellOutput, + terminationBarrier: options.terminationBarrier + }) +} + +function isDirectWslGitNotFound(error: unknown, resolved: ResolvedCommand): boolean { + if (resolved.wslMode !== 'direct-git' || !error || typeof error !== 'object') { + return false + } + const { code, stderr } = error as { code?: unknown; stderr?: unknown } + const message = typeof stderr === 'string' ? stderr : String(stderr ?? '') + return code === 127 && (message.includes('not found') || message.includes('No such file')) +} + +export function directWslGitExitCode(error: unknown, resolved: ResolvedCommand): number | null { + if (resolved.wslMode !== 'direct-git' || !error || typeof error !== 'object') { + return null + } + const code = (error as { code?: unknown }).code + return typeof code === 'number' ? code : null +} + +export function invalidateMissingDirectWslGit(error: unknown, resolved: ResolvedCommand): boolean { + const isMissing = isDirectWslGitNotFound(error, resolved) + if (isMissing && resolved.wsl) { + invalidateWslGitReadEnvironment(resolved.wsl.distro) + } + return isMissing +} + +export function disableDirectWslGitAfterSuccessfulFallback( + wasMissing: boolean, + resolved: ResolvedCommand +): void { + if (!wasMissing && resolved.wsl) { + disableWslGitReadEnvironment(resolved.wsl.distro) + } +} diff --git a/src/main/git/command-runner/git-exec-file.ts b/src/main/git/command-runner/git-exec-file.ts new file mode 100644 index 00000000000..c2815c4bb4c --- /dev/null +++ b/src/main/git/command-runner/git-exec-file.ts @@ -0,0 +1,221 @@ +import { execFileSync, type SpawnOptions } from 'node:child_process' +import { withGitSpan } from '../../observability/instrumentation' +import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe' +import { + resolveGitFetchHeadCommand, + runWithGitFetchHeadLock +} from '../../../shared/git-fetch-head-lock' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting +} from '../wsl-linked-worktree-git-routing' +import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution' +import type { GitExecOptions } from './git-exec-options' +import { execFileCapture, execFileCaptureToTermination } from './exec-file-capture' +import { + directWslGitExitCode, + disableDirectWslGitAfterSuccessfulFallback, + invalidateMissingDirectWslGit, + resolveGitCommand +} from './git-command-resolution' +import { prepareWindowsHostGitEnvironment } from './windows-host-git-environment' +import { buildNetworkSshPolicyEnv } from './git-ssh-policy-env' +import { nonInteractiveGitEnv, untranslatedGitOutputEnv } from './git-process-env' + +/** + * Async git command execution. Drop-in replacement for + * `execFileAsync('git', args, { cwd, encoding, ... })`. + */ +async function gitExecFileAsyncUnlocked( + args: string[], + options: GitExecOptions +): Promise<{ stdout: string; stderr: string }> { + // Why: span the user-visible `git ` form, not the resolved binary, so dashboards group by intent. + return withGitSpan( + { args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) }, + async () => { + if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { + signal: options.signal + }) + } + let resolved = resolveGitCommand(args, options, false, options.captureWslLoginShellOutput) + const environmentReady = prepareWindowsHostGitEnvironment( + resolved, + options.env, + options.signal + ) + const env = environmentReady ? await environmentReady : options.env + const effectiveOptions = env === options.env ? options : { ...options, env } + resolved = resolveGitCommand( + args, + effectiveOptions, + false, + effectiveOptions.captureWslLoginShellOutput + ) + const policy = effectiveOptions.useConfiguredSshCommandForNetwork + ? await buildNetworkSshPolicyEnv(effectiveOptions) + : { env: nonInteractiveGitEnv(effectiveOptions.env), mode: 'default' as const } + const capture = ( + command: ResolvedCommand + ): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> => { + const captureOptions = { + cwd: command.cwd, + encoding: (options.encoding ?? 'utf-8') as BufferEncoding, + maxBuffer: options.maxBuffer, + timeout: options.timeout, + stdin: options.stdin, + env: policy.env, + signal: options.signal, + terminationBarrier: options.terminationBarrier + } + return options.terminationBarrier + ? execFileCaptureToTermination( + command.binary, + command.args, + captureOptions, + command.termination + ) + : execFileCapture(command.binary, command.args, captureOptions) + } + let result: { stdout: string | Buffer; stderr: string | Buffer } + try { + result = await capture(resolved) + } catch (error) { + if (directWslGitExitCode(error, resolved) !== null && !options.signal?.aborted) { + const wasMissing = invalidateMissingDirectWslGit(error, resolved) + const fallback = resolveGitCommand( + args, + effectiveOptions, + true, + effectiveOptions.captureWslLoginShellOutput + ) + result = await capture(fallback) + // Why: matching failures can be normal Git control flow; only a successful login retry proves the direct environment was insufficient. + disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved) + const { stdout, stderr } = result + return { + stdout: readCapturedGitString(stdout as string, fallback), + stderr: stderr as string + } + } + if (options.useConfiguredSshCommandForNetwork && error && typeof error === 'object') { + Object.assign(error, { gitSshPolicyMode: policy.mode }) + } + throw error + } + const { stdout, stderr } = result + return { stdout: readCapturedGitString(stdout as string, resolved), stderr: stderr as string } + } + ) +} + +export function gitExecFileAsync( + args: string[], + options: GitExecOptions +): Promise<{ stdout: string; stderr: string }> { + const run = () => gitExecFileAsyncUnlocked(args, options) + const command = resolveGitFetchHeadCommand(args, options.cwd) + return command.needsLock + ? runWithGitFetchHeadLock(command.cwd, options.signal, run, command.gitDir) + : run() +} + +/** + * 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 }> { + if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro) + } + // `git show` is a read, so this normally runs with no shell at all. The fence + // still matters for the login-shell fallback: these are raw blob bytes going + // straight to the diff/blob viewer, where a banner becomes file content. + let resolved = resolveGitCommand(args, options, false, true) + const environmentReady = prepareWindowsHostGitEnvironment(resolved, undefined) + if (environmentReady) { + await environmentReady + } + resolved = resolveGitCommand(args, options, false, true) + const { stdout } = (await execFileCapture(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: 'buffer', + maxBuffer: options.maxBuffer, + env: untranslatedGitOutputEnv() + })) as { stdout: Buffer } + return { stdout: readCapturedGitBuffer(stdout, resolved) } +} + +/** + * Slice a fenced payload out of raw bytes. + * + * Why bytes: blob content may be binary, so decoding to a string to find the + * fence would corrupt it. Returns the buffer untouched when the command was not + * fenced or the fence is absent. + */ +function readCapturedGitBuffer(stdout: Buffer, resolved: ResolvedCommand): Buffer { + const captured = resolved.captured + if (!captured) { + return stdout + } + const beginIndex = stdout.lastIndexOf(captured.beginMarker, undefined, 'utf8') + if (beginIndex === -1) { + return stdout + } + const payloadStart = beginIndex + Buffer.byteLength(captured.beginMarker, 'utf8') + const endIndex = stdout.indexOf(captured.endMarker, payloadStart, 'utf8') + return endIndex === -1 ? stdout.subarray(payloadStart) : stdout.subarray(payloadStart, endIndex) +} + +function readCapturedGitString(stdout: string, resolved: ResolvedCommand): string { + const captured = resolved.captured + if (!captured) { + return stdout + } + const beginIndex = stdout.lastIndexOf(captured.beginMarker) + if (beginIndex === -1) { + return stdout + } + const payloadStart = beginIndex + captured.beginMarker.length + const endIndex = stdout.indexOf(captured.endMarker, payloadStart) + return endIndex === -1 ? stdout.slice(payloadStart) : stdout.slice(payloadStart, endIndex) +} + +// Why: sync git blocks the main thread; a dead network drive can hang git for minutes without a timeout (issue #7225's 127s freeze). +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, + windowsHide: true + }) as string + } finally { + // Sync exec blocks the main thread for its whole duration — the cost issue #7576 flags. + recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) + } +} diff --git a/src/main/git/command-runner/git-exec-options.ts b/src/main/git/command-runner/git-exec-options.ts new file mode 100644 index 00000000000..1055721475c --- /dev/null +++ b/src/main/git/command-runner/git-exec-options.ts @@ -0,0 +1,17 @@ +// Why: cap execFile output to prevent an uncatchable V8 string overflow; match relay MAX_GIT_BUFFER. +export const DEFAULT_GIT_MAX_BUFFER = 10 * 1024 * 1024 + +export type GitExecOptions = { + cwd: string + encoding?: BufferEncoding | 'buffer' + maxBuffer?: number + timeout?: number + stdin?: string + env?: NodeJS.ProcessEnv + signal?: AbortSignal + wslDistro?: string + preferWslDirectGit?: boolean + useConfiguredSshCommandForNetwork?: boolean + terminationBarrier?: boolean + captureWslLoginShellOutput?: boolean +} diff --git a/src/main/git/command-runner/git-process-env.ts b/src/main/git/command-runner/git-process-env.ts new file mode 100644 index 00000000000..6154364e5ab --- /dev/null +++ b/src/main/git/command-runner/git-process-env.ts @@ -0,0 +1,74 @@ +import { addWslEnvKeys } from '../../wsl-env' +import { + appendGitConfigEnv, + gitCredentialPromptGuardEnv +} from '../../../shared/git-credential-prompt-env' +import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../../shared/git-output-locale' + +export function gitOptionalLocksDisabledEnv( + env: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv { + return { + ...env, + GIT_OPTIONAL_LOCKS: '0' + } +} + +/** + * Append git config via the GIT_CONFIG_COUNT/KEY_n/VALUE_n env protocol (git >= 2.31), + * composing with any count already in `env` so we never clobber a caller's config. + */ +export { appendGitConfigEnv } + +/** + * Pin Orca-spawned git to untranslated English output so stderr/progress parsers + * work under any user locale (issue #7808). Terminal git is untouched. + */ +export function untranslatedGitOutputEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return { ...env, ...UNTRANSLATED_GIT_OUTPUT_ENV } +} + +export function promptGuardGitEnv( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): NodeJS.ProcessEnv { + return gitCredentialPromptGuardEnv(untranslatedGitOutputEnv(env), platform) +} + +/** + * Credential-prompt guard for a general-purpose shell (PTYs, hook scripts): + * like promptGuardGitEnv but without the issue-7808 locale pins, which 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 { + return gitCredentialPromptGuardEnv(env, platform) +} + +/** + * Force git non-interactive so it fails fast instead of hanging on a prompt with + * no terminal to answer it; on headless `serve` those stuck calls wedge every + * client (issue #5308). + * + * - GIT_TERMINAL_PROMPT=0: git errors instead of prompting for credentials. + * - GIT_ASKPASS / SSH_ASKPASS: emptied when unset so no GUI helper blocks; a + * caller-provided askpass is preserved (custom setups serve creds non-interactively). + * - GIT_SSH_COMMAND BatchMode=yes: SSH errors instead of prompting (doesn't change + * host trust); only added when the caller hasn't set its own. + */ +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 GIT_SSH_COMMAND to WSL only when we set it — a caller's Windows-specific value must not leak into Linux git. + addWslEnvKeys(next, ['GIT_SSH_COMMAND']) + } + } + return next +} diff --git a/src/main/git/command-runner/git-spawn.ts b/src/main/git/command-runner/git-spawn.ts new file mode 100644 index 00000000000..47d761b9964 --- /dev/null +++ b/src/main/git/command-runner/git-spawn.ts @@ -0,0 +1,50 @@ +import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' +import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe' +import { createAbortError } from './abort-error' +import { resolveGitCommand } from './git-command-resolution' +import { untranslatedGitOutputEnv } from './git-process-env' +import { prepareWindowsHostGitEnvironment } from './windows-host-git-environment' + +/** + * Spawn a git child process. Drop-in replacement for + * `spawn('git', args, { cwd, stdio, ... })`. + */ +export type GitSpawnOptions = SpawnOptions & { cwd: string; wslDistro?: string } + +export async function gitSpawnAfterWindowsEnvironmentReady( + args: string[], + options: GitSpawnOptions +): Promise { + if (options.signal?.aborted) { + throw createAbortError() + } + const resolved = resolveGitCommand(args, { + cwd: options.cwd, + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.env ? { env: options.env } : {}) + }) + const env = await (prepareWindowsHostGitEnvironment(resolved, options.env, options.signal) ?? + options.env) + if (options.signal?.aborted) { + throw createAbortError() + } + return gitSpawn(args, env === options.env ? options : { ...options, env }) +} + +export function gitSpawn(args: string[], options: GitSpawnOptions): ChildProcess { + const { wslDistro, ...spawnOptions } = options + const resolved = resolveGitCommand(args, { + cwd: options.cwd, + ...(wslDistro ? { wslDistro } : {}), + ...(spawnOptions.env ? { env: spawnOptions.env } : {}) + }) + const spawnStartedAt = performance.now() + const child = spawn(resolved.binary, resolved.args, { + ...spawnOptions, + env: untranslatedGitOutputEnv(spawnOptions.env ?? process.env), + windowsHide: true, + cwd: resolved.cwd + }) + recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) + return child +} diff --git a/src/main/git/command-runner/git-ssh-policy-env.ts b/src/main/git/command-runner/git-ssh-policy-env.ts new file mode 100644 index 00000000000..faece9ab141 --- /dev/null +++ b/src/main/git/command-runner/git-ssh-policy-env.ts @@ -0,0 +1,170 @@ +import { addWslEnvKeys } from '../../wsl-env' +import { quotePosixShell } from '../../../shared/wsl-login-shell-command' +import { execFileCapture } from './exec-file-capture' +import { resolveGitCommand } from './git-command-resolution' +import { DEFAULT_GIT_MAX_BUFFER, type GitExecOptions } from './git-exec-options' +import { promptGuardGitEnv } from './git-process-env' + +export 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(' ') +} + +export 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' } + } + + // Why fenced: a login-shell banner here reads as a user-configured sshCommand, + // which skips the BatchMode fallback below and disarms the no-prompt guard. + const resolved = resolveGitCommand(['config', '--get', 'core.sshCommand'], options, true, true) + 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 + }) + const payload = resolved.captured?.readStdout(String(stdout)) ?? String(stdout) + configuredCommand = payload.trim() + } catch { + configuredCommand = '' + } + + if (!configuredCommand) { + const env = { ...promptEnv, GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' } + // Why: WSL routing can come from either an explicit distro or a UNC cwd. + if (resolved.wsl) { + addWslEnvKeys(env, ['GIT_SSH_COMMAND']) + } + return { env, mode: 'fallback' } + } + + const batchModeCommand = buildOpenSshBatchModeCommand(configuredCommand) + if (!batchModeCommand) { + // Why: custom SSH wrappers are user policy; rewriting their argv is riskier than relying on prompt guards + timeout. + return { env: promptEnv, mode: 'configured-wrapper-passthrough' } + } + + const env = { ...promptEnv, GIT_SSH_COMMAND: batchModeCommand } + if (resolved.wsl) { + addWslEnvKeys(env, ['GIT_SSH_COMMAND']) + } + return { env, mode: 'configured-openssh' } +} diff --git a/src/main/git/command-runner/git-stream-stdout.ts b/src/main/git/command-runner/git-stream-stdout.ts new file mode 100644 index 00000000000..318be527c41 --- /dev/null +++ b/src/main/git/command-runner/git-stream-stdout.ts @@ -0,0 +1,220 @@ +import { spawn, type ChildProcess, 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 { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting +} from '../wsl-linked-worktree-git-routing' +import { createAbortError } from './abort-error' +import { killSpawnedCommandTree } from './spawned-command-tree-kill' +import type { ResolvedCommand } from './wsl-command-resolution' +import { DEFAULT_GIT_MAX_BUFFER, type GitExecOptions } from './git-exec-options' +import { + directWslGitExitCode, + disableDirectWslGitAfterSuccessfulFallback, + invalidateMissingDirectWslGit, + resolveGitCommand, + resolveGitCommandWithoutProbe +} from './git-command-resolution' +import { prepareWindowsHostGitEnvironment } from './windows-host-git-environment' +import { nonInteractiveGitEnv, untranslatedGitOutputEnv } from './git-process-env' +import { gitSpawn } from './git-spawn' + +/** Result of a streamed git command; `stoppedEarly` is true when onStdout asked to stop before the child exited. */ +export type GitStreamResult = { stoppedEarly: boolean } + +export type GitStreamOptions = { + cwd: string + env?: NodeJS.ProcessEnv + wslDistro?: string + preferWslDirectGit?: boolean + signal?: AbortSignal + /** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */ + maxBuffer?: number + /** + * Called for each decoded stdout chunk. Return true to stop: the child is + * killed and the promise resolves with stoppedEarly=true. + */ + onStdout: (chunk: string) => boolean | void +} + +/** + * Stream a git command's stdout incrementally instead of buffering it whole. + * + * Why: output larger than V8's max string (e.g. status on a repo with a huge + * un-ignored folder) crashes the process when buffered; streaming keeps memory + * bounded and lets the parser stop git early. Built on gitSpawn for WSL routing. + */ +export async function gitStreamStdout( + args: string[], + options: GitStreamOptions +): Promise { + const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER + return withGitSpan({ args, cwd: options.cwd }, async () => { + if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { + signal: options.signal + }) + } + const gitOptions: GitExecOptions = { + cwd: options.cwd, + ...(options.env ? { env: options.env } : {}), + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.preferWslDirectGit ? { preferWslDirectGit: true } : {}), + ...(options.signal ? { signal: options.signal } : {}) + } + let resolved = resolveGitCommand(args, gitOptions) + const environmentReady = prepareWindowsHostGitEnvironment( + resolved, + gitOptions.env, + options.signal + ) + if (environmentReady) { + gitOptions.env = await environmentReady + } + resolved = resolveGitCommand(args, gitOptions) + const stream = (command: ResolvedCommand): Promise => + new Promise((resolve, reject) => { + if (options.signal?.aborted) { + reject(createAbortError()) + return + } + const stdio: SpawnOptions['stdio'] = ['ignore', 'pipe', 'pipe'] + const spawnOptions = { + cwd: options.cwd, + env: nonInteractiveGitEnv(gitOptions.env), + stdio, + wslDistro: options.wslDistro, + windowsHide: true + } + let child: ChildProcess + if (command.wslMode === 'direct-git') { + const spawnStartedAt = performance.now() + child = spawn(command.binary, command.args, { + cwd: command.cwd, + env: untranslatedGitOutputEnv(spawnOptions.env), + stdio: spawnOptions.stdio, + windowsHide: true + }) + recordSubprocessSpawn(command.binary, command.args, performance.now() - spawnStartedAt) + } else { + child = gitSpawn(args, spawnOptions) + } + + let settled = false + let stoppedEarly = false + let stdoutBytes = 0 + let stderr = '' + let stderrBytes = 0 + // Why: decode statefully so a multibyte UTF-8 char split across chunks isn't corrupted into replacement chars. + 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, stdoutBytes })) + return + } + resolve({ stoppedEarly }) + } + + function onStdoutData(chunk: Buffer): void { + stdoutBytes += chunk.byteLength + if (stdoutBytes > maxBuffer) { + void killSpawnedCommandTree(child) + finish(new Error('git stdout exceeded maxBuffer.')) + return + } + const decoded = stdoutDecoder.write(chunk) + if (decoded.length === 0) { + return + } + // Why: a throw from the caller's parser would escape this event handler and crash main; convert to a rejection. + let shouldStop: boolean | void + try { + shouldStop = options.onStdout(decoded) + } catch (error) { + void killSpawnedCommandTree(child) + finish(error instanceof Error ? error : new Error(String(error))) + return + } + if (shouldStop === true) { + // Parser hit its limit: kill git and resolve cleanly with the partial output. + stoppedEarly = true + void killSpawnedCommandTree(child) + finish(null) + } + } + function onStderrData(chunk: Buffer): void { + stderrBytes += chunk.byteLength + if (stderrBytes > maxBuffer) { + void 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(Object.assign(new Error(`git exited with ${code}: ${stderr}`), { code })) + } + function onAbort(): void { + if (!child.pid) { + // Why: failed spawn reports ENOENT after abort cleanup; retain a listener so it cannot crash main. + child.once('error', () => {}) + } + 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() + } + }) + try { + return await stream(resolved) + } catch (error) { + const stdoutBytes = + error && typeof error === 'object' ? (error as { stdoutBytes?: unknown }).stdoutBytes : null + if ( + stdoutBytes === 0 && + directWslGitExitCode(error, resolved) !== null && + !options.signal?.aborted + ) { + const wasMissing = invalidateMissingDirectWslGit(error, resolved) + resolved = resolveGitCommandWithoutProbe(args, gitOptions) + const result = await stream(resolved) + disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved) + return result + } + throw error + } + }) +} diff --git a/src/main/git/command-runner/github-cli-host-fallback.ts b/src/main/git/command-runner/github-cli-host-fallback.ts new file mode 100644 index 00000000000..14cdec84dea --- /dev/null +++ b/src/main/git/command-runner/github-cli-host-fallback.ts @@ -0,0 +1,93 @@ +import type { ResolvedCommand } from './wsl-command-resolution' + +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 hasExplicitRepoViewTarget(args: string[]): boolean { + const target = args[2] + return ( + args[0] === 'repo' && + args[1] === 'view' && + typeof target === 'string' && + !target.startsWith('-') && + target.includes('/') + ) +} + +function canRunGitHubCliWithoutRepoCwd(args: string[]): boolean { + if (hasExplicitRepoArg(args)) { + return true + } + if (args[0] === 'api') { + return !argsUseGhApiPlaceholders(args) + } + return args[0] === 'auth' || hasExplicitRepoViewTarget(args) +} + +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`) +} + +export function canFallBackToHostGitHubCli( + command: 'gh', + args: string[], + resolved: ResolvedCommand, + stderr: string +): boolean { + return ( + process.platform === 'win32' && + resolved.wsl !== null && + isMissingCommandInWsl(stderr, command) && + canRunGitHubCliWithoutRepoCwd(args) + ) +} + +export function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand { + return { + binary: command, + args, + // Why: host gh can't use a WSL UNC cwd; we only fall back for commands with explicit repo/API context, so none is needed. + cwd: undefined, + wsl: null, + wslMode: null + } +} + +export 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') + ) +} diff --git a/src/main/git/command-runner/glab-exec-file.ts b/src/main/git/command-runner/glab-exec-file.ts new file mode 100644 index 00000000000..f0be8bb992b --- /dev/null +++ b/src/main/git/command-runner/glab-exec-file.ts @@ -0,0 +1,111 @@ +import { addWslEnvKeys } from '../../wsl-env' +import { extractExecError, parseRetryAfterMs } from '../exec-error' +import { resolveCommand, resolveDefaultWslCli } from './wsl-command-resolution' +import { isHostCommandMissing } from './github-cli-host-fallback' +import { execFileCapture } from './exec-file-capture' +import type { GitExecOptions } from './git-exec-options' +import { argsLookIdempotent } from './gh-idempotency' +import { + isTransientGhError, + sleep, + GH_RETRY_AFTER_MAX_MS, + GH_RETRY_DELAYS_MS +} from './gh-retry-policy' + +// Why: cloned from the gh runner rather than abstracted behind a generic runner, to avoid touching the working gh path. + +export type GlabExecOptions = Omit & { + cwd?: string + wslDistro?: string + idempotent?: boolean + allowDefaultWslFallback?: boolean +} + +/** Async glab CLI execution; drop-in for execFileAsync('glab', …). Retry policy mirrors ghExecFileAsync. */ +/** + * glab's `--hostname` rejects host:port, so a ported self-hosted GitLab must use the GITLAB_HOST env var instead — translate it. + * @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 } + } + // Why WSLENV: a glab routed into a distro only sees Windows-side variables + // named in WSLENV, so without this the ported host silently never crosses and + // glab talks to gitlab.com instead (#12557). Credit: #12558. + const env: NodeJS.ProcessEnv = { ...(options.env ?? process.env), GITLAB_HOST: host } + // Unguarded by platform on purpose: WSLENV is meaningless outside Windows, so + // the extra key is inert there, and gating it would need the test to know the + // platform for no behavioural gain. + addWslEnvKeys(env, ['GITLAB_HOST']) + return { + args: [...args.slice(0, i), ...args.slice(i + 2)], + options: { ...options, env } + } +} + +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, + signal: options.signal + }) + 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 — don't auto-retry a non-idempotent write that GitLab may already have applied. + 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 +} diff --git a/src/main/git/command-runner/spawn-command-capture.ts b/src/main/git/command-runner/spawn-command-capture.ts new file mode 100644 index 00000000000..8d804e10f3e --- /dev/null +++ b/src/main/git/command-runner/spawn-command-capture.ts @@ -0,0 +1,109 @@ +import { spawn } from 'node:child_process' +import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe' +import { getSpawnArgsForWindows } from '../../win32-utils' +import { createAbortError } from './abort-error' +import { killSpawnedCommandTree } from './spawned-command-tree-kill' + +export type CommandExecOptions = { + cwd?: string + encoding?: BufferEncoding + maxBuffer?: number + timeout?: number + env?: NodeJS.ProcessEnv + signal?: AbortSignal + wslDistro?: string +} + +export 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 => { + 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(() => { + void 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) { + void 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) { + void 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) + }) +} diff --git a/src/main/git/command-runner/spawned-command-tree-kill.ts b/src/main/git/command-runner/spawned-command-tree-kill.ts new file mode 100644 index 00000000000..c2fefcba1bc --- /dev/null +++ b/src/main/git/command-runner/spawned-command-tree-kill.ts @@ -0,0 +1,53 @@ +import { spawn, type ChildProcess } from 'node:child_process' + +const WINDOWS_TREE_KILL_WAIT_MS = 2_000 + +export function killSpawnedCommandTree(child: ChildProcess): Promise { + const pid = child.pid + if (!pid || process.platform !== 'win32') { + child.kill() + return Promise.resolve() + } + return new Promise((resolve) => { + let killer: ChildProcess + try { + // Why: Windows shims/wsl.exe own descendants; wait for /t tree cleanup so a timed-out command can't outlive its probe. + killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true + }) + if (!killer || typeof killer.unref !== 'function') { + child.kill() + resolve() + return + } + } catch { + child.kill() + resolve() + return + } + let settled = false + let timer: NodeJS.Timeout | null = null + const finish = (fallbackToChildKill: boolean): void => { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + } + killer.removeAllListeners() + if (fallbackToChildKill) { + child.kill() + } + resolve() + } + killer.once('error', () => finish(true)) + killer.once('close', (code) => finish(code !== 0)) + timer = setTimeout(() => { + killer.kill() + finish(true) + }, WINDOWS_TREE_KILL_WAIT_MS) + killer.unref() + }) +} diff --git a/src/main/git/command-runner/windows-host-git-environment.ts b/src/main/git/command-runner/windows-host-git-environment.ts new file mode 100644 index 00000000000..b67a4a16e67 --- /dev/null +++ b/src/main/git/command-runner/windows-host-git-environment.ts @@ -0,0 +1,92 @@ +import { createAbortError } from './abort-error' +import { resolveGitCommand } from './git-command-resolution' +import type { ResolvedCommand } from './wsl-command-resolution' + +let waitForWindowsHostGitEnvironment: (() => Promise) | null = null + +export function configureWindowsHostGitEnvironmentReadiness( + waitUntilReady: (() => Promise) | null +): void { + waitForWindowsHostGitEnvironment = waitUntilReady +} + +export async function awaitWindowsHostGitEnvironmentReady(options: { + cwd: string + wslDistro?: string + signal?: AbortSignal +}): Promise { + const resolved = resolveGitCommand(['--version'], options) + await prepareWindowsHostGitEnvironment(resolved, undefined, options.signal) +} + +function refreshWindowsHostPath(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv | undefined { + if (!env) { + return undefined + } + const currentPath = process.env.Path ?? process.env.PATH + if (currentPath === undefined) { + return env + } + const next = { ...env } + const pathKeys = Object.keys(next).filter((key) => key.toLowerCase() === 'path') + if (pathKeys.length === 0) { + next[process.env.Path === undefined ? 'PATH' : 'Path'] = currentPath + } else { + for (const key of pathKeys) { + next[key] = currentPath + } + } + return next +} + +export function prepareWindowsHostGitEnvironment( + resolved: ResolvedCommand, + env: NodeJS.ProcessEnv | undefined, + signal?: AbortSignal +): Promise | null { + if ( + process.platform !== 'win32' || + resolved.wsl !== null || + waitForWindowsHostGitEnvironment === null + ) { + return null + } + const ready = waitForWindowsHostGitEnvironment().then(() => refreshWindowsHostPath(env)) + if (!signal) { + return ready + } + if (signal.aborted) { + return Promise.reject(createAbortError()) + } + return new Promise((resolve, reject) => { + let settled = false + const cleanup = (): void => signal.removeEventListener('abort', onAbort) + const onAbort = (): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(createAbortError()) + } + signal.addEventListener('abort', onAbort, { once: true }) + ready.then( + (value) => { + if (settled) { + return + } + settled = true + cleanup() + resolve(value) + }, + (error) => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + ) + }) +} diff --git a/src/main/git/command-runner/wsl-aware-spawn.ts b/src/main/git/command-runner/wsl-aware-spawn.ts new file mode 100644 index 00000000000..2b18d26424f --- /dev/null +++ b/src/main/git/command-runner/wsl-aware-spawn.ts @@ -0,0 +1,26 @@ +import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' +import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe' +import { resolveCommand } from './wsl-command-resolution' + +/** + * 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, + windowsHide: true, + cwd: resolved.cwd + }) + recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) + return child +} diff --git a/src/main/git/command-runner/wsl-command-resolution.ts b/src/main/git/command-runner/wsl-command-resolution.ts new file mode 100644 index 00000000000..a70c0ca2bc9 --- /dev/null +++ b/src/main/git/command-runner/wsl-command-resolution.ts @@ -0,0 +1,186 @@ +import { getDefaultWslDistro, parseWslPath, type WslPathInfo } from '../../wsl' +import { + buildWslCapturedLoginShellCommand, + buildWslExecArgs, + buildWslLoginShellCommand, + quotePosixShell, + type WslCapturedLoginShellCommand +} from '../../../shared/wsl-login-shell-command' +import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../../shared/git-output-locale' +import type { WslGitReadEnvironment } from '../wsl-git-read-environment' +import { + createWslProcessGroupTermination, + type WslProcessGroupTermination +} from '../wsl-process-group-termination' +import { translateArgForWsl, translateArgsForWsl } from './wsl-path-translation' + +// Env-assignment prefix for WSL-routed git, where spawn env can't cross the wsl.exe boundary; values are shell-safe unquoted. +const GIT_OUTPUT_LOCALE_SHELL_PREFIX = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV) + .map(([key, value]) => `${key}=${value}`) + .join(' ') +const GIT_OUTPUT_LOCALE_ENV_ARGS = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV).map( + ([key, value]) => `${key}=${value}` +) + +export type ResolvedCommand = { + binary: string + args: string[] + cwd: string | undefined + /** Non-null when the command was routed through WSL. */ + wsl: WslPathInfo | null + wslMode: 'direct-git' | 'login-shell' | 'non-login-shell' | null + /** Present only when the caller opted into a fenced login-shell read. */ + captured?: WslCapturedLoginShellCommand + termination?: WslProcessGroupTermination +} + +let defaultWslDistroOverride: string | null = null + +// Why: allow host commands fallback to route through the user's pinned WSL distro when host execution fails. +export function setDefaultWslDistroOverride(distro: string | null): void { + defaultWslDistroOverride = distro +} + +export function resolveDefaultWslCli( + command: 'gh' | 'glab', + args: string[] +): ResolvedCommand | null { + const distro = defaultWslDistroOverride ?? getDefaultWslDistro() + return distro ? resolveCommand(command, args, undefined, distro) : null +} + +/** + * Resolve whether a command invocation should be routed through wsl.exe. + * + * Why `bash -c "cd … && …"` instead of `--cd`: wsl.exe's --cd fails with + * ERROR_PATH_NOT_FOUND under Node's execFile/spawn in some configs. + */ +export function resolveCommand( + command: string, + args: string[], + cwd: string | undefined, + wslDistroOverride?: string, + options: { + useWslLoginShell?: boolean + captureLoginShellOutput?: boolean + wslGitReadEnvironment?: WslGitReadEnvironment + env?: NodeJS.ProcessEnv + terminationBarrier?: boolean + } = {} +): ResolvedCommand { + if (process.platform !== 'win32') { + return { binary: command, args, cwd, wsl: null, wslMode: null } + } + + // Why: global gh callers (rate_limit, listAccessibleProjects) have no cwd to derive a distro from; a distro hint still routes through wsl.exe. + // TODO(wsl-default-distro): no default-distro setting yet, so override-less global gh callers fall back to host gh.exe (ENOENT on WSL-only installs). + 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, wslMode: null } + } + + const translatedArgs = translateArgsForWsl(args) + // Why: env on wsl.exe stays Windows-side (WSLENV forwards only named vars), so the locale must ride the command string (issue #7808). + const localePrefix = command === 'git' ? `${GIT_OUTPUT_LOCALE_SHELL_PREFIX} ` : '' + const escapedCommand = quotePosixShell(command) + // Why: shell-escape each arg to prevent word splitting / glob expansion inside the bash -c string. + const escapedArgs = translatedArgs.map(quotePosixShell) + // Why: prepend `cd &&` for a UNC cwd; skip it when only a distro override was given (global gh needs no cwd). + const linuxCwd = cwdWsl?.linuxPath ?? (cwd && wslDistroOverride ? translateArgForWsl(cwd) : null) + const shellCmd = linuxCwd + ? `cd ${quotePosixShell(linuxCwd)} && ${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}` + : `${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}` + + if (command === 'git' && options.wslGitReadEnvironment) { + const optionalLocks = options.env?.GIT_OPTIONAL_LOCKS + return withWslProcessGroupTermination( + { + binary: 'wsl.exe', + args: [ + '-d', + wsl.distro, + '--exec', + '/usr/bin/env', + `PATH=${options.wslGitReadEnvironment.path}`, + `HOME=${options.wslGitReadEnvironment.home}`, + ...GIT_OUTPUT_LOCALE_ENV_ARGS, + ...(optionalLocks !== undefined ? [`GIT_OPTIONAL_LOCKS=${optionalLocks}`] : []), + options.wslGitReadEnvironment.gitPath, + ...(linuxCwd ? ['-C', linuxCwd] : []), + ...translatedArgs + ], + cwd: undefined, + wsl, + wslMode: 'direct-git' + }, + options.terminationBarrier + ) + } + + if (options.useWslLoginShell) { + // Why opt-in: the login shell is interactive for bash/zsh, so its rc output + // lands on stdout ahead of the payload. Callers that buffer the whole stream + // fence it; streaming consumers (git grep, ls-files -z) must not, because a + // marker would be glued onto their first record. + if (options.captureLoginShellOutput) { + const captured = buildWslCapturedLoginShellCommand(shellCmd) + return withWslProcessGroupTermination( + { + binary: 'wsl.exe', + args: buildWslExecArgs(wsl.distro, ['sh', '-lc', captured.command]), + cwd: undefined, + wsl, + wslMode: 'login-shell', + captured + }, + options.terminationBarrier + ) + } + return withWslProcessGroupTermination( + { + binary: 'wsl.exe', + args: buildWslExecArgs(wsl.distro, ['sh', '-lc', buildWslLoginShellCommand(shellCmd)]), + cwd: undefined, + wsl, + wslMode: 'login-shell' + }, + options.terminationBarrier + ) + } + + return withWslProcessGroupTermination( + { + binary: 'wsl.exe', + args: buildWslExecArgs(wsl.distro, ['bash', '-c', shellCmd]), + // Why: the `cd` inside bash -c handles the directory; a UNC cwd on the Node process is redundant and can break Node internals. + cwd: undefined, + wsl, + wslMode: 'non-login-shell' + }, + options.terminationBarrier + ) +} + +function withWslProcessGroupTermination( + command: ResolvedCommand, + enabled: boolean | undefined +): ResolvedCommand { + if (!enabled || !command.wsl) { + return command + } + const execIndex = command.args.indexOf('--exec') + if (execIndex === -1) { + return command + } + const termination = createWslProcessGroupTermination(command.wsl.distro) + return { + ...command, + args: [ + ...command.args.slice(0, execIndex + 1), + ...termination.wrapGuestArgs(command.args.slice(execIndex + 1)) + ], + termination + } +} diff --git a/src/main/git/command-runner/wsl-path-translation.ts b/src/main/git/command-runner/wsl-path-translation.ts new file mode 100644 index 00000000000..a3cdfd2079a --- /dev/null +++ b/src/main/git/command-runner/wsl-path-translation.ts @@ -0,0 +1,50 @@ +import { parseWslPath, toWindowsWslPath } from '../../wsl' + +/** + * Translate Windows-style path arguments to Linux paths for commands run in WSL. + * + * Why: callers pass Windows paths as git arguments, which WSL git can't read. + * UNC paths (\\wsl.localhost\…) become native Linux; drive paths (C:\…) → /mnt/c/… + */ +export function translateArgsForWsl(args: string[]): string[] { + return args.map(translateArgForWsl) +} + +export 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 +} + +/** + * Translate absolute Linux paths in git output back to Windows UNC paths. + * Why: git-in-WSL emits Linux-native paths, but Orca reads files via Node fs, which needs Windows UNC. + */ +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 + } + + // Rewrite absolute Linux paths in structured git output (e.g. "worktree /home/user/repo/feature") to Windows UNC. + return output.replace(/(?<=worktree )(\/.+)$/gm, (_match, linuxPath: string) => + toWindowsWslPath(linuxPath, distro) + ) +} diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 55d2330dd43..7a07a3302b8 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -1,2175 +1,44 @@ -/* eslint-disable max-lines -- command routing, WSL translation, and git/gh/glab wrappers stay co-located for consistent platform behavior. */ /** * Centralized git/gh/command runner with transparent WSL support. * * Why: when a repo lives on a WSL filesystem, native Windows binaries (git.exe, * gh.exe, rg.exe) are absent or slow, so this routes execution through * `wsl.exe -d ` with translated Linux paths. + * + * The implementation lives in ./command-runner/*; this module stays the entry + * point its ~100 call sites (and their vi.mock factories) import from. */ -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, - ghRateLimitScopeKey, - isGhPrimaryRateLimitStderr, - isGhRateLimitProbe, - notifyGhPrimaryRateLimit, - type GhRateLimitBucket -} from './gh-rate-limit-breaker' -import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl' -import { addWslEnvKeys } from '../wsl-env' -import { - appendGitConfigEnv, - gitCredentialPromptGuardEnv -} from '../../shared/git-credential-prompt-env' -import { getSpawnArgsForWindows, isWindowsBatchScript, resolveWindowsCommand } from '../win32-utils' -import { isWslDirectGitReadCommand } from './wsl-direct-git-read-commands' -import { - buildWslCapturedLoginShellCommand, - buildWslExecArgs, - buildWslLoginShellCommand, - quotePosixShell, - type WslCapturedLoginShellCommand -} from '../../shared/wsl-login-shell-command' -import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale' -import { endSubprocessStdin } from '../../shared/subprocess-stdin-write' -import { runProcess } from '../../shared/child-process/run-process' -import { - resolveGitFetchHeadCommand, - runWithGitFetchHeadLock -} from '../../shared/git-fetch-head-lock' -import { - disableWslGitReadEnvironment, - getWslGitReadEnvironment, - invalidateWslGitReadEnvironment, - peekWslGitReadEnvironment, - type WslGitReadEnvironment -} from './wsl-git-read-environment' -import { - isWslLinkedWorktreeGitRoutingCandidate, - prepareWslLinkedWorktreeGitRouting, - usesHostGitForWslLinkedWorktree -} from './wsl-linked-worktree-git-routing' -import { - createWslProcessGroupTermination, - type WslProcessGroupTermination -} from './wsl-process-group-termination' // Re-exported for existing importers; lightweight consumers should import from './exec-error' to avoid this heavy module. -import { extractExecError, parseRetryAfterMs } from './exec-error' -export { extractExecError, parseRetryAfterMs } - -// ─── Core resolution ──────────────────────────────────────────────── - -// Env-assignment prefix for WSL-routed git, where spawn env can't cross the wsl.exe boundary; values are shell-safe unquoted. -const GIT_OUTPUT_LOCALE_SHELL_PREFIX = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV) - .map(([key, value]) => `${key}=${value}`) - .join(' ') -const GIT_OUTPUT_LOCALE_ENV_ARGS = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV).map( - ([key, value]) => `${key}=${value}` -) - -type ResolvedCommand = { - binary: string - args: string[] - cwd: string | undefined - /** Non-null when the command was routed through WSL. */ - wsl: WslPathInfo | null - wslMode: 'direct-git' | 'login-shell' | 'non-login-shell' | null - /** Present only when the caller opted into a fenced login-shell read. */ - captured?: WslCapturedLoginShellCommand - termination?: WslProcessGroupTermination -} - -/** - * Translate Windows-style path arguments to Linux paths for commands run in WSL. - * - * Why: callers pass Windows paths as git arguments, which WSL git can't read. - * UNC paths (\\wsl.localhost\…) become native Linux; drive paths (C:\…) → /mnt/c/… - */ -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 hasExplicitRepoViewTarget(args: string[]): boolean { - const target = args[2] - return ( - args[0] === 'repo' && - args[1] === 'view' && - typeof target === 'string' && - !target.startsWith('-') && - target.includes('/') - ) -} - -function canRunGitHubCliWithoutRepoCwd(args: string[]): boolean { - if (hasExplicitRepoArg(args)) { - return true - } - if (args[0] === 'api') { - return !argsUseGhApiPlaceholders(args) - } - return args[0] === 'auth' || hasExplicitRepoViewTarget(args) -} - -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 can't use a WSL UNC cwd; we only fall back for commands with explicit repo/API context, so none is needed. - cwd: undefined, - wsl: null, - wslMode: null - } -} - -let defaultWslDistroOverride: string | null = null -let waitForWindowsHostGitEnvironment: (() => Promise) | null = null - -// Why: allow host commands fallback to route through the user's pinned WSL distro when host execution fails. -export function setDefaultWslDistroOverride(distro: string | null): void { - defaultWslDistroOverride = distro -} - -export function configureWindowsHostGitEnvironmentReadiness( - waitUntilReady: (() => Promise) | null -): void { - waitForWindowsHostGitEnvironment = waitUntilReady -} - -export async function awaitWindowsHostGitEnvironmentReady(options: { - cwd: string - wslDistro?: string - signal?: AbortSignal -}): Promise { - const resolved = resolveGitCommand(['--version'], options) - await prepareWindowsHostGitEnvironment(resolved, undefined, options.signal) -} - -function refreshWindowsHostPath(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv | undefined { - if (!env) { - return undefined - } - const currentPath = process.env.Path ?? process.env.PATH - if (currentPath === undefined) { - return env - } - const next = { ...env } - const pathKeys = Object.keys(next).filter((key) => key.toLowerCase() === 'path') - if (pathKeys.length === 0) { - next[process.env.Path === undefined ? 'PATH' : 'Path'] = currentPath - } else { - for (const key of pathKeys) { - next[key] = currentPath - } - } - return next -} - -function prepareWindowsHostGitEnvironment( - resolved: ResolvedCommand, - env: NodeJS.ProcessEnv | undefined, - signal?: AbortSignal -): Promise | null { - if ( - process.platform !== 'win32' || - resolved.wsl !== null || - waitForWindowsHostGitEnvironment === null - ) { - return null - } - const ready = waitForWindowsHostGitEnvironment().then(() => refreshWindowsHostPath(env)) - if (!signal) { - return ready - } - if (signal.aborted) { - return Promise.reject(createAbortError()) - } - return new Promise((resolve, reject) => { - let settled = false - const cleanup = (): void => signal.removeEventListener('abort', onAbort) - const onAbort = (): void => { - if (settled) { - return - } - settled = true - cleanup() - reject(createAbortError()) - } - signal.addEventListener('abort', onAbort, { once: true }) - ready.then( - (value) => { - if (settled) { - return - } - settled = true - cleanup() - resolve(value) - }, - (error) => { - if (settled) { - return - } - settled = true - cleanup() - reject(error) - } - ) - }) -} - -function resolveDefaultWslCli(command: 'gh' | 'glab', args: string[]): ResolvedCommand | null { - const distro = defaultWslDistroOverride ?? 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') - ) -} - -/** - * Resolve whether a command invocation should be routed through wsl.exe. - * - * Why `bash -c "cd … && …"` instead of `--cd`: wsl.exe's --cd fails with - * ERROR_PATH_NOT_FOUND under Node's execFile/spawn in some configs. - */ -function resolveCommand( - command: string, - args: string[], - cwd: string | undefined, - wslDistroOverride?: string, - options: { - useWslLoginShell?: boolean - captureLoginShellOutput?: boolean - wslGitReadEnvironment?: WslGitReadEnvironment - env?: NodeJS.ProcessEnv - terminationBarrier?: boolean - } = {} -): ResolvedCommand { - if (process.platform !== 'win32') { - return { binary: command, args, cwd, wsl: null, wslMode: null } - } - - // Why: global gh callers (rate_limit, listAccessibleProjects) have no cwd to derive a distro from; a distro hint still routes through wsl.exe. - // TODO(wsl-default-distro): no default-distro setting yet, so override-less global gh callers fall back to host gh.exe (ENOENT on WSL-only installs). - 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, wslMode: null } - } - - const translatedArgs = translateArgsForWsl(args) - // Why: env on wsl.exe stays Windows-side (WSLENV forwards only named vars), so the locale must ride the command string (issue #7808). - const localePrefix = command === 'git' ? `${GIT_OUTPUT_LOCALE_SHELL_PREFIX} ` : '' - const escapedCommand = quotePosixShell(command) - // Why: shell-escape each arg to prevent word splitting / glob expansion inside the bash -c string. - const escapedArgs = translatedArgs.map(quotePosixShell) - // Why: prepend `cd &&` for a UNC cwd; skip it when only a distro override was given (global gh needs no cwd). - const linuxCwd = cwdWsl?.linuxPath ?? (cwd && wslDistroOverride ? translateArgForWsl(cwd) : null) - const shellCmd = linuxCwd - ? `cd ${quotePosixShell(linuxCwd)} && ${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}` - : `${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}` - - if (command === 'git' && options.wslGitReadEnvironment) { - const optionalLocks = options.env?.GIT_OPTIONAL_LOCKS - return withWslProcessGroupTermination( - { - binary: 'wsl.exe', - args: [ - '-d', - wsl.distro, - '--exec', - '/usr/bin/env', - `PATH=${options.wslGitReadEnvironment.path}`, - `HOME=${options.wslGitReadEnvironment.home}`, - ...GIT_OUTPUT_LOCALE_ENV_ARGS, - ...(optionalLocks !== undefined ? [`GIT_OPTIONAL_LOCKS=${optionalLocks}`] : []), - options.wslGitReadEnvironment.gitPath, - ...(linuxCwd ? ['-C', linuxCwd] : []), - ...translatedArgs - ], - cwd: undefined, - wsl, - wslMode: 'direct-git' - }, - options.terminationBarrier - ) - } - - if (options.useWslLoginShell) { - // Why opt-in: the login shell is interactive for bash/zsh, so its rc output - // lands on stdout ahead of the payload. Callers that buffer the whole stream - // fence it; streaming consumers (git grep, ls-files -z) must not, because a - // marker would be glued onto their first record. - if (options.captureLoginShellOutput) { - const captured = buildWslCapturedLoginShellCommand(shellCmd) - return withWslProcessGroupTermination( - { - binary: 'wsl.exe', - args: buildWslExecArgs(wsl.distro, ['sh', '-lc', captured.command]), - cwd: undefined, - wsl, - wslMode: 'login-shell', - captured - }, - options.terminationBarrier - ) - } - return withWslProcessGroupTermination( - { - binary: 'wsl.exe', - args: buildWslExecArgs(wsl.distro, ['sh', '-lc', buildWslLoginShellCommand(shellCmd)]), - cwd: undefined, - wsl, - wslMode: 'login-shell' - }, - options.terminationBarrier - ) - } - - return withWslProcessGroupTermination( - { - binary: 'wsl.exe', - args: buildWslExecArgs(wsl.distro, ['bash', '-c', shellCmd]), - // Why: the `cd` inside bash -c handles the directory; a UNC cwd on the Node process is redundant and can break Node internals. - cwd: undefined, - wsl, - wslMode: 'non-login-shell' - }, - options.terminationBarrier - ) -} - -function withWslProcessGroupTermination( - command: ResolvedCommand, - enabled: boolean | undefined -): ResolvedCommand { - if (!enabled || !command.wsl) { - return command - } - const execIndex = command.args.indexOf('--exec') - if (execIndex === -1) { - return command - } - const termination = createWslProcessGroupTermination(command.wsl.distro) - return { - ...command, - args: [ - ...command.args.slice(0, execIndex + 1), - ...termination.wrapGuestArgs(command.args.slice(execIndex + 1)) - ], - termination - } -} - -// ─── Git-specific runners ─────────────────────────────────────────── - -// Why: cap execFile output to prevent an uncatchable V8 string overflow; match relay 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 - preferWslDirectGit?: boolean - useConfiguredSshCommandForNetwork?: boolean - terminationBarrier?: boolean - captureWslLoginShellOutput?: boolean -} - -type CommandExecOptions = { - cwd?: string - encoding?: BufferEncoding - maxBuffer?: number - timeout?: number - env?: NodeJS.ProcessEnv - signal?: AbortSignal - wslDistro?: string -} - -function wslDistroForCommand(cwd: string | undefined, override?: string): string | null { - if (process.platform !== 'win32') { - return null - } - return (cwd ? parseWslPath(cwd)?.distro : undefined) ?? override ?? null -} - -function resolveGitCommand( - args: string[], - options: GitExecOptions, - forceLoginShell = false, - captureLoginShellOutput = false -): ResolvedCommand { - if (usesHostGitForWslLinkedWorktree(options.cwd, options.wslDistro)) { - // Why: WSL Git resolves a Windows-authored linked-worktree pointer relative to cwd. - return { binary: 'git', args, cwd: options.cwd, wsl: null, wslMode: null } - } - if (!forceLoginShell && shouldAttemptWslDirectGit(args, options)) { - const distro = wslDistroForCommand(options.cwd, options.wslDistro) - const environment = distro ? peekWslGitReadEnvironment(distro) : undefined - if (environment) { - return resolveCommand('git', args, options.cwd, options.wslDistro, { - wslGitReadEnvironment: environment, - env: options.env, - terminationBarrier: options.terminationBarrier - }) - } - if (distro) { - void getWslGitReadEnvironment(distro) - } - } - return resolveGitCommandWithoutProbe(args, options, captureLoginShellOutput) -} - -function shouldAttemptWslDirectGit(args: string[], options: GitExecOptions): boolean { - return Boolean( - process.platform === 'win32' && - // Why either: callers can still opt in explicitly, but a plain read no - // longer has to -- it needs nothing the login shell provides, and routing - // it through one is what exposes callers to the shell's rc output. - (options.preferWslDirectGit || isWslDirectGitReadCommand(args)) && - !options.useConfiguredSshCommandForNetwork && - !Object.entries(options.env ?? {}).some( - ([key, value]) => - key.startsWith('GIT_') && key !== 'GIT_OPTIONAL_LOCKS' && value !== process.env[key] - ) && - options.wslDistro - ) -} - -function resolveGitCommandWithoutProbe( - args: string[], - options: GitExecOptions, - captureLoginShellOutput = false -): ResolvedCommand { - return resolveCommand('git', args, options.cwd, options.wslDistro, { - useWslLoginShell: Boolean(options.wslDistro), - captureLoginShellOutput, - terminationBarrier: options.terminationBarrier - }) -} - -function isDirectWslGitNotFound(error: unknown, resolved: ResolvedCommand): boolean { - if (resolved.wslMode !== 'direct-git' || !error || typeof error !== 'object') { - return false - } - const { code, stderr } = error as { code?: unknown; stderr?: unknown } - const message = typeof stderr === 'string' ? stderr : String(stderr ?? '') - return code === 127 && (message.includes('not found') || message.includes('No such file')) -} - -function directWslGitExitCode(error: unknown, resolved: ResolvedCommand): number | null { - if (resolved.wslMode !== 'direct-git' || !error || typeof error !== 'object') { - return null - } - const code = (error as { code?: unknown }).code - return typeof code === 'number' ? code : null -} - -function invalidateMissingDirectWslGit(error: unknown, resolved: ResolvedCommand): boolean { - const isMissing = isDirectWslGitNotFound(error, resolved) - if (isMissing && resolved.wsl) { - invalidateWslGitReadEnvironment(resolved.wsl.distro) - } - return isMissing -} - -function disableDirectWslGitAfterSuccessfulFallback( - wasMissing: boolean, - resolved: ResolvedCommand -): void { - if (!wasMissing && resolved.wsl) { - disableWslGitReadEnvironment(resolved.wsl.distro) - } -} - -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 -} - -const WINDOWS_TREE_KILL_WAIT_MS = 2_000 - -function killSpawnedCommandTree(child: ChildProcess): Promise { - const pid = child.pid - if (!pid || process.platform !== 'win32') { - child.kill() - return Promise.resolve() - } - return new Promise((resolve) => { - let killer: ChildProcess - try { - // Why: Windows shims/wsl.exe own descendants; wait for /t tree cleanup so a timed-out command can't outlive its probe. - killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { - stdio: 'ignore', - windowsHide: true - }) - if (!killer || typeof killer.unref !== 'function') { - child.kill() - resolve() - return - } - } catch { - child.kill() - resolve() - return - } - let settled = false - let timer: NodeJS.Timeout | null = null - const finish = (fallbackToChildKill: boolean): void => { - if (settled) { - return - } - settled = true - if (timer) { - clearTimeout(timer) - } - killer.removeAllListeners() - if (fallbackToChildKill) { - child.kill() - } - resolve() - } - killer.once('error', () => finish(true)) - killer.once('close', (code) => finish(code !== 0)) - timer = setTimeout(() => { - killer.kill() - finish(true) - }, WINDOWS_TREE_KILL_WAIT_MS) - killer.unref() - }) -} - -type ExecFileCaptureOptions = Omit & { - timeout?: number - stdin?: string - terminationBarrier?: boolean -} - -const GIT_TERMINATION_BARRIER_FALLBACK_TIMEOUT_MS = 2_147_000_000 - -async function execFileCaptureToTermination( - command: string, - args: string[], - options: ExecFileCaptureOptions, - termination?: WslProcessGroupTermination -): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> { - const result = await runProcess({ - program: command, - args, - cwd: typeof options.cwd === 'string' ? options.cwd : undefined, - env: options.env, - timeoutMs: options.timeout ?? GIT_TERMINATION_BARRIER_FALLBACK_TIMEOUT_MS, - maxOutputBytes: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER, - signal: options.signal, - terminationBarrier: termination ?? true, - ...(options.stdin === undefined ? {} : { input: options.stdin }) - }) - const stdout = options.encoding === 'buffer' ? Buffer.from(result.stdout) : result.stdout - const cleanStderr = termination?.stripControlOutput(result.stderr) ?? result.stderr - const stderr = options.encoding === 'buffer' ? Buffer.from(cleanStderr) : cleanStderr - if (result.code === 0 && !result.timedOut && !options.signal?.aborted) { - return { stdout, stderr } - } - const error = new Error( - result.timedOut - ? `${command} timed out.` - : options.signal?.aborted - ? 'The operation was aborted.' - : cleanStderr.trim() || `${command} exited with ${result.code}.` - ) - if (options.signal?.aborted) { - error.name = 'AbortError' - } - throw Object.assign(error, { - code: result.code, - killed: result.timedOut || result.signal !== null || options.signal?.aborted === true, - signal: result.signal, - stdout, - stderr - }) -} - -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 terminating = 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 (settled || terminating) { - return - } - terminating = true - const abortError = createAbortError() - if (!child) { - terminating = false - finish(abortError) - return - } - void killSpawnedCommandTree(child).then(() => { - terminating = false - finish(abortError) - }) - } - - try { - const spawnStartedAt = performance.now() - // Why: our abort listener owns tree cleanup; Node's signal handler could kill wsl.exe before taskkill sees its children. - child = execFile( - command, - args, - { - cwd: options.cwd, - // Why: git.exe is console-subsystem and Orca's main process owns no - // console, so every spawn without this flashes a conhost that takes - // foreground. Git runs on nearly every interaction (#14543). - windowsHide: true, - encoding: options.encoding, - maxBuffer: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER, - env: options.env - }, - (error, stdout, stderr) => { - if (terminating) { - return - } - 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) => { - if (!terminating) { - finish(error) - } - }) - - if (options.stdin !== undefined) { - endSubprocessStdin(child.stdin, options.stdin) - } - - // Why: Node's timeout waits forever on signal-ignoring CLIs; enforce our own deadline with bounded tree cleanup. - if (options.timeout && options.timeout > 0) { - timer = setTimeout(() => { - if (settled || terminating) { - return - } - terminating = true - const timeoutError = new Error(`${command} timed out.`) - if (!child) { - terminating = false - finish(timeoutError) - return - } - void killSpawnedCommandTree(child).then(() => { - terminating = false - finish(timeoutError) - }) - }, 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 => { - 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(() => { - void 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) { - void 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) { - void 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 via the GIT_CONFIG_COUNT/KEY_n/VALUE_n env protocol (git >= 2.31), - * composing with any count already in `env` so we never clobber a caller's config. - */ -export { appendGitConfigEnv } - -/** - * Pin Orca-spawned git to untranslated English output so stderr/progress parsers - * work under any user locale (issue #7808). Terminal git is untouched. - */ -export function untranslatedGitOutputEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - return { ...env, ...UNTRANSLATED_GIT_OUTPUT_ENV } -} - -export function promptGuardGitEnv( - env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform -): NodeJS.ProcessEnv { - return gitCredentialPromptGuardEnv(untranslatedGitOutputEnv(env), platform) -} - -/** - * Credential-prompt guard for a general-purpose shell (PTYs, hook scripts): - * like promptGuardGitEnv but without the issue-7808 locale pins, which 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 { - return gitCredentialPromptGuardEnv(env, platform) -} - -/** - * Force git non-interactive so it fails fast instead of hanging on a prompt with - * no terminal to answer it; on headless `serve` those stuck calls wedge every - * client (issue #5308). - * - * - GIT_TERMINAL_PROMPT=0: git errors instead of prompting for credentials. - * - GIT_ASKPASS / SSH_ASKPASS: emptied when unset so no GUI helper blocks; a - * caller-provided askpass is preserved (custom setups serve creds non-interactively). - * - GIT_SSH_COMMAND BatchMode=yes: SSH errors instead of prompting (doesn't change - * host trust); only added when the caller hasn't set its own. - */ -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 GIT_SSH_COMMAND to WSL only when we set it — a caller's Windows-specific value 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' } - } - - // Why fenced: a login-shell banner here reads as a user-configured sshCommand, - // which skips the BatchMode fallback below and disarms the no-prompt guard. - const resolved = resolveGitCommand(['config', '--get', 'core.sshCommand'], options, true, true) - 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 - }) - const payload = resolved.captured?.readStdout(String(stdout)) ?? String(stdout) - configuredCommand = payload.trim() - } catch { - configuredCommand = '' - } - - if (!configuredCommand) { - const env = { ...promptEnv, GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' } - // Why: WSL routing can come from either an explicit distro or a UNC cwd. - if (resolved.wsl) { - addWslEnvKeys(env, ['GIT_SSH_COMMAND']) - } - return { env, mode: 'fallback' } - } - - const batchModeCommand = buildOpenSshBatchModeCommand(configuredCommand) - if (!batchModeCommand) { - // Why: custom SSH wrappers are user policy; rewriting their argv is riskier than relying on prompt guards + timeout. - return { env: promptEnv, mode: 'configured-wrapper-passthrough' } - } - - const env = { ...promptEnv, GIT_SSH_COMMAND: batchModeCommand } - if (resolved.wsl) { - addWslEnvKeys(env, ['GIT_SSH_COMMAND']) - } - return { env, mode: 'configured-openssh' } -} - -/** - * Async git command execution. Drop-in replacement for - * `execFileAsync('git', args, { cwd, encoding, ... })`. - */ -async function gitExecFileAsyncUnlocked( - args: string[], - options: GitExecOptions -): Promise<{ stdout: string; stderr: string }> { - // Why: span the user-visible `git ` form, not the resolved binary, so dashboards group by intent. - return withGitSpan( - { args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) }, - async () => { - if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { - await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { - signal: options.signal - }) - } - let resolved = resolveGitCommand(args, options, false, options.captureWslLoginShellOutput) - const environmentReady = prepareWindowsHostGitEnvironment( - resolved, - options.env, - options.signal - ) - const env = environmentReady ? await environmentReady : options.env - const effectiveOptions = env === options.env ? options : { ...options, env } - resolved = resolveGitCommand( - args, - effectiveOptions, - false, - effectiveOptions.captureWslLoginShellOutput - ) - const policy = effectiveOptions.useConfiguredSshCommandForNetwork - ? await buildNetworkSshPolicyEnv(effectiveOptions) - : { env: nonInteractiveGitEnv(effectiveOptions.env), mode: 'default' as const } - const capture = ( - command: ResolvedCommand - ): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> => { - const captureOptions = { - cwd: command.cwd, - encoding: (options.encoding ?? 'utf-8') as BufferEncoding, - maxBuffer: options.maxBuffer, - timeout: options.timeout, - stdin: options.stdin, - env: policy.env, - signal: options.signal, - terminationBarrier: options.terminationBarrier - } - return options.terminationBarrier - ? execFileCaptureToTermination( - command.binary, - command.args, - captureOptions, - command.termination - ) - : execFileCapture(command.binary, command.args, captureOptions) - } - let result: { stdout: string | Buffer; stderr: string | Buffer } - try { - result = await capture(resolved) - } catch (error) { - if (directWslGitExitCode(error, resolved) !== null && !options.signal?.aborted) { - const wasMissing = invalidateMissingDirectWslGit(error, resolved) - const fallback = resolveGitCommand( - args, - effectiveOptions, - true, - effectiveOptions.captureWslLoginShellOutput - ) - result = await capture(fallback) - // Why: matching failures can be normal Git control flow; only a successful login retry proves the direct environment was insufficient. - disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved) - const { stdout, stderr } = result - return { - stdout: readCapturedGitString(stdout as string, fallback), - stderr: stderr as string - } - } - if (options.useConfiguredSshCommandForNetwork && error && typeof error === 'object') { - Object.assign(error, { gitSshPolicyMode: policy.mode }) - } - throw error - } - const { stdout, stderr } = result - return { stdout: readCapturedGitString(stdout as string, resolved), stderr: stderr as string } - } - ) -} - -export function gitExecFileAsync( - args: string[], - options: GitExecOptions -): Promise<{ stdout: string; stderr: string }> { - const run = () => gitExecFileAsyncUnlocked(args, options) - const command = resolveGitFetchHeadCommand(args, options.cwd) - return command.needsLock - ? runWithGitFetchHeadLock(command.cwd, options.signal, run, command.gitDir) - : run() -} - -/** - * 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 { wslDistro, ...execOptions } = options - const resolved = resolveCommand(command, args, options.cwd, wslDistro) - const binary = - resolved.wsl === null ? resolveWindowsCommand(resolved.binary, options.env) : resolved.binary - if (isWindowsBatchScript(binary)) { - return spawnCommandCapture(binary, resolved.args, { - ...execOptions, - cwd: resolved.cwd - }) - } - try { - const { stdout, stderr } = await execFileCapture(binary, resolved.args, { - cwd: resolved.cwd, - encoding: execOptions.encoding ?? 'utf-8', - maxBuffer: execOptions.maxBuffer, - timeout: execOptions.timeout, - env: execOptions.env, - signal: execOptions.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, - { - ...execOptions, - 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 }> { - if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { - await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro) - } - // `git show` is a read, so this normally runs with no shell at all. The fence - // still matters for the login-shell fallback: these are raw blob bytes going - // straight to the diff/blob viewer, where a banner becomes file content. - let resolved = resolveGitCommand(args, options, false, true) - const environmentReady = prepareWindowsHostGitEnvironment(resolved, undefined) - if (environmentReady) { - await environmentReady - } - resolved = resolveGitCommand(args, options, false, true) - const { stdout } = (await execFileCapture(resolved.binary, resolved.args, { - cwd: resolved.cwd, - encoding: 'buffer', - maxBuffer: options.maxBuffer, - env: untranslatedGitOutputEnv() - })) as { stdout: Buffer } - return { stdout: readCapturedGitBuffer(stdout, resolved) } -} - -/** - * Slice a fenced payload out of raw bytes. - * - * Why bytes: blob content may be binary, so decoding to a string to find the - * fence would corrupt it. Returns the buffer untouched when the command was not - * fenced or the fence is absent. - */ -function readCapturedGitBuffer(stdout: Buffer, resolved: ResolvedCommand): Buffer { - const captured = resolved.captured - if (!captured) { - return stdout - } - const beginIndex = stdout.lastIndexOf(captured.beginMarker, undefined, 'utf8') - if (beginIndex === -1) { - return stdout - } - const payloadStart = beginIndex + Buffer.byteLength(captured.beginMarker, 'utf8') - const endIndex = stdout.indexOf(captured.endMarker, payloadStart, 'utf8') - return endIndex === -1 ? stdout.subarray(payloadStart) : stdout.subarray(payloadStart, endIndex) -} - -function readCapturedGitString(stdout: string, resolved: ResolvedCommand): string { - const captured = resolved.captured - if (!captured) { - return stdout - } - const beginIndex = stdout.lastIndexOf(captured.beginMarker) - if (beginIndex === -1) { - return stdout - } - const payloadStart = beginIndex + captured.beginMarker.length - const endIndex = stdout.indexOf(captured.endMarker, payloadStart) - return endIndex === -1 ? stdout.slice(payloadStart) : stdout.slice(payloadStart, endIndex) -} - -/** Result of a streamed git command; `stoppedEarly` is true when onStdout asked to stop before the child exited. */ -export type GitStreamResult = { stoppedEarly: boolean } - -type GitStreamOptions = { - cwd: string - env?: NodeJS.ProcessEnv - wslDistro?: string - preferWslDirectGit?: boolean - signal?: AbortSignal - /** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */ - maxBuffer?: number - /** - * Called for each decoded stdout chunk. Return true to stop: the child is - * killed and the promise resolves with stoppedEarly=true. - */ - onStdout: (chunk: string) => boolean | void -} - -/** - * Stream a git command's stdout incrementally instead of buffering it whole. - * - * Why: output larger than V8's max string (e.g. status on a repo with a huge - * un-ignored folder) crashes the process when buffered; streaming keeps memory - * bounded and lets the parser stop git early. Built on gitSpawn for WSL routing. - */ -export async function gitStreamStdout( - args: string[], - options: GitStreamOptions -): Promise { - const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER - return withGitSpan({ args, cwd: options.cwd }, async () => { - if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { - await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { - signal: options.signal - }) - } - const gitOptions: GitExecOptions = { - cwd: options.cwd, - ...(options.env ? { env: options.env } : {}), - ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), - ...(options.preferWslDirectGit ? { preferWslDirectGit: true } : {}), - ...(options.signal ? { signal: options.signal } : {}) - } - let resolved = resolveGitCommand(args, gitOptions) - const environmentReady = prepareWindowsHostGitEnvironment( - resolved, - gitOptions.env, - options.signal - ) - if (environmentReady) { - gitOptions.env = await environmentReady - } - resolved = resolveGitCommand(args, gitOptions) - const stream = (command: ResolvedCommand): Promise => - new Promise((resolve, reject) => { - if (options.signal?.aborted) { - reject(createAbortError()) - return - } - const stdio: SpawnOptions['stdio'] = ['ignore', 'pipe', 'pipe'] - const spawnOptions = { - cwd: options.cwd, - env: nonInteractiveGitEnv(gitOptions.env), - stdio, - wslDistro: options.wslDistro, - windowsHide: true - } - let child: ChildProcess - if (command.wslMode === 'direct-git') { - const spawnStartedAt = performance.now() - child = spawn(command.binary, command.args, { - cwd: command.cwd, - env: untranslatedGitOutputEnv(spawnOptions.env), - stdio: spawnOptions.stdio, - windowsHide: true - }) - recordSubprocessSpawn(command.binary, command.args, performance.now() - spawnStartedAt) - } else { - child = gitSpawn(args, spawnOptions) - } - - let settled = false - let stoppedEarly = false - let stdoutBytes = 0 - let stderr = '' - let stderrBytes = 0 - // Why: decode statefully so a multibyte UTF-8 char split across chunks isn't corrupted into replacement chars. - 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, stdoutBytes })) - return - } - resolve({ stoppedEarly }) - } - - function onStdoutData(chunk: Buffer): void { - stdoutBytes += chunk.byteLength - if (stdoutBytes > maxBuffer) { - void killSpawnedCommandTree(child) - finish(new Error('git stdout exceeded maxBuffer.')) - return - } - const decoded = stdoutDecoder.write(chunk) - if (decoded.length === 0) { - return - } - // Why: a throw from the caller's parser would escape this event handler and crash main; convert to a rejection. - let shouldStop: boolean | void - try { - shouldStop = options.onStdout(decoded) - } catch (error) { - void killSpawnedCommandTree(child) - finish(error instanceof Error ? error : new Error(String(error))) - return - } - if (shouldStop === true) { - // Parser hit its limit: kill git and resolve cleanly with the partial output. - stoppedEarly = true - void killSpawnedCommandTree(child) - finish(null) - } - } - function onStderrData(chunk: Buffer): void { - stderrBytes += chunk.byteLength - if (stderrBytes > maxBuffer) { - void 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(Object.assign(new Error(`git exited with ${code}: ${stderr}`), { code })) - } - function onAbort(): void { - if (!child.pid) { - // Why: failed spawn reports ENOENT after abort cleanup; retain a listener so it cannot crash main. - child.once('error', () => {}) - } - 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() - } - }) - try { - return await stream(resolved) - } catch (error) { - const stdoutBytes = - error && typeof error === 'object' ? (error as { stdoutBytes?: unknown }).stdoutBytes : null - if ( - stdoutBytes === 0 && - directWslGitExitCode(error, resolved) !== null && - !options.signal?.aborted - ) { - const wasMissing = invalidateMissingDirectWslGit(error, resolved) - resolved = resolveGitCommandWithoutProbe(args, gitOptions) - const result = await stream(resolved) - disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved) - return result - } - throw error - } - }) -} - -// Why: sync git blocks the main thread; a dead network drive can hang git for minutes without a timeout (issue #7225's 127s freeze). -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, - windowsHide: true - }) as string - } finally { - // Sync exec blocks the main thread for its whole duration — 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, ... })`. - */ -type GitSpawnOptions = SpawnOptions & { cwd: string; wslDistro?: string } - -export async function gitSpawnAfterWindowsEnvironmentReady( - args: string[], - options: GitSpawnOptions -): Promise { - if (options.signal?.aborted) { - throw createAbortError() - } - const resolved = resolveGitCommand(args, { - cwd: options.cwd, - ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), - ...(options.env ? { env: options.env } : {}) - }) - const env = await (prepareWindowsHostGitEnvironment(resolved, options.env, options.signal) ?? - options.env) - if (options.signal?.aborted) { - throw createAbortError() - } - return gitSpawn(args, env === options.env ? options : { ...options, env }) -} - -export function gitSpawn(args: string[], options: GitSpawnOptions): ChildProcess { - const { wslDistro, ...spawnOptions } = options - const resolved = resolveGitCommand(args, { - cwd: options.cwd, - ...(wslDistro ? { wslDistro } : {}), - ...(spawnOptions.env ? { env: spawnOptions.env } : {}) - }) - const spawnStartedAt = performance.now() - const child = spawn(resolved.binary, resolved.args, { - ...spawnOptions, - env: untranslatedGitOutputEnv(spawnOptions.env ?? process.env), - windowsHide: true, - cwd: resolved.cwd - }) - recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) - return child -} - -// ─── gh CLI runners ───────────────────────────────────────────────── - -// `cwd?` omitted for non-repo-scoped gh calls (rate_limit, listAccessibleProjects) so one WSL-aware wrapper serves both. -// `wslDistro?` routes global cwd-less gh through `wsl.exe -d ` on WSL-only Windows where gh.exe isn't on host PATH. -// `idempotent?` gates transient-error retry (auto-detected from argv); retrying a write that already reached GitHub would duplicate it. -type GhExecOptions = Omit & { - cwd?: string - wslDistro?: string - idempotent?: boolean - // Why: `gh api` and `--repo OWNER/REPO` shorthand resolve against gh's - // default host, not the repo's remote. Carrying the host here lets the - // runner qualify every spawn once, so call sites can't silently fall back - // to github.com for GHES repos; it also scopes the rate-limit breaker. - host?: string -} - -const NON_IDEMPOTENT_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']) -// `gh ` write subcommands; reads are absent on purpose so they keep retrying. -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-POSTs when -f/-F/--field body fields are given without -X; track them. - 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 - } - // Detect GraphQL `query=mutation(…)` so endpoint writes 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 -X auto-POSTs → non-idempotent; GraphQL query bodies are the exception (still reads). - if ( - args[0] === 'api' && - hasApiBodyField && - !explicitMethodSeen && - !(isGraphQlApi && hasGraphQlQuery) - ) { - return false - } - // `gh ` writes (args[1]); `gh api` without -X defaults to idempotent GET, so it's excluded here. - if (args.length >= 2 && args[0] !== 'api') { - if (NON_IDEMPOTENT_GH_VERBS.has(args[1])) { - return false - } - } - return true -} - -/** - * Classify whether a gh execFile rejection is worth retrying. - * - * Why: gh surfaces HTTP status as stderr substrings ("HTTP 504", econnreset, …). - * Retry 5xx/network resets and 429 only without Retry-After (propagate those so - * the UI can show the wait); primary-rate-limit 403 is never 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: 3 attempts total (250ms → 1s backoff); array length defines retry count (total attempts = length + 1). -const GH_RETRY_DELAYS_MS = [250, 1000] as const - -// Why: Retry-After is unbounded and untrusted; cap at 30s so a gh call can't block the IPC thread indefinitely. -const GH_RETRY_AFTER_MAX_MS = 30_000 -const DEFAULT_GH_EXEC_TIMEOUT_MS = 30_000 - -async function sleep(ms: number, signal?: AbortSignal): Promise { - if (signal?.aborted) { - throw createAbortError() - } - await new Promise((resolve, reject) => { - const timer = setTimeout(finish, ms) - const onAbort = (): void => finish(createAbortError()) - function finish(error?: Error): void { - clearTimeout(timer) - signal?.removeEventListener('abort', onAbort) - if (error) { - reject(error) - } else { - resolve() - } - } - signal?.addEventListener('abort', onAbort, { once: true }) - }) -} - -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' - } -} - -function hasGhHostnameFlag(args: readonly string[]): boolean { - return args.some((arg) => arg === '--hostname' || arg.startsWith('--hostname=')) -} - -function hostQualifiedGhRepoValue(value: string, host: string): string { - // URLs and already-qualified HOST/OWNER/REPO values pass through untouched. - if (value.includes('://') || value.split('/').length !== 2) { - return value - } - return `${host}/${value}` -} - -/** - * Host-qualify a gh invocation from `options.host`: `--hostname` for `api` - * calls, `HOST/OWNER/REPO` for `--repo`/`-R` shorthand. SSH-backed repos run - * gh with no cwd, so this is their only host signal (#8312). - * - * @internal exported for tests. - */ -export function applyGhHostToArgs(args: string[], host?: string): string[] { - if (!host) { - return args - } - let result = args - if (result[0] === 'api' && !hasGhHostnameFlag(result)) { - result = ['api', '--hostname', host, ...result.slice(1)] - } - // Why: bare OWNER/REPO shorthand resolves against gh's default host — GH_HOST - // when set — so github.com must be qualified too, not just GHES, or a - // process-level GH_HOST redirects pinned github.com commands. - // Combined short forms (`-Ra/b`, `-R=a/b`) are deliberately not rewritten: - // no call site uses them, and prefix-matching `-R` corrupts free-text values - // of other flags (e.g. a --title that happens to start with `-R`). - const qualified: string[] = [] - for (let i = 0; i < result.length; i += 1) { - const arg = result[i] - if (arg === '--repo' || arg === '-R') { - qualified.push(arg) - const value = result[i + 1] - if (value !== undefined) { - qualified.push(hostQualifiedGhRepoValue(value, host)) - i += 1 - } - continue - } - if (arg.startsWith('--repo=')) { - qualified.push(`--repo=${hostQualifiedGhRepoValue(arg.slice('--repo='.length), host)}`) - continue - } - qualified.push(arg) - } - return qualified -} - -function explicitGhHostname(args: readonly string[]): string | undefined { - for (let i = 0; i < args.length; i += 1) { - if (args[i] === '--hostname') { - const value = args[i + 1]?.trim() - return value || undefined - } - if (args[i].startsWith('--hostname=')) { - const value = args[i].slice('--hostname='.length).trim() - return value || undefined - } - } - return undefined -} - -function explicitGhRepoHostname(args: readonly string[]): string | undefined { - for (let i = 0; i < args.length; i += 1) { - let value: string | undefined - if (args[i] === '--repo' || args[i] === '-R') { - value = args[i + 1] - } else if (args[i].startsWith('--repo=')) { - value = args[i].slice('--repo='.length) - } - const parts = value?.trim().split('/') - if (parts?.length === 3 && parts.every(Boolean)) { - return parts[0] - } - } - return undefined -} - -function ghRateLimitScope( - args: readonly string[], - options: GhExecOptions, - resolved: ResolvedCommand -): string { - const runtime = resolved.wsl ? `wsl:${resolved.wsl.distro.toLowerCase()}` : 'native' - // Why: an explicit argv hostname controls the actual gh request even when - // GH_HOST or options.host disagree, so breaker state must follow that host. - const host = - explicitGhHostname(args) ?? - options.host ?? - explicitGhRepoHostname(args) ?? - options.env?.GH_HOST ?? - process.env.GH_HOST ?? - 'github.com' - return ghRateLimitScopeKey(runtime, host) -} - -function assertGhRateLimitScopeAvailable( - args: readonly string[], - options: GhExecOptions, - resolved: ResolvedCommand, - bucket: GhRateLimitBucket, - exemptProbe: boolean -): void { - if (exemptProbe) { - return - } - const blockedUntilMs = getGhRateLimitBlockedUntilMs( - bucket, - Date.now(), - ghRateLimitScope(args, options, resolved) - ) - if (blockedUntilMs !== null) { - throw createGhRateLimitBlockedError(bucket, blockedUntilMs) - } -} - -/** - * 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; other errors fail fast. - */ -export async function ghExecFileAsync( - args: string[], - options: GhExecOptions = {} -): Promise<{ stdout: string; stderr: string }> { - // Why: retry safety must reflect the original call even when fallbacks replace the resolved command. - const idempotent = options.idempotent ?? argsLookIdempotent(args) - args = applyGhHostToArgs(args, options.host) - let resolved = resolveCommand('gh', args, options.cwd, options.wslDistro) - // Why: while a bucket is rate-limited every spawn returns 403 — fail fast; the probe is exempt so the breaker can learn the reset. - // Why: scope by runtime and host so unrelated github.com, GHES, and WSL quotas cannot block each other. - const rateLimitBucket = classifyGhRateLimitBucket(args) - const rateLimitProbe = isGhRateLimitProbe(args) - assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) - 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: bound gh so one stuck child fails visibly instead of wedging the IPC lane. - timeout: options.timeout ?? defaultGhExecTimeoutMs(options.env), - env: nonInteractiveGhEnv(options.env), - signal: options.signal - }) - return { stdout: stdout as string, stderr: stderr as string } - } catch (err) { - lastError = err - const { stderr } = extractExecError(err) - if (isGhPrimaryRateLimitStderr(stderr)) { - notifyGhPrimaryRateLimit(rateLimitBucket, ghRateLimitScope(args, options, resolved)) - } - 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 host gh.exe, and global calls (rate_limit/auth) carry no cwd to route by. - resolved = wslResolved - attemptedDefaultWslFallback = true - assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) - attempt = -1 - continue - } - } - if (!attemptedHostFallback && canFallBackToHostGitHubCli('gh', args, resolved, stderr)) { - resolved = resolveHostGitHubCli('gh', args) - attemptedHostFallback = true - assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) - attempt = -1 - continue - } - const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length - if (idempotent && !isLastAttempt && isTransientGhError(stderr)) { - // Why: honor the server's Retry-After over our backoff (a shorter sleep just re-fails); cap so a huge hint can't stall IPC. - const retryAfterMs = parseRetryAfterMs(stderr) - const delayMs = - retryAfterMs !== null - ? Math.min(retryAfterMs, GH_RETRY_AFTER_MAX_MS) - : GH_RETRY_DELAYS_MS[attempt] - await sleep(delayMs, options.signal) - continue - } - throw err - } - } - // Unreachable: the loop either returns or throws. Here for TS exhaustiveness. - throw lastError -} - -// ─── glab CLI runner ──────────────────────────────────────────────── -// Why: cloned from the gh runner rather than abstracted behind a generic runner, to avoid touching the working gh path. - -type GlabExecOptions = Omit & { - cwd?: string - wslDistro?: string - idempotent?: boolean - allowDefaultWslFallback?: boolean -} - -/** Async glab CLI execution; drop-in for execFileAsync('glab', …). Retry policy mirrors ghExecFileAsync. */ -/** - * glab's `--hostname` rejects host:port, so a ported self-hosted GitLab must use the GITLAB_HOST env var instead — translate it. - * @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 } - } - // Why WSLENV: a glab routed into a distro only sees Windows-side variables - // named in WSLENV, so without this the ported host silently never crosses and - // glab talks to gitlab.com instead (#12557). Credit: #12558. - const env: NodeJS.ProcessEnv = { ...(options.env ?? process.env), GITLAB_HOST: host } - // Unguarded by platform on purpose: WSLENV is meaningless outside Windows, so - // the extra key is inert there, and gating it would need the test to know the - // platform for no behavioural gain. - addWslEnvKeys(env, ['GITLAB_HOST']) - return { - args: [...args.slice(0, i), ...args.slice(i + 2)], - options: { ...options, env } - } -} - -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, - signal: options.signal - }) - 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 — don't auto-retry a non-idempotent write that GitLab may already have applied. - 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, - windowsHide: true, - 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: git-in-WSL emits Linux-native paths, but Orca reads files via Node fs, which needs Windows UNC. - */ -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 - } - - // Rewrite absolute Linux paths in structured git output (e.g. "worktree /home/user/repo/feature") to Windows UNC. - return output.replace(/(?<=worktree )(\/.+)$/gm, (_match, linuxPath: string) => - toWindowsWslPath(linuxPath, distro) - ) -} +export { extractExecError, parseRetryAfterMs } from './exec-error' + +export { setDefaultWslDistroOverride } from './command-runner/wsl-command-resolution' +export { + awaitWindowsHostGitEnvironmentReady, + configureWindowsHostGitEnvironmentReadiness +} from './command-runner/windows-host-git-environment' +export { DEFAULT_GIT_MAX_BUFFER } from './command-runner/git-exec-options' +export { + appendGitConfigEnv, + gitOptionalLocksDisabledEnv, + nonInteractiveGitEnv, + promptGuardGitEnv, + promptGuardShellEnv, + untranslatedGitOutputEnv +} from './command-runner/git-process-env' +export { + gitExecFileAsync, + gitExecFileAsyncBuffer, + gitExecFileSync +} from './command-runner/git-exec-file' +export { commandExecFileAsync } from './command-runner/command-exec-file' +export { gitStreamStdout, type GitStreamResult } from './command-runner/git-stream-stdout' +export { gitSpawn, gitSpawnAfterWindowsEnvironmentReady } from './command-runner/git-spawn' +export { isTransientGhError } from './command-runner/gh-retry-policy' +export { applyGhHostToArgs } from './command-runner/gh-host-args' +export { ghExecFileAsync } from './command-runner/gh-exec-file' +export { glabExecFileAsync, redirectPortedHostnameToEnv } from './command-runner/glab-exec-file' +export { wslAwareSpawn } from './command-runner/wsl-aware-spawn' +export { translateWslOutputPaths } from './command-runner/wsl-path-translation' /** Convenience re-export of wsl.ts path helpers so consumers don't import it directly. */ export { parseWslPath, toLinuxPath, toWindowsWslPath, isWslPath } from '../wsl' diff --git a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt index c943b8b9ce3..cf300d316c4 100644 --- a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt @@ -21,7 +21,7 @@ main/codex-accounts/service.ts main/codex/codex-state-db-backfill-recovery.ts main/codex/codex-trust-grant-host.ts main/codex/codex-wsl-hook-install-plan.ts -main/git/runner.ts +main/git/command-runner/wsl-command-resolution.ts main/git/wsl-git-read-environment.ts main/ipc/filesystem-watcher-wsl.ts main/local-worktree-filesystem.ts diff --git a/src/main/wsl/wsl-invocation-boundary.test.ts b/src/main/wsl/wsl-invocation-boundary.test.ts index 6ff1b93404d..dbe708f4821 100644 --- a/src/main/wsl/wsl-invocation-boundary.test.ts +++ b/src/main/wsl/wsl-invocation-boundary.test.ts @@ -345,7 +345,7 @@ describe('wsl.exe is spawned through one runner', () => { // ALLOWLIST.length >= N` cannot fail while the allowlist alone exceeds N, // so it passed even for a scanner that found nothing. This fails the moment // detection stops seeing a call that is definitely there. - expect(offenders).toContain('main/git/runner.ts') + expect(offenders).toContain('main/git/command-runner/wsl-command-resolution.ts') }) it('adds no new direct wsl.exe spawn', () => { diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index fe6ffb9c5bb..779fe60ead6 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -8,6 +8,10 @@ # # Migrating a file means replacing its spawn/execFile calls with # runProcess/spawnProcess and deleting its line here. +# +# The src/main/git/command-runner/* entries are the seven spawn sites that used +# to live in the single line src/main/git/runner.ts; splitting that file moved +# them, it did not add any. src/cli/handlers/account.ts src/cli/handlers/core.ts src/cli/handlers/interactive-login-interruption.ts @@ -56,7 +60,13 @@ src/main/emulator/serve-sim-runtime-materializer.ts src/main/emulator/simctl-simulator-devices.ts src/main/emulator/simulator-app-visibility.ts src/main/external-editor-launch.ts -src/main/git/runner.ts +src/main/git/command-runner/exec-file-capture.ts +src/main/git/command-runner/git-exec-file.ts +src/main/git/command-runner/git-spawn.ts +src/main/git/command-runner/git-stream-stdout.ts +src/main/git/command-runner/spawn-command-capture.ts +src/main/git/command-runner/spawned-command-tree-kill.ts +src/main/git/command-runner/wsl-aware-spawn.ts src/main/git/wsl-git-read-environment.ts src/main/github/gh-utils.ts src/main/hooks.ts