mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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 <n@example.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export function createAbortError(): Error {
|
||||
const error = new Error('The operation was aborted.')
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<ExecFileOptions, 'timeout'> & {
|
||||
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 })
|
||||
})
|
||||
}
|
||||
@@ -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 <distro>` 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<GitExecOptions, 'cwd'> & {
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
const NON_IDEMPOTENT_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE'])
|
||||
// `gh <noun> <verb>` 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 <noun> <verb>` 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
|
||||
}
|
||||
@@ -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<void> {
|
||||
if (signal?.aborted) {
|
||||
throw createAbortError()
|
||||
}
|
||||
await new Promise<void>((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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 <subcommand>` 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<ChildProcess> {
|
||||
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
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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<GitStreamResult> {
|
||||
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<GitStreamResult> =>
|
||||
new Promise<GitStreamResult>((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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
)
|
||||
}
|
||||
@@ -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<GitExecOptions, 'cwd'> & {
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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<void> {
|
||||
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()
|
||||
})
|
||||
}
|
||||
@@ -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<void>) | null = null
|
||||
|
||||
export function configureWindowsHostGitEnvironmentReadiness(
|
||||
waitUntilReady: (() => Promise<void>) | null
|
||||
): void {
|
||||
waitForWindowsHostGitEnvironment = waitUntilReady
|
||||
}
|
||||
|
||||
export async function awaitWindowsHostGitEnvironmentReady(options: {
|
||||
cwd: string
|
||||
wslDistro?: string
|
||||
signal?: AbortSignal
|
||||
}): Promise<void> {
|
||||
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<NodeJS.ProcessEnv | undefined> | 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)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <linuxPath> &&` 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
+33
-2164
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user