mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(git): bypass WSL login shells for status reads
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import process from 'node:process'
|
||||
|
||||
import { createJiti } from 'jiti'
|
||||
|
||||
const DEFAULT_SAMPLES = 20
|
||||
const DEFAULT_WARMUPS = 3
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
distro: null,
|
||||
nativeRepo: null,
|
||||
mountedRepo: null,
|
||||
samples: DEFAULT_SAMPLES,
|
||||
warmups: DEFAULT_WARMUPS,
|
||||
loginDelayMs: 0
|
||||
}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
const value = argv[++index]
|
||||
if (!value) {
|
||||
throw new Error(`${arg} requires a value`)
|
||||
}
|
||||
if (arg === '--distro') {
|
||||
options.distro = value
|
||||
} else if (arg === '--native-repo') {
|
||||
options.nativeRepo = value
|
||||
} else if (arg === '--mounted-repo') {
|
||||
options.mountedRepo = value
|
||||
} else if (arg === '--samples') {
|
||||
options.samples = Number(value)
|
||||
} else if (arg === '--warmups') {
|
||||
options.warmups = Number(value)
|
||||
} else if (arg === '--login-delay-ms') {
|
||||
options.loginDelayMs = Number(value)
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
}
|
||||
for (const [name, value, minimum] of [
|
||||
['samples', options.samples, 5],
|
||||
['warmups', options.warmups, 0],
|
||||
['login-delay-ms', options.loginDelayMs, 0]
|
||||
]) {
|
||||
if (!Number.isInteger(value) || value < minimum || value > 1_000) {
|
||||
throw new Error(`--${name} must be an integer between ${minimum} and 1000`)
|
||||
}
|
||||
}
|
||||
if (!options.nativeRepo || !options.mountedRepo) {
|
||||
throw new Error('--native-repo and --mounted-repo are required')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
env: options.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
const stdout = []
|
||||
const stderr = []
|
||||
child.stdout.on('data', (chunk) => stdout.push(chunk))
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk))
|
||||
child.on('error', reject)
|
||||
child.on('close', (status) => {
|
||||
const result = {
|
||||
status,
|
||||
stdout: Buffer.concat(stdout),
|
||||
stderr: Buffer.concat(stderr)
|
||||
}
|
||||
if (status === 0 || options.allowFailure) {
|
||||
resolve(result)
|
||||
} else {
|
||||
reject(new Error(`${command} exited ${status}: ${result.stderr.toString('utf8').trim()}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function wslArgs(distro, args) {
|
||||
return ['-d', distro, '--exec', ...args]
|
||||
}
|
||||
|
||||
function wslShellArgs(distro, args) {
|
||||
return ['-d', distro, '--', ...args]
|
||||
}
|
||||
|
||||
async function resolveDistro(requested) {
|
||||
if (requested) {
|
||||
return requested
|
||||
}
|
||||
const result = await run('wsl.exe', ['--list', '--quiet'], {
|
||||
env: { ...process.env, WSL_UTF8: '1' }
|
||||
})
|
||||
const distro = result.stdout
|
||||
.toString('utf8')
|
||||
.replaceAll('\0', '')
|
||||
.split(/\r?\n/)
|
||||
.map((value) => value.trim())
|
||||
.find(Boolean)
|
||||
if (!distro) {
|
||||
throw new Error('No WSL distro is installed')
|
||||
}
|
||||
return distro
|
||||
}
|
||||
|
||||
function percentile(samples, value) {
|
||||
const sorted = [...samples].sort((left, right) => left - right)
|
||||
return sorted[Math.ceil((value / 100) * sorted.length) - 1]
|
||||
}
|
||||
|
||||
function summarize(samples) {
|
||||
return {
|
||||
samples: samples.length,
|
||||
medianMs: Number(percentile(samples, 50).toFixed(1)),
|
||||
p95Ms: Number(percentile(samples, 95).toFixed(1)),
|
||||
minMs: Number(Math.min(...samples).toFixed(1)),
|
||||
maxMs: Number(Math.max(...samples).toFixed(1))
|
||||
}
|
||||
}
|
||||
|
||||
function assertRepoPath(path, expectedPrefix) {
|
||||
if (!path.startsWith(expectedPrefix) || path.includes('\0') || path.includes('\n')) {
|
||||
throw new Error(`Unexpected benchmark repository path: ${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('This benchmark requires a Windows host with WSL')
|
||||
}
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
assertRepoPath(options.nativeRepo, '/')
|
||||
assertRepoPath(options.mountedRepo, '/mnt/')
|
||||
const distro = await resolveDistro(options.distro)
|
||||
const jiti = createJiti(import.meta.url)
|
||||
const { buildWslLoginShellCommand, escapeWslShCommandForWindows, quotePosixShell } =
|
||||
await jiti.import('../../src/shared/wsl-login-shell-command.ts')
|
||||
|
||||
const loginProbe = buildWslLoginShellCommand(
|
||||
`printf '\\n__ORCA_PATH__%s\\n__ORCA_GIT__%s\\n' "$PATH" "$(command -v git)"`
|
||||
)
|
||||
const probe = await run(
|
||||
'wsl.exe',
|
||||
wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(loginProbe)])
|
||||
)
|
||||
const probeText = probe.stdout.toString('utf8')
|
||||
const loginPath = /__ORCA_PATH__(.*)/.exec(probeText)?.[1]?.trim()
|
||||
const gitPath = /__ORCA_GIT__(.*)/.exec(probeText)?.[1]?.trim()
|
||||
if (!loginPath || !gitPath?.startsWith('/')) {
|
||||
throw new Error(`Could not resolve login-shell Git environment: ${probeText.trim()}`)
|
||||
}
|
||||
const outputMarker = `__ORCA_GIT_OUTPUT_${process.pid}__\n`
|
||||
|
||||
const runGit = async (mode, repo, args) => {
|
||||
const startedAt = performance.now()
|
||||
let result
|
||||
if (mode === 'login') {
|
||||
const command = [
|
||||
`cd ${quotePosixShell(repo)} &&`,
|
||||
`printf ${quotePosixShell(outputMarker)} &&`,
|
||||
`LC_ALL=C LANG=C ${quotePosixShell('git')}`,
|
||||
...args.map(quotePosixShell)
|
||||
].join(' ')
|
||||
const delay = options.loginDelayMs > 0 ? `sleep ${options.loginDelayMs / 1_000}; ` : ''
|
||||
const script = `${delay}${buildWslLoginShellCommand(command)}`
|
||||
result = await run(
|
||||
'wsl.exe',
|
||||
wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(script)])
|
||||
)
|
||||
const markerOffset = result.stdout.indexOf(outputMarker)
|
||||
if (markerOffset < 0) {
|
||||
throw new Error('Login shell did not emit the Git output marker')
|
||||
}
|
||||
result.stdout = result.stdout.subarray(markerOffset + Buffer.byteLength(outputMarker))
|
||||
} else {
|
||||
result = await run(
|
||||
'wsl.exe',
|
||||
wslArgs(distro, [
|
||||
'/usr/bin/env',
|
||||
`PATH=${loginPath}`,
|
||||
'LC_ALL=C',
|
||||
'LANG=C',
|
||||
gitPath,
|
||||
'-C',
|
||||
repo,
|
||||
...args
|
||||
])
|
||||
)
|
||||
}
|
||||
return { ...result, elapsedMs: performance.now() - startedAt }
|
||||
}
|
||||
|
||||
const measure = async (label, runArm) => {
|
||||
for (let index = 0; index < options.warmups; index += 1) {
|
||||
await runArm('login')
|
||||
await runArm('fast')
|
||||
}
|
||||
const samples = { login: [], fast: [] }
|
||||
for (let index = 0; index < options.samples; index += 1) {
|
||||
const order = index % 2 === 0 ? ['login', 'fast'] : ['fast', 'login']
|
||||
const results = []
|
||||
for (const mode of order) {
|
||||
const result = await runArm(mode)
|
||||
samples[mode].push(result.elapsedMs)
|
||||
results.push(result)
|
||||
}
|
||||
if (results[0].status !== results[1].status || !results[0].stdout.equals(results[1].stdout)) {
|
||||
throw new Error(
|
||||
`${label} correctness mismatch between ${order.join(' and ')} arms:\n` +
|
||||
`${order[0]}=${JSON.stringify(results[0].stdout.toString('utf8'))}\n` +
|
||||
`${order[1]}=${JSON.stringify(results[1].stdout.toString('utf8'))}`
|
||||
)
|
||||
}
|
||||
}
|
||||
const login = summarize(samples.login)
|
||||
const fast = summarize(samples.fast)
|
||||
return {
|
||||
login,
|
||||
fast,
|
||||
medianSpeedup: Number((login.medianMs / fast.medianMs).toFixed(2)),
|
||||
medianSavedMs: Number((login.medianMs - fast.medianMs).toFixed(1))
|
||||
}
|
||||
}
|
||||
|
||||
const benchmarkRepo = async (repo) => {
|
||||
const branch = (
|
||||
await runGit('fast', repo, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
|
||||
).stdout
|
||||
.toString('utf8')
|
||||
.trim()
|
||||
const fixtureName = `.orca-wsl-git-shell-benchmark-${process.pid}.txt`
|
||||
const fixturePath = `${repo}/${fixtureName}`
|
||||
const prepareStage = async () => {
|
||||
await runGit('fast', repo, ['reset', '--quiet', '--', fixtureName])
|
||||
await run(
|
||||
'wsl.exe',
|
||||
wslArgs(distro, [
|
||||
'/bin/sh',
|
||||
'-c',
|
||||
'printf "benchmark\\n" > "$1"',
|
||||
'orca-wsl-git-shell-benchmark',
|
||||
fixturePath
|
||||
])
|
||||
)
|
||||
}
|
||||
const cleanupStage = async () => {
|
||||
await runGit('fast', repo, ['reset', '--quiet', '--', fixtureName])
|
||||
await run('wsl.exe', wslArgs(distro, ['/bin/rm', '-f', '--', fixturePath]))
|
||||
}
|
||||
|
||||
const operations = {
|
||||
status: [
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'status',
|
||||
'--porcelain=v2',
|
||||
'--branch',
|
||||
'--untracked-files=all'
|
||||
],
|
||||
numstat: ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M'],
|
||||
upstream: [
|
||||
'for-each-ref',
|
||||
'--format=%(refname)%00%(upstream)%00%(upstream:short)',
|
||||
'--count=1',
|
||||
`refs/heads/${branch}`
|
||||
]
|
||||
}
|
||||
const result = {}
|
||||
for (const [name, args] of Object.entries(operations)) {
|
||||
result[name] = await measure(`${repo}:${name}`, (mode) => runGit(mode, repo, args))
|
||||
}
|
||||
try {
|
||||
result.stageRefresh = await measure(`${repo}:stage-refresh`, async (mode) => {
|
||||
await prepareStage()
|
||||
const startedAt = performance.now()
|
||||
await runGit('login', repo, ['add', '--', fixtureName])
|
||||
const status = await runGit(mode, repo, operations.status)
|
||||
const numstat = await runGit(mode, repo, [
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'diff',
|
||||
'-z',
|
||||
'--cached',
|
||||
'--numstat',
|
||||
'-M'
|
||||
])
|
||||
return {
|
||||
status: 0,
|
||||
stdout: Buffer.concat([status.stdout, numstat.stdout]),
|
||||
stderr: Buffer.concat([status.stderr, numstat.stderr]),
|
||||
elapsedMs: performance.now() - startedAt
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
await cleanupStage()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const verifyProductionRunner = async (repo) => {
|
||||
const windowsRepo = (
|
||||
await run('wsl.exe', wslArgs(distro, ['/usr/bin/wslpath', '-w', repo]))
|
||||
).stdout
|
||||
.toString('utf8')
|
||||
.trim()
|
||||
const args = [
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'status',
|
||||
'--porcelain=v2',
|
||||
'--branch',
|
||||
'--untracked-files=all'
|
||||
]
|
||||
const [{ gitExecFileAsync }, { getWslGitReadEnvironment }, expected] = await Promise.all([
|
||||
jiti.import('../../src/main/git/runner.ts'),
|
||||
jiti.import('../../src/main/git/wsl-git-read-environment.ts'),
|
||||
runGit('fast', repo, args)
|
||||
])
|
||||
await getWslGitReadEnvironment(distro)
|
||||
const startedAt = performance.now()
|
||||
const actual = await gitExecFileAsync(args, {
|
||||
cwd: windowsRepo,
|
||||
preferWslDirectGit: true,
|
||||
wslDistro: distro
|
||||
})
|
||||
if (!Buffer.from(actual.stdout).equals(expected.stdout)) {
|
||||
throw new Error(`Production runner output mismatch for ${windowsRepo}`)
|
||||
}
|
||||
return {
|
||||
windowsRepo,
|
||||
environmentPrimed: true,
|
||||
elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
|
||||
outputBytes: expected.stdout.length
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
distro,
|
||||
gitPath,
|
||||
gitVersion: (await runGit('fast', options.nativeRepo, ['--version'])).stdout
|
||||
.toString('utf8')
|
||||
.trim(),
|
||||
samples: options.samples,
|
||||
warmups: options.warmups,
|
||||
injectedLoginDelayMs: options.loginDelayMs,
|
||||
loginProbePreambleBytes: Buffer.byteLength(probeText.split('__ORCA_PATH__', 1)[0]),
|
||||
guestProcessShape: {
|
||||
login: 'sh -> interactive login shell -> git',
|
||||
fast: 'env -> git'
|
||||
},
|
||||
native: await benchmarkRepo(options.nativeRepo),
|
||||
mounted: await benchmarkRepo(options.mountedRepo),
|
||||
productionRunnerVerification: {
|
||||
native: await verifyProductionRunner(options.nativeRepo),
|
||||
mounted: await verifyProductionRunner(options.mountedRepo)
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -110,6 +110,7 @@
|
||||
"bench:startup": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/startup-time-bench.mjs",
|
||||
"bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/daemon-coldstart-bench.mjs",
|
||||
"bench:wsl-hook-relay-reattach": "pnpm run build:relay && node config/scripts/wsl-hook-relay-reattach-benchmark.mjs",
|
||||
"bench:wsl-git-shell": "node config/scripts/wsl-git-shell-benchmark.mjs",
|
||||
"bench:hang-watchdog-memory": "pnpm run ensure:electron-runtime && node config/scripts/hang-watchdog-memory-benchmark.mjs",
|
||||
"bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/main-thread-jank-bench.mjs",
|
||||
"bench:worktree-deletion": "node tests/tools/benchmarks/worktree-deletion-dev-bench.mjs",
|
||||
|
||||
@@ -13,3 +13,15 @@ export function gitOptionsForWorktree(
|
||||
...(options.signal ? { signal: options.signal } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function gitStatusReadOptionsForWorktree(
|
||||
cwd: string,
|
||||
options: GitRuntimeOptions = {}
|
||||
): {
|
||||
cwd: string
|
||||
wslDistro?: string
|
||||
signal?: AbortSignal
|
||||
preferWslDirectGit: true
|
||||
} {
|
||||
return { ...gitOptionsForWorktree(cwd, options), preferWslDirectGit: true }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileMock, execFileSyncMock, spawnMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn(),
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: execFileMock,
|
||||
execFileSync: execFileSyncMock,
|
||||
spawn: spawnMock
|
||||
}))
|
||||
vi.mock('../observability/instrumentation', () => ({
|
||||
withGitSpan: (_attributes: unknown, run: () => unknown) => run()
|
||||
}))
|
||||
vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() }))
|
||||
|
||||
import { gitExecFileAsync, gitStreamStdout } from './runner'
|
||||
import {
|
||||
getWslGitReadEnvironment,
|
||||
resetWslGitReadEnvironmentForTests,
|
||||
seedWslGitReadEnvironmentForTests
|
||||
} from './wsl-git-read-environment'
|
||||
|
||||
const DISTRO = 'Ubuntu'
|
||||
const LOGIN_ENVIRONMENT = { gitPath: '/home/user/bin/git', path: '/home/user/bin:/usr/bin:/bin' }
|
||||
|
||||
type MockChild = EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
stderr: EventEmitter
|
||||
pid: number
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function createMockChild(): MockChild {
|
||||
const child = new EventEmitter() as MockChild
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.pid = 1234
|
||||
child.kill = vi.fn()
|
||||
return child
|
||||
}
|
||||
|
||||
async function withPlatform<T>(platform: NodeJS.Platform, run: () => Promise<T>): Promise<T> {
|
||||
const original = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: original })
|
||||
}
|
||||
}
|
||||
|
||||
function succeedExecFile(stdout = 'ok'): void {
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||
const child = createMockChild()
|
||||
queueMicrotask(() => callback?.(null, stdout, ''))
|
||||
return child
|
||||
})
|
||||
}
|
||||
|
||||
describe('WSL direct Git reads', () => {
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
execFileSyncMock.mockReset()
|
||||
spawnMock.mockReset()
|
||||
resetWslGitReadEnvironmentForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetWslGitReadEnvironmentForTests()
|
||||
})
|
||||
|
||||
it('coalesces the login-shell environment probe per distro', async () => {
|
||||
let completeProbe: ((error: Error | null, stdout: string, stderr: string) => void) | undefined
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||
completeProbe = callback
|
||||
return createMockChild()
|
||||
})
|
||||
|
||||
const first = getWslGitReadEnvironment(DISTRO)
|
||||
const second = getWslGitReadEnvironment(DISTRO)
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1)
|
||||
completeProbe?.(
|
||||
null,
|
||||
'profile banner\n\0ORCA_WSL_GIT_READ_ENV_V1\0/home/user/bin:/usr/bin\0/home/user/bin/git\0',
|
||||
''
|
||||
)
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
{ gitPath: '/home/user/bin/git', path: '/home/user/bin:/usr/bin' },
|
||||
{ gitPath: '/home/user/bin/git', path: '/home/user/bin:/usr/bin' }
|
||||
])
|
||||
expect(execFileMock.mock.calls[0]?.[1]?.slice(3, 5)).toEqual(['sh', '-lc'])
|
||||
expect(execFileMock.mock.calls[0]?.[1]?.[5]).toContain('^GIT_')
|
||||
})
|
||||
|
||||
it('runs an opted-in read directly with translated cwd and arguments', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
|
||||
succeedExecFile()
|
||||
|
||||
await gitExecFileAsync(['status', '--short', String.raw`C:\repo\file.txt`], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
env: { GIT_OPTIONAL_LOCKS: '0' },
|
||||
preferWslDirectGit: true,
|
||||
wslDistro: DISTRO
|
||||
})
|
||||
|
||||
expect(execFileMock.mock.calls[0]?.[0]).toBe('wsl.exe')
|
||||
expect(execFileMock.mock.calls[0]?.[1]).toEqual([
|
||||
'-d',
|
||||
DISTRO,
|
||||
'--exec',
|
||||
'/usr/bin/env',
|
||||
`PATH=${LOGIN_ENVIRONMENT.path}`,
|
||||
'LANGUAGE=en',
|
||||
'LC_ALL=en_US.UTF-8',
|
||||
'LANG=en_US.UTF-8',
|
||||
'GIT_OPTIONAL_LOCKS=0',
|
||||
LOGIN_ENVIRONMENT.gitPath,
|
||||
'-C',
|
||||
'/mnt/c/repo',
|
||||
'status',
|
||||
'--short',
|
||||
'/mnt/c/repo/file.txt'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the first read on the login shell while priming the fast path', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
let completeProbe: ((error: Error | null, stdout: string, stderr: string) => void) | undefined
|
||||
execFileMock.mockImplementation((_command, args, _options, callback) => {
|
||||
const child = createMockChild()
|
||||
if ((args as string[])[5]?.includes('ORCA_WSL_GIT_READ_ENV_V1')) {
|
||||
completeProbe = callback
|
||||
} else {
|
||||
queueMicrotask(() => callback?.(null, 'ok', ''))
|
||||
}
|
||||
return child
|
||||
})
|
||||
|
||||
const options = {
|
||||
cwd: String.raw`C:\repo`,
|
||||
preferWslDirectGit: true as const,
|
||||
wslDistro: DISTRO
|
||||
}
|
||||
await gitExecFileAsync(['status', '--short'], options)
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2)
|
||||
expect(execFileMock.mock.calls[1]?.[1]?.slice(3, 5)).toEqual(['sh', '-lc'])
|
||||
completeProbe?.(
|
||||
null,
|
||||
`\0ORCA_WSL_GIT_READ_ENV_V1\0${LOGIN_ENVIRONMENT.path}\0${LOGIN_ENVIRONMENT.gitPath}\0`,
|
||||
''
|
||||
)
|
||||
await Promise.resolve()
|
||||
await gitExecFileAsync(['status', '--short'], options)
|
||||
expect(execFileMock.mock.calls[2]?.[1]).toContain('--exec')
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps unopted commands on the WSL login shell', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
|
||||
succeedExecFile()
|
||||
|
||||
await gitExecFileAsync(['add', '--', 'file.txt'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
wslDistro: DISTRO
|
||||
})
|
||||
|
||||
expect(execFileMock.mock.calls[0]?.[1]?.slice(3, 5)).toEqual(['sh', '-lc'])
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps reads with custom Git environment policy on the login shell', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
|
||||
succeedExecFile()
|
||||
|
||||
await gitExecFileAsync(['status', '--short'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
env: { GIT_CONFIG_GLOBAL: '/home/user/custom.gitconfig' },
|
||||
preferWslDirectGit: true,
|
||||
wslDistro: DISTRO
|
||||
})
|
||||
|
||||
expect(execFileMock.mock.calls[0]?.[1]?.slice(3, 5)).toEqual(['sh', '-lc'])
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves override-less WSL UNC routing on its existing non-login shell', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
|
||||
succeedExecFile()
|
||||
|
||||
await gitExecFileAsync(['status', '--short'], {
|
||||
cwd: String.raw`\\wsl.localhost\Ubuntu\repo`,
|
||||
preferWslDirectGit: true
|
||||
})
|
||||
|
||||
expect(execFileMock.mock.calls[0]?.[1]?.slice(3, 5)).toEqual(['bash', '-c'])
|
||||
})
|
||||
})
|
||||
|
||||
it('invalidates a missing direct executable and retries through the login shell', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
|
||||
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
const child = createMockChild()
|
||||
queueMicrotask(() =>
|
||||
callback?.(
|
||||
Object.assign(new Error('exit 127'), { code: 127 }),
|
||||
'',
|
||||
'/usr/bin/env: No such file or directory'
|
||||
)
|
||||
)
|
||||
return child
|
||||
})
|
||||
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
const child = createMockChild()
|
||||
queueMicrotask(() => callback?.(null, 'ok', ''))
|
||||
return child
|
||||
})
|
||||
|
||||
await expect(
|
||||
gitExecFileAsync(['status', '--short'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
preferWslDirectGit: true,
|
||||
wslDistro: DISTRO
|
||||
})
|
||||
).resolves.toEqual({ stdout: 'ok', stderr: '' })
|
||||
|
||||
expect(execFileMock.mock.calls[0]?.[1]).toContain('--exec')
|
||||
expect(execFileMock.mock.calls[1]?.[1]?.slice(3, 5)).toEqual(['sh', '-lc'])
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back and disables the fast path after another direct Git failure', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
|
||||
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
const child = createMockChild()
|
||||
queueMicrotask(() =>
|
||||
callback?.(Object.assign(new Error('exit 128'), { code: 128 }), '', 'helper failed')
|
||||
)
|
||||
return child
|
||||
})
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||
const child = createMockChild()
|
||||
queueMicrotask(() => callback?.(null, 'ok', ''))
|
||||
return child
|
||||
})
|
||||
const options = {
|
||||
cwd: String.raw`C:\repo`,
|
||||
preferWslDirectGit: true as const,
|
||||
wslDistro: DISTRO
|
||||
}
|
||||
|
||||
await gitExecFileAsync(['status', '--short'], options)
|
||||
await gitExecFileAsync(['status', '--short'], options)
|
||||
|
||||
expect(execFileMock.mock.calls[0]?.[1]).toContain('--exec')
|
||||
expect(execFileMock.mock.calls[1]?.[1]?.slice(3, 5)).toEqual(['sh', '-lc'])
|
||||
expect(execFileMock.mock.calls[2]?.[1]?.slice(3, 5)).toEqual(['sh', '-lc'])
|
||||
})
|
||||
})
|
||||
|
||||
it('streams opted-in reads directly', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = createMockChild()
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit('data', Buffer.from('# branch.head main\n'))
|
||||
child.emit('close', 0)
|
||||
})
|
||||
return child
|
||||
})
|
||||
const chunks: string[] = []
|
||||
|
||||
await gitStreamStdout(['status', '--porcelain=v2'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
onStdout: (chunk) => {
|
||||
chunks.push(chunk)
|
||||
},
|
||||
preferWslDirectGit: true,
|
||||
wslDistro: DISTRO
|
||||
})
|
||||
|
||||
expect(chunks).toEqual(['# branch.head main\n'])
|
||||
expect(spawnMock.mock.calls[0]?.[1]).toContain('--exec')
|
||||
})
|
||||
})
|
||||
|
||||
it('retries a direct stream only when no stdout was delivered', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
|
||||
spawnMock
|
||||
.mockImplementationOnce(() => {
|
||||
const child = createMockChild()
|
||||
queueMicrotask(() => {
|
||||
child.stderr.emit('data', Buffer.from('/usr/bin/env: No such file or directory'))
|
||||
child.emit('close', 127)
|
||||
})
|
||||
return child
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
const child = createMockChild()
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit('data', Buffer.from('# branch.head main\n'))
|
||||
child.emit('close', 0)
|
||||
})
|
||||
return child
|
||||
})
|
||||
|
||||
await gitStreamStdout(['status', '--porcelain=v2'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
onStdout: () => {},
|
||||
preferWslDirectGit: true,
|
||||
wslDistro: DISTRO
|
||||
})
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2)
|
||||
expect(spawnMock.mock.calls[0]?.[1]).toContain('--exec')
|
||||
expect(spawnMock.mock.calls[1]?.[1]?.slice(3, 5)).toEqual(['sh', '-lc'])
|
||||
})
|
||||
})
|
||||
|
||||
it('does not retry a failed direct stream after delivering output', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = createMockChild()
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit('data', Buffer.from('partial'))
|
||||
child.stderr.emit('data', Buffer.from('/usr/bin/env: No such file or directory'))
|
||||
child.emit('close', 127)
|
||||
})
|
||||
return child
|
||||
})
|
||||
|
||||
await expect(
|
||||
gitStreamStdout(['status', '--porcelain=v2'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
onStdout: () => {},
|
||||
preferWslDirectGit: true,
|
||||
wslDistro: DISTRO
|
||||
})
|
||||
).rejects.toThrow('git exited with 127')
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps network reads on the login shell even if opted in', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
|
||||
succeedExecFile()
|
||||
|
||||
await gitExecFileAsync(['fetch', '--dry-run'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
preferWslDirectGit: true,
|
||||
useConfiguredSshCommandForNetwork: true,
|
||||
wslDistro: DISTRO
|
||||
})
|
||||
|
||||
expect(
|
||||
execFileMock.mock.calls.every((call) => call[1]?.slice(3, 5).join(' ') === 'sh -lc')
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves non-Windows execution unchanged', async () => {
|
||||
await withPlatform('linux', async () => {
|
||||
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
|
||||
succeedExecFile()
|
||||
|
||||
await gitExecFileAsync(['status', '--short'], {
|
||||
cwd: '/repo',
|
||||
preferWslDirectGit: true,
|
||||
wslDistro: DISTRO
|
||||
})
|
||||
|
||||
expect(execFileMock.mock.calls[0]?.[0]).toBe('git')
|
||||
})
|
||||
})
|
||||
})
|
||||
+285
-121
@@ -41,6 +41,13 @@ import {
|
||||
} from '../../shared/wsl-login-shell-command'
|
||||
import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale'
|
||||
import { endSubprocessStdin } from '../../shared/subprocess-stdin-write'
|
||||
import {
|
||||
disableWslGitReadEnvironment,
|
||||
getWslGitReadEnvironment,
|
||||
invalidateWslGitReadEnvironment,
|
||||
peekWslGitReadEnvironment,
|
||||
type WslGitReadEnvironment
|
||||
} from './wsl-git-read-environment'
|
||||
// Re-exported for existing importers; lightweight consumers should import from './exec-error' to avoid this heavy module.
|
||||
import { extractExecError, parseRetryAfterMs } from './exec-error'
|
||||
export { extractExecError, parseRetryAfterMs }
|
||||
@@ -51,6 +58,9 @@ export { extractExecError, parseRetryAfterMs }
|
||||
const GIT_OUTPUT_LOCALE_SHELL_PREFIX = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(' ')
|
||||
const GIT_OUTPUT_LOCALE_ENV_ARGS = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV).map(
|
||||
([key, value]) => `${key}=${value}`
|
||||
)
|
||||
|
||||
type ResolvedCommand = {
|
||||
binary: string
|
||||
@@ -58,6 +68,7 @@ type ResolvedCommand = {
|
||||
cwd: string | undefined
|
||||
/** Non-null when the command was routed through WSL. */
|
||||
wsl: WslPathInfo | null
|
||||
wslMode: 'direct-git' | 'login-shell' | 'non-login-shell' | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,7 +171,8 @@ function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand {
|
||||
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
|
||||
wsl: null,
|
||||
wslMode: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,10 +214,14 @@ function resolveCommand(
|
||||
args: string[],
|
||||
cwd: string | undefined,
|
||||
wslDistroOverride?: string,
|
||||
options: { useWslLoginShell?: boolean } = {}
|
||||
options: {
|
||||
useWslLoginShell?: boolean
|
||||
wslGitReadEnvironment?: WslGitReadEnvironment
|
||||
env?: NodeJS.ProcessEnv
|
||||
} = {}
|
||||
): ResolvedCommand {
|
||||
if (process.platform !== 'win32') {
|
||||
return { binary: command, args, cwd, wsl: null }
|
||||
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.
|
||||
@@ -214,7 +230,7 @@ function resolveCommand(
|
||||
const wsl: WslPathInfo | null =
|
||||
cwdWsl ?? (wslDistroOverride ? { distro: wslDistroOverride, linuxPath: '' } : null)
|
||||
if (!wsl) {
|
||||
return { binary: command, args, cwd, wsl: null }
|
||||
return { binary: command, args, cwd, wsl: null, wslMode: null }
|
||||
}
|
||||
|
||||
const translatedArgs = translateArgsForWsl(args)
|
||||
@@ -229,6 +245,28 @@ function resolveCommand(
|
||||
? `cd ${quotePosixShell(linuxCwd)} && ${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}`
|
||||
: `${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}`
|
||||
|
||||
if (command === 'git' && options.wslGitReadEnvironment) {
|
||||
const optionalLocks = options.env?.GIT_OPTIONAL_LOCKS
|
||||
return {
|
||||
binary: 'wsl.exe',
|
||||
args: [
|
||||
'-d',
|
||||
wsl.distro,
|
||||
'--exec',
|
||||
'/usr/bin/env',
|
||||
`PATH=${options.wslGitReadEnvironment.path}`,
|
||||
...GIT_OUTPUT_LOCALE_ENV_ARGS,
|
||||
...(optionalLocks !== undefined ? [`GIT_OPTIONAL_LOCKS=${optionalLocks}`] : []),
|
||||
options.wslGitReadEnvironment.gitPath,
|
||||
...(linuxCwd ? ['-C', linuxCwd] : []),
|
||||
...translatedArgs
|
||||
],
|
||||
cwd: undefined,
|
||||
wsl,
|
||||
wslMode: 'direct-git'
|
||||
}
|
||||
}
|
||||
|
||||
if (options.useWslLoginShell) {
|
||||
return {
|
||||
binary: 'wsl.exe',
|
||||
@@ -241,7 +279,8 @@ function resolveCommand(
|
||||
escapeWslShCommandForWindows(buildWslLoginShellCommand(shellCmd))
|
||||
],
|
||||
cwd: undefined,
|
||||
wsl
|
||||
wsl,
|
||||
wslMode: 'login-shell'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +289,8 @@ function resolveCommand(
|
||||
args: ['-d', 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
|
||||
wsl,
|
||||
wslMode: 'non-login-shell'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +308,7 @@ type GitExecOptions = {
|
||||
env?: NodeJS.ProcessEnv
|
||||
signal?: AbortSignal
|
||||
wslDistro?: string
|
||||
preferWslDirectGit?: boolean
|
||||
useConfiguredSshCommandForNetwork?: boolean
|
||||
}
|
||||
|
||||
@@ -281,6 +322,82 @@ type CommandExecOptions = {
|
||||
wslDistro?: string
|
||||
}
|
||||
|
||||
function wslDistroForCommand(cwd: string | undefined, override?: string): string | null {
|
||||
if (process.platform !== 'win32') {
|
||||
return null
|
||||
}
|
||||
return (cwd ? parseWslPath(cwd)?.distro : undefined) ?? override ?? null
|
||||
}
|
||||
|
||||
function resolveGitCommand(
|
||||
args: string[],
|
||||
options: GitExecOptions,
|
||||
forceLoginShell = false
|
||||
): ResolvedCommand {
|
||||
if (!forceLoginShell && shouldAttemptWslDirectGit(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
|
||||
})
|
||||
}
|
||||
if (distro) {
|
||||
void getWslGitReadEnvironment(distro)
|
||||
}
|
||||
}
|
||||
return resolveCommand('git', args, options.cwd, options.wslDistro, {
|
||||
useWslLoginShell: Boolean(options.wslDistro)
|
||||
})
|
||||
}
|
||||
|
||||
function shouldAttemptWslDirectGit(options: GitExecOptions): boolean {
|
||||
return Boolean(
|
||||
process.platform === 'win32' &&
|
||||
options.preferWslDirectGit &&
|
||||
!options.useConfiguredSshCommandForNetwork &&
|
||||
!Object.keys(options.env ?? {}).some(
|
||||
(key) => key.startsWith('GIT_') && key !== 'GIT_OPTIONAL_LOCKS'
|
||||
) &&
|
||||
options.wslDistro
|
||||
)
|
||||
}
|
||||
|
||||
function resolveGitCommandWithoutProbe(args: string[], options: GitExecOptions): ResolvedCommand {
|
||||
return resolveCommand('git', args, options.cwd, options.wslDistro, {
|
||||
useWslLoginShell: Boolean(options.wslDistro)
|
||||
})
|
||||
}
|
||||
|
||||
function isDirectWslGitNotFound(error: unknown, resolved: ResolvedCommand): boolean {
|
||||
if (resolved.wslMode !== 'direct-git' || !error || typeof error !== 'object') {
|
||||
return false
|
||||
}
|
||||
const { code, stderr } = error as { code?: unknown; stderr?: unknown }
|
||||
const message = typeof stderr === 'string' ? stderr : String(stderr ?? '')
|
||||
return code === 127 && (message.includes('not found') || message.includes('No such file'))
|
||||
}
|
||||
|
||||
function directWslGitExitCode(error: unknown, resolved: ResolvedCommand): number | null {
|
||||
if (resolved.wslMode !== 'direct-git' || !error || typeof error !== 'object') {
|
||||
return null
|
||||
}
|
||||
const code = (error as { code?: unknown }).code
|
||||
return typeof code === 'number' ? code : null
|
||||
}
|
||||
|
||||
function disableOrInvalidateDirectWslGit(error: unknown, resolved: ResolvedCommand): void {
|
||||
if (!resolved.wsl) {
|
||||
return
|
||||
}
|
||||
if (isDirectWslGitNotFound(error, resolved)) {
|
||||
invalidateWslGitReadEnvironment(resolved.wsl.distro)
|
||||
} else {
|
||||
disableWslGitReadEnvironment(resolved.wsl.distro)
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingCommandError(error: unknown): boolean {
|
||||
return Boolean(
|
||||
error && typeof error === 'object' && (error as { code?: unknown }).code === 'ENOENT'
|
||||
@@ -841,25 +958,32 @@ export async function gitExecFileAsync(
|
||||
return withGitSpan(
|
||||
{ args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) },
|
||||
async () => {
|
||||
const resolved = resolveCommand('git', args, options.cwd, options.wslDistro, {
|
||||
useWslLoginShell: Boolean(options.wslDistro)
|
||||
})
|
||||
const resolved = resolveGitCommand(args, options)
|
||||
const policy = options.useConfiguredSshCommandForNetwork
|
||||
? await buildNetworkSshPolicyEnv(options)
|
||||
: { env: nonInteractiveGitEnv(options.env), mode: 'default' as const }
|
||||
let result: { stdout: string | Buffer; stderr: string | Buffer }
|
||||
try {
|
||||
result = await execFileCapture(resolved.binary, resolved.args, {
|
||||
cwd: resolved.cwd,
|
||||
const capture = (
|
||||
command: ResolvedCommand
|
||||
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> =>
|
||||
execFileCapture(command.binary, command.args, {
|
||||
cwd: command.cwd,
|
||||
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
|
||||
maxBuffer: options.maxBuffer,
|
||||
timeout: options.timeout,
|
||||
stdin: options.stdin,
|
||||
// Why: never let a git read-path call block on an interactive prompt (issue #5308) — fail fast.
|
||||
env: policy.env,
|
||||
signal: options.signal
|
||||
})
|
||||
let result: { stdout: string | Buffer; stderr: string | Buffer }
|
||||
try {
|
||||
result = await capture(resolved)
|
||||
} catch (error) {
|
||||
if (directWslGitExitCode(error, resolved) !== null && !options.signal?.aborted) {
|
||||
disableOrInvalidateDirectWslGit(error, resolved)
|
||||
result = await capture(resolveGitCommand(args, options, true))
|
||||
const { stdout, stderr } = result
|
||||
return { stdout: stdout as string, stderr: stderr as string }
|
||||
}
|
||||
if (options.useConfiguredSshCommandForNetwork && error && typeof error === 'object') {
|
||||
Object.assign(error, { gitSshPolicyMode: policy.mode })
|
||||
}
|
||||
@@ -942,6 +1066,7 @@ type GitStreamOptions = {
|
||||
cwd: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
wslDistro?: string
|
||||
preferWslDirectGit?: boolean
|
||||
signal?: AbortSignal
|
||||
/** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */
|
||||
maxBuffer?: number
|
||||
@@ -965,115 +1090,154 @@ export async function gitStreamStdout(
|
||||
): Promise<GitStreamResult> {
|
||||
const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER
|
||||
return withGitSpan({ args, cwd: options.cwd }, async () => {
|
||||
return new Promise<GitStreamResult>((resolve, reject) => {
|
||||
if (options.signal?.aborted) {
|
||||
reject(createAbortError())
|
||||
return
|
||||
}
|
||||
const child = gitSpawn(args, {
|
||||
cwd: options.cwd,
|
||||
env: nonInteractiveGitEnv(options.env),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
wslDistro: options.wslDistro,
|
||||
windowsHide: true
|
||||
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 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(options.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()
|
||||
}
|
||||
})
|
||||
|
||||
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()
|
||||
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
|
||||
) {
|
||||
disableOrInvalidateDirectWslGit(error, resolved)
|
||||
resolved = resolveGitCommandWithoutProbe(args, gitOptions)
|
||||
return stream(resolved)
|
||||
}
|
||||
const finish = (error: Error | null): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
if (error) {
|
||||
reject(Object.assign(error, { stderr }))
|
||||
return
|
||||
}
|
||||
resolve({ stoppedEarly })
|
||||
}
|
||||
|
||||
function onStdoutData(chunk: Buffer): void {
|
||||
stdoutBytes += chunk.byteLength
|
||||
if (stdoutBytes > maxBuffer) {
|
||||
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(new Error(`git exited with ${code}: ${stderr}`))
|
||||
}
|
||||
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()
|
||||
}
|
||||
})
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1120,6 +1120,22 @@ describe('getStatus', () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '' })
|
||||
})
|
||||
|
||||
it('opts status reads into direct WSL Git without changing mutation options', async () => {
|
||||
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
|
||||
await getStatus('/repo', { wslDistro: 'Ubuntu' })
|
||||
await stageFile('/repo', 'src/file.ts', { wslDistro: 'Ubuntu' })
|
||||
|
||||
expect(gitStreamOptionsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ preferWslDirectGit: true, wslDistro: 'Ubuntu' })
|
||||
)
|
||||
const addOptions = gitExecFileAsyncMock.mock.calls.find(([args]) =>
|
||||
(args as string[]).includes('add')
|
||||
)?.[1] as { preferWslDirectGit?: boolean }
|
||||
expect(addOptions.preferWslDirectGit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('benchmarks concurrent status burst subprocess pressure', async () => {
|
||||
const benchPath = process.env.ORCA_GIT_STATUS_COALESCING_BENCH_JSON
|
||||
if (!benchPath) {
|
||||
@@ -1594,7 +1610,7 @@ describe('getStatus', () => {
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/feature/prompts'],
|
||||
{ cwd: '/repo' }
|
||||
{ cwd: '/repo', preferWslDirectGit: true }
|
||||
)
|
||||
expect(result.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 })
|
||||
})
|
||||
|
||||
@@ -51,7 +51,7 @@ import { resolveWorktreeBaseCommitOid } from './worktree-base-ref-probe'
|
||||
import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit'
|
||||
import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe'
|
||||
import type { GitRuntimeOptions } from './git-runtime-options'
|
||||
import { gitOptionsForWorktree } from './git-runtime-options'
|
||||
import { gitOptionsForWorktree, gitStatusReadOptionsForWorktree } from './git-runtime-options'
|
||||
import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner'
|
||||
import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output'
|
||||
import {
|
||||
@@ -321,6 +321,7 @@ async function runGetStatus(
|
||||
const { stoppedEarly } = await gitStreamStdout(statusArgs, {
|
||||
cwd: worktreePath,
|
||||
wslDistro: options.wslDistro,
|
||||
preferWslDirectGit: true,
|
||||
// Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock.
|
||||
env: gitOptionalLocksDisabledEnv(),
|
||||
signal: options.signal,
|
||||
@@ -467,7 +468,7 @@ function createBranchLineTotalInput(
|
||||
.map((entry) => entry.path),
|
||||
runDiffNumstat: (args, signal) =>
|
||||
gitExecFileAsync(args, {
|
||||
...gitOptionsForWorktree(worktreePath, options),
|
||||
...gitStatusReadOptionsForWorktree(worktreePath, options),
|
||||
// Why: after the spread, so the shared lease signal wins over this caller's own.
|
||||
signal,
|
||||
env: gitOptionalLocksDisabledEnv(),
|
||||
@@ -618,7 +619,10 @@ async function runNumstat(
|
||||
'--numstat',
|
||||
'-M'
|
||||
],
|
||||
{ ...gitOptionsForWorktree(worktreePath, options), env: gitOptionalLocksDisabledEnv() }
|
||||
{
|
||||
...gitStatusReadOptionsForWorktree(worktreePath, options),
|
||||
env: gitOptionalLocksDisabledEnv()
|
||||
}
|
||||
)
|
||||
return parseNumstat(stdout)
|
||||
} catch (error) {
|
||||
@@ -852,7 +856,7 @@ async function probeOrRevalidateEffectiveUpstreamStatus(
|
||||
} else if (cached) {
|
||||
try {
|
||||
const status = await getGitUpstreamStatusForUpstreamName(
|
||||
(args) => gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)),
|
||||
(args) => gitExecFileAsync(args, gitStatusReadOptionsForWorktree(worktreePath, options)),
|
||||
cached.upstreamName
|
||||
)
|
||||
return { status, probedSameNameOriginRef: false }
|
||||
@@ -889,7 +893,7 @@ async function probeEffectiveUpstreamStatus(
|
||||
): Promise<{ status: GitUpstreamStatus; probedSameNameOriginRef: boolean }> {
|
||||
let probedSameNameOriginRef = false
|
||||
const snapshotRunner = createGitConfigSnapshotRunner((args) =>
|
||||
gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options))
|
||||
gitExecFileAsync(args, gitStatusReadOptionsForWorktree(worktreePath, options))
|
||||
)
|
||||
const status = await getEffectiveGitUpstreamStatus((args) => {
|
||||
if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import {
|
||||
buildWslLoginShellCommand,
|
||||
escapeWslShCommandForWindows
|
||||
} from '../../shared/wsl-login-shell-command'
|
||||
|
||||
export type WslGitReadEnvironment = { gitPath: string; path: string }
|
||||
|
||||
const PROBE_MARKER = 'ORCA_WSL_GIT_READ_ENV_V1'
|
||||
const PROBE_TIMEOUT_MS = 10_000
|
||||
const PROBE_MAX_BUFFER = 64 * 1024
|
||||
const environmentByDistro = new Map<string, Promise<WslGitReadEnvironment | null>>()
|
||||
const resolvedEnvironmentByDistro = new Map<string, WslGitReadEnvironment>()
|
||||
|
||||
function parseProbe(stdout: string): WslGitReadEnvironment | null {
|
||||
const fields = stdout.split('\0')
|
||||
const markerIndex = fields.lastIndexOf(PROBE_MARKER)
|
||||
const path = fields[markerIndex + 1] ?? ''
|
||||
const gitPath = fields[markerIndex + 2] ?? ''
|
||||
if (
|
||||
markerIndex < 0 ||
|
||||
!path.includes('/') ||
|
||||
path.length > 32_768 ||
|
||||
!gitPath.startsWith('/') ||
|
||||
gitPath.includes('\n') ||
|
||||
gitPath.includes('\r')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return { gitPath, path }
|
||||
}
|
||||
|
||||
function probeWslGitReadEnvironment(distro: string): Promise<WslGitReadEnvironment | null> {
|
||||
const probeCommand = [
|
||||
'_orca_git_path=$(command -v git 2>/dev/null || true)',
|
||||
'case "$_orca_git_path" in /*) [ -x "$_orca_git_path" ] || exit 127 ;; *) exit 127 ;; esac',
|
||||
'if [ -n "${XDG_CONFIG_HOME:-}" ] || [ -n "${LD_LIBRARY_PATH:-}" ] || env | grep -q \'^GIT_\'; then exit 78; fi',
|
||||
`printf '\\0${PROBE_MARKER}\\0%s\\0%s\\0' "$PATH" "$_orca_git_path"`
|
||||
].join('\n')
|
||||
const script = escapeWslShCommandForWindows(buildWslLoginShellCommand(probeCommand))
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
'wsl.exe',
|
||||
['-d', distro, '--', 'sh', '-lc', script],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
maxBuffer: PROBE_MAX_BUFFER,
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
windowsHide: true
|
||||
},
|
||||
(error, stdout) => resolve(error ? null : parseProbe(String(stdout)))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function getWslGitReadEnvironment(distro: string): Promise<WslGitReadEnvironment | null> {
|
||||
let environment = environmentByDistro.get(distro)
|
||||
if (!environment) {
|
||||
environment = probeWslGitReadEnvironment(distro).then((resolved) => {
|
||||
if (resolved && environmentByDistro.get(distro) === environment) {
|
||||
resolvedEnvironmentByDistro.set(distro, resolved)
|
||||
}
|
||||
return resolved
|
||||
})
|
||||
environmentByDistro.set(distro, environment)
|
||||
}
|
||||
return environment
|
||||
}
|
||||
|
||||
export function peekWslGitReadEnvironment(distro: string): WslGitReadEnvironment | undefined {
|
||||
return resolvedEnvironmentByDistro.get(distro)
|
||||
}
|
||||
|
||||
export function invalidateWslGitReadEnvironment(distro: string): void {
|
||||
environmentByDistro.delete(distro)
|
||||
resolvedEnvironmentByDistro.delete(distro)
|
||||
}
|
||||
|
||||
export function disableWslGitReadEnvironment(distro: string): void {
|
||||
environmentByDistro.set(distro, Promise.resolve(null))
|
||||
resolvedEnvironmentByDistro.delete(distro)
|
||||
}
|
||||
|
||||
export function resetWslGitReadEnvironmentForTests(): void {
|
||||
environmentByDistro.clear()
|
||||
resolvedEnvironmentByDistro.clear()
|
||||
}
|
||||
|
||||
export function seedWslGitReadEnvironmentForTests(
|
||||
distro: string,
|
||||
environment: WslGitReadEnvironment
|
||||
): void {
|
||||
environmentByDistro.set(distro, Promise.resolve(environment))
|
||||
resolvedEnvironmentByDistro.set(distro, environment)
|
||||
}
|
||||
Reference in New Issue
Block a user