Files
orca/src/main/git/command-runner/git-exec-file.ts
T
Neil a7e24766b7 fix(wsl): run WSL git with Windows Git line-ending settings on checkouts Windows Git wrote (#27778)
* fix(wsl): run WSL git with Windows Git settings on Windows-authored drive checkouts

* fix(wsl): detect Windows Git checkouts from host autocrlf plus a whole-tree sample

* fix(wsl): keep WSL-created worktrees on Linux Git defaults and never commit CRLF on an unproven checkout

- worktree add / clone write a new checkout whose .git points at /mnt/..., which Windows Git
  cannot open; injecting autocrlf=true there produced CRLF files later read unflagged.
- Writes on a host-autocrlf=true checkout whose sample is unproven run with autocrlf=input,
  so staging normalizes CRLF without ever writing CRLF to disk.
- Writes wait for a stale host answer and re-check checkout identity after joining a sample.

* fix(wsl): compare a joined sample with the checkout identity read after it settles
2026-10-12 18:40:06 -07:00

332 lines
12 KiB
TypeScript

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 { pendingWslDriveCheckoutGitRouting } from '../wsl-drive-checkout-git-routing'
import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution'
import { annotateWslHostFailure } from './wsl-host-failure'
import type { GitAdmissionTier, GitExecOptions } from './git-exec-options'
import { execFileCapture, execFileCaptureToTermination } from './exec-file-capture'
import {
pendingWslDirectGitReadEnvironment,
directWslGitExitCode,
isQuietGitControlFlowExit,
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'
import { acquireGitAdmission } from './git-subprocess-admission'
import { GitCommandTimeoutError, gitCommandTimeoutMs } from './git-command-timeout'
import { classifyGitCommand } from '../../../shared/git-command-classification'
import { createAbortError } from './abort-error'
/**
* 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 (span) => {
const checkoutRoutingReady = pendingWslDriveCheckoutGitRouting(args, options)
if (checkoutRoutingReady) {
await checkoutRoutingReady
}
const readEnvironmentReady = pendingWslDirectGitReadEnvironment(args, options)
if (readEnvironmentReady) {
await readEnvironmentReady
}
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 || classifyGitCommand(args) === 'network'
? await buildNetworkSshPolicyEnv(effectiveOptions, args)
: { env: nonInteractiveGitEnv(effectiveOptions.env), mode: 'default' as const }
const grant = options.admissionExempt
? { queueWaitMs: 0, release: () => {} }
: await acquireGitAdmission({
args,
cwd: options.cwd,
wslDistro: options.wslDistro,
tier: options.admissionTier,
signal: options.admissionSignal ?? options.signal
})
span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs)
const timeoutMs = gitCommandTimeoutMs(args, options.timeout, options.timeoutMsForTest)
const terminationState: { current: Promise<void> | null } = { current: null }
const capture = (
command: ResolvedCommand
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> => {
let reportTerminated: () => void = () => {}
terminationState.current = new Promise<void>((resolve) => {
reportTerminated = resolve
})
const captureOptions = {
cwd: command.cwd,
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
timeout: timeoutMs,
stdin: options.stdin,
env: policy.env,
signal: options.signal,
terminationBarrier: options.terminationBarrier,
admissionTier: options.admissionTier,
onChildTerminated: reportTerminated,
...(timeoutMs === undefined
? {}
: { createTimeoutError: () => new GitCommandTimeoutError(timeoutMs) })
}
const captured = options.terminationBarrier
? execFileCaptureToTermination(
command.binary,
command.args,
captureOptions,
command.termination
)
: execFileCapture(command.binary, command.args, captureOptions)
// Why: a dead WSL distro fails with an empty stderr, so the span would carry no cause at all.
return captured.catch((error: unknown) => {
throw annotateWslHostFailure(error, command)
})
}
const runCapturedCommand = async (): Promise<{ stdout: string; stderr: string }> => {
let result: { stdout: string | Buffer; stderr: string | Buffer }
try {
result = await capture(resolved)
} catch (error) {
if (
directWslGitExitCode(error, resolved) !== null &&
!isQuietGitControlFlowExit(error) &&
!options.signal?.aborted
) {
await terminationState.current
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
}
}
try {
options.admissionSignal?.throwIfAborted()
if (options.canStart?.() === false) {
throw createAbortError()
}
return await runCapturedCommand()
} finally {
const termination = terminationState.current
if (termination) {
void termination.then(grant.release)
} else {
grant.release()
}
}
}
)
}
export function gitExecFileAsync(
args: string[],
options: GitExecOptions
): Promise<{ stdout: string; stderr: string }> {
const command = resolveGitFetchHeadCommand(args, options.cwd)
return command.needsLock
? runWithGitFetchHeadLock(
command.cwd,
options.signal,
() => gitExecFileAsyncUnlocked(args, options),
command.gitDir
)
: gitExecFileAsyncUnlocked(args, options)
}
/**
* 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
timeout?: number
timeoutMsForTest?: number
env?: NodeJS.ProcessEnv
wslDistro?: string
preferWslDirectGit?: boolean
admissionTier?: GitAdmissionTier
signal?: AbortSignal
}
): Promise<{ stdout: Buffer }> {
return withGitSpan({ args, cwd: options.cwd }, async (span) => {
const checkoutRoutingReady = pendingWslDriveCheckoutGitRouting(args, options)
if (checkoutRoutingReady) {
await checkoutRoutingReady
}
const readEnvironmentReady = pendingWslDirectGitReadEnvironment(args, options)
if (readEnvironmentReady) {
await readEnvironmentReady
}
// `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, options.signal)
if (environmentReady) {
await environmentReady
}
resolved = resolveGitCommand(args, options, false, true)
const grant = await acquireGitAdmission({
args,
cwd: options.cwd,
wslDistro: options.wslDistro,
tier: options.admissionTier,
signal: options.signal
})
span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs)
const timeoutMs = gitCommandTimeoutMs(args, options.timeout, options.timeoutMsForTest)
let termination: Promise<void> | null = null
try {
let reportTerminated: () => void = () => {}
termination = new Promise<void>((resolve) => {
reportTerminated = resolve
})
const { stdout } = await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: 'buffer',
signal: options.signal,
maxBuffer: options.maxBuffer,
timeout: timeoutMs,
env: untranslatedGitOutputEnv(options.env),
admissionTier: options.admissionTier,
onChildTerminated: reportTerminated,
...(timeoutMs === undefined
? {}
: { createTimeoutError: () => new GitCommandTimeoutError(timeoutMs) })
})
if (!Buffer.isBuffer(stdout)) {
throw new Error('Git buffer capture returned text instead of bytes')
}
return { stdout: readCapturedGitBuffer(stdout, resolved) }
} finally {
if (termination) {
void termination.then(grant.release)
} else {
grant.release()
}
}
})
}
/**
* 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)
}
}