diff --git a/config/scripts/wsl-git-shell-benchmark.mjs b/config/scripts/wsl-git-shell-benchmark.mjs new file mode 100644 index 00000000000..cc1265b3aa7 --- /dev/null +++ b/config/scripts/wsl-git-shell-benchmark.mjs @@ -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 +}) diff --git a/package.json b/package.json index b15010cf461..0b40794c93f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/git/git-runtime-options.ts b/src/main/git/git-runtime-options.ts index ca0155f07f6..caa52bd02b4 100644 --- a/src/main/git/git-runtime-options.ts +++ b/src/main/git/git-runtime-options.ts @@ -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 } +} diff --git a/src/main/git/runner-wsl-direct-read.test.ts b/src/main/git/runner-wsl-direct-read.test.ts new file mode 100644 index 00000000000..5206f742732 --- /dev/null +++ b/src/main/git/runner-wsl-direct-read.test.ts @@ -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 +} + +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(platform: NodeJS.Platform, run: () => Promise): Promise { + 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') + }) + }) +}) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 9ef510968b7..6751bb9b264 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -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 { const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER return withGitSpan({ args, cwd: options.cwd }, async () => { - return new Promise((resolve, reject) => { - if (options.signal?.aborted) { - reject(createAbortError()) - return - } - const child = gitSpawn(args, { - cwd: options.cwd, - env: nonInteractiveGitEnv(options.env), - stdio: ['ignore', 'pipe', 'pipe'], - wslDistro: options.wslDistro, - windowsHide: true + 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 => + new Promise((resolve, reject) => { + if (options.signal?.aborted) { + reject(createAbortError()) + return + } + const stdio: SpawnOptions['stdio'] = ['ignore', 'pipe', 'pipe'] + const spawnOptions = { + cwd: options.cwd, + env: nonInteractiveGitEnv(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 + } }) } diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 4bd645c7141..3d90ce23ff0 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -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 }) }) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index c03d9307112..07bf124d31c 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -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}`)) { diff --git a/src/main/git/wsl-git-read-environment.ts b/src/main/git/wsl-git-read-environment.ts new file mode 100644 index 00000000000..9bcb7cd6634 --- /dev/null +++ b/src/main/git/wsl-git-read-environment.ts @@ -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>() +const resolvedEnvironmentByDistro = new Map() + +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 { + 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 { + 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) +}