diff --git a/config/process-host-direct-import-baseline.txt b/config/process-host-direct-import-baseline.txt index c9e41a2ffa6..54c8da3cf94 100644 --- a/config/process-host-direct-import-baseline.txt +++ b/config/process-host-direct-import-baseline.txt @@ -30,7 +30,6 @@ src/main/external-editors/external-editor-launch.ts src/main/git/command-runner/process-capture/exec-file-capture.ts src/main/git/command-runner/git-exec-file-sync.ts src/main/git/command-runner/git-spawn.ts -src/main/git/command-runner/git-stream-stdout.ts src/main/git/command-runner/process-capture/spawn-command-capture.ts src/main/git/command-runner/process-capture/spawned-command-tree-kill.ts src/main/git/command-runner/host-routing/wsl-aware-spawn.ts diff --git a/src/main/git/command-runner/git-environment/git-ssh-policy-env.ts b/src/main/git/command-runner/git-environment/git-ssh-policy-env.ts index 5a9dd9fb069..f7e1a44b5f9 100644 --- a/src/main/git/command-runner/git-environment/git-ssh-policy-env.ts +++ b/src/main/git/command-runner/git-environment/git-ssh-policy-env.ts @@ -10,6 +10,7 @@ import { execFileCapture } from '../process-capture/exec-file-capture' import { resolveGitCommand } from '../host-routing/git-command-resolution' import { DEFAULT_GIT_MAX_BUFFER, type GitExecOptions } from '../git-exec-options' import { promptGuardGitEnv } from './git-process-env' +import { assertPreparedGitCommand } from '../host-routing/wsl-git-path-preparation' import { acquireGitAdmission } from '../admission/git-subprocess-admission' const CORE_SSH_COMMAND_PROBE_TIMEOUT_MS = 2500 @@ -39,6 +40,12 @@ export async function buildNetworkSshPolicyEnv( tier: options.admissionTier, signal: options.signal }) + try { + assertPreparedGitCommand(resolved, probeArgs, options) + } catch (error) { + grant.release() + throw error + } let reportTerminated: () => void = () => {} const terminated = new Promise((resolve) => { reportTerminated = resolve diff --git a/src/main/git/command-runner/git-exec-file-buffer.ts b/src/main/git/command-runner/git-exec-file-buffer.ts new file mode 100644 index 00000000000..6052500dd05 --- /dev/null +++ b/src/main/git/command-runner/git-exec-file-buffer.ts @@ -0,0 +1,118 @@ +import { withGitSpan } from '../../observability/instrumentation' +import { + pendingWslGitPathPreparation, + captureGitExecutionContext, + assertPreparedGitCommand, + gitResultWithExecutionContext, + type GitExecutionContext +} from './host-routing/wsl-git-path-preparation' +import type { GitAdmissionTier } from '../../../shared/rpc-contract/git-admission-tier-params' +import { + pendingWslDirectGitReadEnvironment, + resolveGitCommand +} from './host-routing/git-command-resolution' +import { prepareWindowsHostGitEnvironment } from './host-routing/windows-host-git-environment' +import { acquireGitAdmission } from './admission/git-subprocess-admission' +import { + GitCommandTimeoutError, + gitCommandTimeoutMs +} from '../../../shared/git/commands/git-command-timeout' +import { execFileCapture } from './process-capture/exec-file-capture' +import { untranslatedGitOutputEnv } from './git-environment/git-process-env' +import { readCapturedGitBuffer } from './process-capture/captured-git-output' +import { routeLocalGitOptions, runAdmittedWslGit } from './host-routing/local-git-runtime-route' +import { runUnlessGitCwdKnownMissing } from './host-routing/missing-git-spawn-cwd' +import { gitRunsIn } from './host-routing/git-runs-in' + +async function gitExecFileAsyncBufferInSpan( + args: string[], + options: { + cwd: string + maxBuffer?: number + timeout?: number + timeoutMsForTest?: number + env?: NodeJS.ProcessEnv + wslDistro?: string + preferWslDirectGit?: boolean + admissionTier?: GitAdmissionTier + signal?: AbortSignal + stdin?: string + executionContext?: GitExecutionContext + } +): Promise<{ stdout: Buffer }> { + return withGitSpan({ args, cwd: options.cwd }, async (span) => { + const checkoutRoutingReady = pendingWslGitPathPreparation(args, options) + options = { + ...options, + executionContext: checkoutRoutingReady + ? await checkoutRoutingReady + : captureGitExecutionContext(options) + } + const readEnvironmentReady = pendingWslDirectGitReadEnvironment(args, options) + if (readEnvironmentReady) { + await readEnvironmentReady + } + // Fence fallback login-shell output so rc banners cannot become raw blob content. + let resolved = resolveGitCommand(args, options, false, true) + const environmentReady = prepareWindowsHostGitEnvironment(resolved, undefined, options.signal) + if (environmentReady) { + await environmentReady + } + resolved = resolveGitCommand(args, options, false, true) + const grant = await acquireGitAdmission({ + args, + cwd: options.cwd, + wslDistro: options.wslDistro, + tier: options.admissionTier, + signal: options.signal + }) + span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs) + span?.setAttribute('git.runs_in', gitRunsIn(resolved)) + const timeoutMs = gitCommandTimeoutMs(args, options.timeout, options.timeoutMsForTest) + let termination: Promise | null = null + try { + assertPreparedGitCommand(resolved, args, options) + let reportTerminated: () => void = () => {} + termination = new Promise((resolve) => { + reportTerminated = resolve + }) + const { stdout } = await execFileCapture(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: 'buffer', + signal: options.signal, + stdin: options.stdin, + maxBuffer: options.maxBuffer, + timeout: timeoutMs, + env: untranslatedGitOutputEnv(options.env), + admissionTier: options.admissionTier, + onChildTerminated: reportTerminated, + ...(timeoutMs === undefined + ? {} + : { createTimeoutError: () => new GitCommandTimeoutError(timeoutMs) }) + }) + if (!Buffer.isBuffer(stdout)) { + throw new Error('Git buffer capture returned text instead of bytes') + } + return gitResultWithExecutionContext( + { stdout: readCapturedGitBuffer(stdout, resolved) }, + resolved.executionContext + ) + } finally { + if (termination) { + void termination.then(grant.release) + } else { + grant.release() + } + } + }) +} + +export function gitExecFileAsyncBuffer( + args: string[], + callerOptions: Parameters[1] +): Promise<{ stdout: Buffer }> { + const options = routeLocalGitOptions(callerOptions) + return runAdmittedWslGit(options, () => + runUnlessGitCwdKnownMissing(options, () => gitExecFileAsyncBufferInSpan(args, options)) + ) +} diff --git a/src/main/git/command-runner/git-exec-file.ts b/src/main/git/command-runner/git-exec-file.ts index bdfa4412410..1af12df82ae 100644 --- a/src/main/git/command-runner/git-exec-file.ts +++ b/src/main/git/command-runner/git-exec-file.ts @@ -3,10 +3,15 @@ import { resolveGitFetchHeadCommand, runWithGitFetchHeadLock } from '../../../shared/git/fetch/git-fetch-head-lock' -import { pendingWslDriveCheckoutGitRouting } from './host-routing/wsl-drive-checkout-git-routing' +import { + assertPreparedGitCommand, + captureGitExecutionContext, + gitResultWithExecutionContext, + type GitExecutionContext, + pendingWslGitPathPreparation +} from './host-routing/wsl-git-path-preparation' import type { ResolvedCommand } from './host-routing/wsl-command-resolution' import { annotateWslHostFailure, isTransientWslFailure } from './host-routing/wsl-host-failure' -import type { GitAdmissionTier } from '../../../shared/rpc-contract/git-admission-tier-params' import type { GitExecOptions } from './git-exec-options' import { execFileCapture, execFileCaptureToTermination } from './process-capture/exec-file-capture' import { @@ -19,25 +24,26 @@ import { } from './host-routing/git-command-resolution' import { prepareWindowsHostGitEnvironment } from './host-routing/windows-host-git-environment' import { buildNetworkSshPolicyEnv } from './git-environment/git-ssh-policy-env' -import { nonInteractiveGitEnv, untranslatedGitOutputEnv } from './git-environment/git-process-env' +import { nonInteractiveGitEnv } from './git-environment/git-process-env' import { acquireGitAdmission } from './admission/git-subprocess-admission' import { GitCommandTimeoutError, gitCommandTimeoutMs } from '../../../shared/git/commands/git-command-timeout' import { classifyGitCommand } from '../../../shared/git/commands/git-command-classification' -import { createAbortError } from './process-capture/abort-error' -import { readCapturedGitBuffer, readCapturedGitString } from './process-capture/captured-git-output' +import { readCapturedGitString } from './process-capture/captured-git-output' import { runUnlessGitCwdKnownMissing } from './host-routing/missing-git-spawn-cwd' import { trackGitWorktreeMutation } from './git-worktree-mutation-state' import { routeLocalGitOptions, runAdmittedWslGit } from './host-routing/local-git-runtime-route' import { gitRunsIn } from './host-routing/git-runs-in' +type GitTextResult = { stdout: string; stderr: string; executionContext?: GitExecutionContext } + async function gitExecFileAsyncUnlocked( args: string[], options: GitExecOptions, terminationState: { current: Promise | null } -): Promise<{ stdout: string; stderr: string }> { +): Promise { // Trace caller intent rather than the resolved binary so WSL and native commands group together. return withGitSpan( { args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) }, @@ -45,9 +51,12 @@ async function gitExecFileAsyncUnlocked( if (options.onStderr && options.terminationBarrier) { throw new Error('onStderr is not supported with terminationBarrier.') } - const checkoutRoutingReady = pendingWslDriveCheckoutGitRouting(args, options) - if (checkoutRoutingReady) { - await checkoutRoutingReady + const checkoutRoutingReady = pendingWslGitPathPreparation(args, options) + options = { + ...options, + executionContext: checkoutRoutingReady + ? await checkoutRoutingReady + : captureGitExecutionContext(options) } const readEnvironmentReady = pendingWslDirectGitReadEnvironment(args, options) if (readEnvironmentReady) { @@ -83,6 +92,7 @@ async function gitExecFileAsyncUnlocked( const capture = ( command: ResolvedCommand ): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> => { + assertPreparedGitCommand(command, args, effectiveOptions) let reportTerminated: () => void = () => {} terminationState.current = new Promise((resolve) => { reportTerminated = resolve @@ -121,7 +131,7 @@ async function gitExecFileAsyncUnlocked( throw withCapturedErrorStdout(annotateWslHostFailure(error, command), command) }) } - const runCapturedCommand = async (): Promise<{ stdout: string; stderr: string }> => { + const runCapturedCommand = async (): Promise => { let result: { stdout: string | Buffer; stderr: string | Buffer } try { result = await capture(resolved) @@ -140,10 +150,13 @@ async function gitExecFileAsyncUnlocked( // Why: matching failures can be normal Git control flow; only a successful login retry proves the direct environment was insufficient. disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved) const { stdout, stderr } = result - return { - stdout: readCapturedGitString(stdout as string, fallback), - stderr: stderr as string - } + return gitResultWithExecutionContext( + { + stdout: readCapturedGitString(stdout as string, fallback), + stderr: stderr as string + }, + fallback.executionContext + ) } if (options.useConfiguredSshCommandForNetwork && error && typeof error === 'object') { Object.assign(error, { gitSshPolicyMode: policy.mode }) @@ -151,16 +164,15 @@ async function gitExecFileAsyncUnlocked( throw error } const { stdout, stderr } = result - return { - stdout: readCapturedGitString(stdout as string, resolved), - stderr: stderr as string - } + return gitResultWithExecutionContext( + { + stdout: readCapturedGitString(stdout as string, resolved), + stderr: stderr as string + }, + resolved.executionContext + ) } try { - options.admissionSignal?.throwIfAborted() - if (options.canStart?.() === false) { - throw createAbortError() - } return await runCapturedCommand() } finally { const termination = terminationState.current @@ -187,7 +199,7 @@ function withCapturedErrorStdout(error: unknown, command: ResolvedCommand): unkn export function gitExecFileAsync( args: string[], callerOptions: GitExecOptions -): Promise<{ stdout: string; stderr: string }> { +): Promise { const options = routeLocalGitOptions(callerOptions) const releaseMutation = trackGitWorktreeMutation(args, options) const terminationState: { current: Promise | null } = { current: null } @@ -215,87 +227,4 @@ export function gitExecFileAsync( }) } -async function gitExecFileAsyncBufferInSpan( - args: string[], - options: { - cwd: string - maxBuffer?: number - timeout?: number - timeoutMsForTest?: number - env?: NodeJS.ProcessEnv - wslDistro?: string - preferWslDirectGit?: boolean - admissionTier?: GitAdmissionTier - signal?: AbortSignal - stdin?: string - } -): Promise<{ stdout: Buffer }> { - return withGitSpan({ args, cwd: options.cwd }, async (span) => { - const checkoutRoutingReady = pendingWslDriveCheckoutGitRouting(args, options) - if (checkoutRoutingReady) { - await checkoutRoutingReady - } - const readEnvironmentReady = pendingWslDirectGitReadEnvironment(args, options) - if (readEnvironmentReady) { - await readEnvironmentReady - } - // Fence fallback login-shell output so rc banners cannot become raw blob content. - let resolved = resolveGitCommand(args, options, false, true) - const environmentReady = prepareWindowsHostGitEnvironment(resolved, undefined, options.signal) - if (environmentReady) { - await environmentReady - } - resolved = resolveGitCommand(args, options, false, true) - const grant = await acquireGitAdmission({ - args, - cwd: options.cwd, - wslDistro: options.wslDistro, - tier: options.admissionTier, - signal: options.signal - }) - span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs) - span?.setAttribute('git.runs_in', gitRunsIn(resolved)) - const timeoutMs = gitCommandTimeoutMs(args, options.timeout, options.timeoutMsForTest) - let termination: Promise | null = null - try { - let reportTerminated: () => void = () => {} - termination = new Promise((resolve) => { - reportTerminated = resolve - }) - const { stdout } = await execFileCapture(resolved.binary, resolved.args, { - cwd: resolved.cwd, - encoding: 'buffer', - signal: options.signal, - stdin: options.stdin, - maxBuffer: options.maxBuffer, - timeout: timeoutMs, - env: untranslatedGitOutputEnv(options.env), - admissionTier: options.admissionTier, - onChildTerminated: reportTerminated, - ...(timeoutMs === undefined - ? {} - : { createTimeoutError: () => new GitCommandTimeoutError(timeoutMs) }) - }) - if (!Buffer.isBuffer(stdout)) { - throw new Error('Git buffer capture returned text instead of bytes') - } - return { stdout: readCapturedGitBuffer(stdout, resolved) } - } finally { - if (termination) { - void termination.then(grant.release) - } else { - grant.release() - } - } - }) -} - -export function gitExecFileAsyncBuffer( - args: string[], - callerOptions: Parameters[1] -): Promise<{ stdout: Buffer }> { - const options = routeLocalGitOptions(callerOptions) - return runAdmittedWslGit(options, () => - runUnlessGitCwdKnownMissing(options, () => gitExecFileAsyncBufferInSpan(args, options)) - ) -} +export { gitExecFileAsyncBuffer } from './git-exec-file-buffer' diff --git a/src/main/git/command-runner/git-exec-options.ts b/src/main/git/command-runner/git-exec-options.ts index e98f02ca1f1..dd6e56f8682 100644 --- a/src/main/git/command-runner/git-exec-options.ts +++ b/src/main/git/command-runner/git-exec-options.ts @@ -1,3 +1,4 @@ +import type { GitExecutionContext } from './host-routing/wsl-git-path-preparation' // Bound output to avoid V8 string overflow; match the relay's MAX_GIT_BUFFER. export const DEFAULT_GIT_MAX_BUFFER = 10 * 1024 * 1024 @@ -16,7 +17,9 @@ export type GitExecOptions = { /** Cancels admission only; a running child still finishes. */ admissionSignal?: AbortSignal /** Rechecks owner eligibility after admission, before starting the child. */ - canStart?: () => boolean + canStart?: (context?: GitExecutionContext) => boolean + /** Internal prepared execution host and path provenance; never serialized. */ + executionContext?: GitExecutionContext wslDistro?: string /** Runs the host's Git even in a WSL-runtime project (local-git-runtime-route.ts); say why. */ hostGit?: true diff --git a/src/main/git/command-runner/git-spawn.ts b/src/main/git/command-runner/git-spawn.ts index 4c064a80ad0..b686629f30f 100644 --- a/src/main/git/command-runner/git-spawn.ts +++ b/src/main/git/command-runner/git-spawn.ts @@ -1,9 +1,15 @@ +import type { ResolvedCommand } from './host-routing/wsl-command-resolution' import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe' import { startGitSpan } from '../../observability/instrumentation' import { createAbortError } from './process-capture/abort-error' import { resolveGitCommand } from './host-routing/git-command-resolution' -import { pendingWslDriveCheckoutGitRouting } from './host-routing/wsl-drive-checkout-git-routing' +import { + assertPreparedGitCommand, + captureGitExecutionContext, + type GitExecutionContext, + pendingWslGitPathPreparation +} from './host-routing/wsl-git-path-preparation' import { untranslatedGitOutputEnv } from './git-environment/git-process-env' import { prepareWindowsHostGitEnvironment } from './host-routing/windows-host-git-environment' import type { GitAdmissionTier } from '../../../shared/rpc-contract/git-admission-tier-params' @@ -23,6 +29,7 @@ export type GitSpawnOptions = SpawnOptions & { wslDistro?: string /** Runs the host's Git even in a WSL-runtime project (local-git-runtime-route.ts); say why. */ hostGit?: true + executionContext?: GitExecutionContext admissionTier?: GitAdmissionTier } @@ -30,16 +37,20 @@ export async function gitSpawnAfterWindowsEnvironmentReady( args: string[], callerOptions: GitSpawnOptions ): Promise { - const options = routeLocalGitOptions(callerOptions) + let options = routeLocalGitOptions(callerOptions) if (options.signal?.aborted) { throw createAbortError() } - const lineEndingsReady = pendingWslDriveCheckoutGitRouting(args, options) - if (lineEndingsReady) { - await lineEndingsReady + const lineEndingsReady = pendingWslGitPathPreparation(args, options) + options = { + ...options, + executionContext: lineEndingsReady + ? await lineEndingsReady + : captureGitExecutionContext(options) } const resolved = resolveGitCommand(args, { cwd: options.cwd, + executionContext: options.executionContext, ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), ...(options.env ? { env: options.env } : {}) }) @@ -63,12 +74,20 @@ export async function withGitAdmission( callerOptions: GitSpawnOptions, spawnChild?: () => ChildProcess ): Promise { - const options = routeLocalGitOptions(callerOptions) - const spawnRouted = spawnChild ?? (() => gitSpawn(args, options)) + let options = routeLocalGitOptions(callerOptions) const admission = admitWslGit(options) if (admission) { await admission } + if (!options.executionContext) { + const ready = pendingWslGitPathPreparation(args, options) + options = { + ...options, + executionContext: ready ? await ready : captureGitExecutionContext(options) + } + } + const resolved = resolveGitCommand(args, options) + const spawnRouted = spawnChild ?? (() => spawnPreparedGitCommand(args, resolved, options)) const span = startGitSpan({ args, cwd: options.cwd }) let grant: Awaited> | null = null try { @@ -80,23 +99,15 @@ export async function withGitAdmission( signal: options.signal }) span.setAttribute('git.queue_wait_ms', grant.queueWaitMs) - // The same resolution gitSpawn makes, for the span only; only the router can change its answer. - if (isLocalGitRuntimeRouterInstalled()) { - span.setAttribute( - 'git.runs_in', - gitRunsIn( - resolveGitCommand(args, { - cwd: options.cwd, - ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}) - }) - ) - ) - } + span.setAttribute('git.runs_in', gitRunsIn(resolved)) if (options.signal?.aborted) { grant.release() span.fail(createAbortError()) throw createAbortError() } + if (spawnChild) { + assertPreparedGitCommand(resolved, args, options) + } const child = spawnRouted() let finalized = false let liveError: Error | null = null @@ -160,9 +171,27 @@ export function gitSpawn(args: string[], callerOptions: GitSpawnOptions): ChildP const { wslDistro, admissionTier: _admissionTier, hostGit: _hostGit, ...spawnOptions } = options const resolved = resolveGitCommand(args, { cwd: options.cwd, + executionContext: options.executionContext, + hostGit: options.hostGit, ...(wslDistro ? { wslDistro } : {}), ...(spawnOptions.env ? { env: spawnOptions.env } : {}) }) + return spawnPreparedGitCommand(args, resolved, options) +} + +export function spawnPreparedGitCommand( + args: string[], + resolved: ResolvedCommand, + options: GitSpawnOptions +): ChildProcess { + assertPreparedGitCommand(resolved, args, options) + const { + wslDistro: _wslDistro, + admissionTier: _tier, + hostGit: _hostGit, + executionContext: _context, + ...spawnOptions + } = options const spawnStartedAt = performance.now() const child = spawn(resolved.binary, resolved.args, { ...spawnOptions, diff --git a/src/main/git/command-runner/git-stream-stdout.ts b/src/main/git/command-runner/git-stream-stdout.ts index d501e1c3eb0..ad83c61fe61 100644 --- a/src/main/git/command-runner/git-stream-stdout.ts +++ b/src/main/git/command-runner/git-stream-stdout.ts @@ -1,10 +1,12 @@ -import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' +import type { ChildProcess, SpawnOptions } from 'node:child_process' import { StringDecoder } from 'node:string_decoder' import { withGitSpan } from '../../observability/instrumentation' -import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe' import { createAbortError } from './process-capture/abort-error' import { killSpawnedCommandTree } from './process-capture/spawned-command-tree-kill' -import { pendingWslDriveCheckoutGitRouting } from './host-routing/wsl-drive-checkout-git-routing' +import { + captureGitExecutionContext, + pendingWslGitPathPreparation +} from './host-routing/wsl-git-path-preparation' import type { ResolvedCommand } from './host-routing/wsl-command-resolution' import { DEFAULT_GIT_MAX_BUFFER, type GitExecOptions } from './git-exec-options' import type { GitAdmissionTier } from '../../../shared/rpc-contract/git-admission-tier-params' @@ -18,8 +20,8 @@ import { resolveGitCommandWithoutProbe } from './host-routing/git-command-resolution' import { prepareWindowsHostGitEnvironment } from './host-routing/windows-host-git-environment' -import { nonInteractiveGitEnv, untranslatedGitOutputEnv } from './git-environment/git-process-env' -import { gitSpawn } from './git-spawn' +import { nonInteractiveGitEnv } from './git-environment/git-process-env' +import { spawnPreparedGitCommand } from './git-spawn' import { acquireGitAdmission } from './admission/git-subprocess-admission' import { GitCommandTimeoutError, @@ -68,11 +70,12 @@ function streamInSpan( timeoutMs: number | undefined ): Promise { return withGitSpan({ args, cwd: options.cwd }, async (span) => { - const checkoutRoutingReady = pendingWslDriveCheckoutGitRouting(args, options) - if (checkoutRoutingReady) { - await checkoutRoutingReady - } + const checkoutRoutingReady = pendingWslGitPathPreparation(args, options) + const executionContext = checkoutRoutingReady + ? await checkoutRoutingReady + : captureGitExecutionContext(options) const gitOptions: GitExecOptions = { + executionContext, cwd: options.cwd, ...(options.env ? { env: options.env } : {}), ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), @@ -119,18 +122,10 @@ function streamInSpan( 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) - } + child = spawnPreparedGitCommand(args, command, { + ...spawnOptions, + executionContext: command.executionContext + }) let terminationReported = false terminationState.current = new Promise((resolveTermination) => { const reportTermination = (): void => { diff --git a/src/main/git/command-runner/host-routing/git-command-resolution-windows-checkout.test.ts b/src/main/git/command-runner/host-routing/git-command-resolution-windows-checkout.test.ts index f79e5926cca..2f6b2cc42aa 100644 --- a/src/main/git/command-runner/host-routing/git-command-resolution-windows-checkout.test.ts +++ b/src/main/git/command-runner/host-routing/git-command-resolution-windows-checkout.test.ts @@ -1,5 +1,8 @@ +import { settleWslAutomountRoot } from '../../../wsl/filesystem/__fixtures__/wsl-automount-root-fixture' +import { resetWslAutomountRootsForTests } from '../../../wsl/filesystem/wsl-automount-root' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { resolveGitCommandWithoutProbe } from './git-command-resolution' +import { captureGitExecutionContext } from './wsl-git-path-preparation' import { recordHostAutocrlf, recordLineEndingSample, @@ -18,11 +21,14 @@ function detectWindowsCheckout(): void { } describe('WSL git on a Windows Git checkout', () => { - beforeEach(() => { + beforeEach(async () => { + resetWslAutomountRootsForTests() + await settleWslAutomountRoot() Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) }) afterEach(() => { + resetWslAutomountRootsForTests() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) resetWslWindowsCheckoutGitConfigForTests() resetWslLinkedWorktreeGitRoutingForTests() @@ -40,4 +46,14 @@ describe('WSL git on a Windows Git checkout', () => { expect(resolved.args.join(' ')).toMatch(/core\.autocrlf=true.+core\.checkStat=minimal.+add/) expect(resolved.args.join(' ')).not.toContain('trustctime') }) + + it('keeps a UNC clone destination on WSL when hostGit suppresses project routing', () => { + const options = { cwd: String.raw`\\wsl.localhost\Ubuntu\home\me\src`, hostGit: true as const } + const resolved = resolveGitCommandWithoutProbe(['clone', 'https://example.invalid/repo'], { + ...options, + executionContext: captureGitExecutionContext(options) + }) + expect(resolved.binary).toBe('wsl.exe') + expect(resolved.args.slice(0, 3)).toEqual(['-d', 'Ubuntu', '--exec']) + }) }) diff --git a/src/main/git/command-runner/host-routing/git-command-resolution.ts b/src/main/git/command-runner/host-routing/git-command-resolution.ts index c712baf991d..36d8344c223 100644 --- a/src/main/git/command-runner/host-routing/git-command-resolution.ts +++ b/src/main/git/command-runner/host-routing/git-command-resolution.ts @@ -14,6 +14,7 @@ import { import { usesHostGitForWslLinkedWorktree } from './wsl-linked-worktree-git-routing' import { withWslWindowsCheckoutGitConfig } from './wsl-windows-checkout-git-config' import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution' +import { captureGitExecutionContext } from './wsl-git-path-preparation' import type { GitExecOptions } from '../git-exec-options' // Linux Git needs Windows-checkout settings; classify original args and inject only at spawn. @@ -27,19 +28,33 @@ export function resolveGitCommand( forceLoginShell = false, captureLoginShellOutput = false ): ResolvedCommand { - if (usesHostGitForWslLinkedWorktree(options.cwd, options.wslDistro)) { + if ( + options.executionContext?.kind === 'host' || + (!options.executionContext && usesHostGitForWslLinkedWorktree(options.cwd, options.wslDistro)) + ) { // Why: WSL Git resolves a Windows-authored linked-worktree pointer relative to cwd. - return { binary: 'git', args, cwd: options.cwd, wsl: null, wslMode: null } + return { + binary: 'git', + args, + cwd: options.cwd, + wsl: null, + wslMode: null, + executionContext: { kind: 'host' } + } } const distro = directWslGitReadDistro(args, options, forceLoginShell) if (distro) { const environment = peekWslGitReadEnvironment(distro) if (environment) { - return resolveCommand('git', wslGitArgs(args, options), options.cwd, distro, { + const resolved = resolveCommand('git', wslGitArgs(args, options), options.cwd, distro, { wslGitReadEnvironment: environment, + wslPaths: + options.executionContext?.kind === 'wsl' ? options.executionContext.paths : undefined, env: options.env, terminationBarrier: options.terminationBarrier }) + resolved.executionContext = options.executionContext ?? captureGitExecutionContext(options) + return resolved } void getWslGitReadEnvironment(distro) } @@ -52,7 +67,11 @@ function directWslGitReadDistro( options: GitExecOptions, forceLoginShell: boolean ): string | null { - if (forceLoginShell || !shouldAttemptWslDirectGit(args, options)) { + if ( + options.executionContext?.kind === 'host' || + forceLoginShell || + !shouldAttemptWslDirectGit(args, options) + ) { return null } return wslDistroForCommand(options.cwd, options.wslDistro) @@ -92,14 +111,39 @@ export function pendingWslDirectGitReadEnvironment( export function resolveGitCommandWithoutProbe( args: string[], options: GitExecOptions, - captureLoginShellOutput = false + captureLoginShellOutput = false, + noteWslLaunch = true ): ResolvedCommand { - return resolveCommand('git', wslGitArgs(args, options), options.cwd, options.wslDistro, { - useWslLoginShell: Boolean(options.wslDistro), - captureLoginShellOutput, - env: options.env, - terminationBarrier: options.terminationBarrier - }) + if ( + options.executionContext?.kind === 'host' || + (!options.executionContext && usesHostGitForWslLinkedWorktree(options.cwd, options.wslDistro)) + ) { + return { + binary: 'git', + args, + cwd: options.cwd, + wsl: null, + wslMode: null, + executionContext: { kind: 'host' } + } + } + const resolved = resolveCommand( + 'git', + wslGitArgs(args, options), + options.cwd, + options.wslDistro, + { + useWslLoginShell: Boolean(options.wslDistro), + captureLoginShellOutput, + noteWslLaunch, + wslPaths: + options.executionContext?.kind === 'wsl' ? options.executionContext.paths : undefined, + env: options.env, + terminationBarrier: options.terminationBarrier + } + ) + resolved.executionContext = options.executionContext ?? captureGitExecutionContext(options) + return resolved } function isDirectWslGitNotFound(error: unknown, resolved: ResolvedCommand): boolean { diff --git a/src/main/git/command-runner/host-routing/runner-windows-host-environment.test.ts b/src/main/git/command-runner/host-routing/runner-windows-host-environment.test.ts index 5d0e226d386..0d2ee5c21ea 100644 --- a/src/main/git/command-runner/host-routing/runner-windows-host-environment.test.ts +++ b/src/main/git/command-runner/host-routing/runner-windows-host-environment.test.ts @@ -1,3 +1,5 @@ +import { settleWslAutomountRoot } from '../../../wsl/filesystem/__fixtures__/wsl-automount-root-fixture' +import { resetWslAutomountRootsForTests } from '../../../wsl/filesystem/wsl-automount-root' import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -58,12 +60,15 @@ async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): describe('Windows host Git environment readiness', () => { const originalPath = process.env.Path - beforeEach(() => { + beforeEach(async () => { + resetWslAutomountRootsForTests() + await settleWslAutomountRoot() execFileMock.mockReset() spawnMock.mockReset() }) afterEach(() => { + resetWslAutomountRootsForTests() _resetGitAdmissionForTests() configureWindowsHostGitEnvironmentReadiness(null) if (originalPath === undefined) { diff --git a/src/main/git/command-runner/host-routing/runner-wsl-direct-read.test.ts b/src/main/git/command-runner/host-routing/runner-wsl-direct-read.test.ts index c40d6e13065..e2f3cdf42ca 100644 --- a/src/main/git/command-runner/host-routing/runner-wsl-direct-read.test.ts +++ b/src/main/git/command-runner/host-routing/runner-wsl-direct-read.test.ts @@ -1,3 +1,5 @@ +import { settleWslAutomountRoot } from '../../../wsl/filesystem/__fixtures__/wsl-automount-root-fixture' +import { resetWslAutomountRootsForTests } from '../../../wsl/filesystem/wsl-automount-root' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createFakeSpawnedChild } from '../../../../shared/__fixtures__/fake-spawned-child' @@ -67,7 +69,9 @@ async function withPlatform(platform: NodeJS.Platform, run: () => Promise) } describe('WSL direct Git reads', () => { - beforeEach(() => { + beforeEach(async () => { + resetWslAutomountRootsForTests() + await settleWslAutomountRoot() execFileMock.mockReset() execFileSyncMock.mockReset() spawnMock.mockReset() @@ -78,6 +82,7 @@ describe('WSL direct Git reads', () => { }) afterEach(() => { + resetWslAutomountRootsForTests() resetWslGitReadEnvironmentForTests() resetWslLinkedWorktreeGitRoutingForTests() resetWslWindowsCheckoutGitConfigForTests() diff --git a/src/main/git/command-runner/host-routing/windows-host-git-environment.ts b/src/main/git/command-runner/host-routing/windows-host-git-environment.ts index 8d6c85afe31..62fae93bd36 100644 --- a/src/main/git/command-runner/host-routing/windows-host-git-environment.ts +++ b/src/main/git/command-runner/host-routing/windows-host-git-environment.ts @@ -1,5 +1,6 @@ import { createAbortError } from '../process-capture/abort-error' -import { resolveGitCommand } from './git-command-resolution' +import { wslDistroForCommand } from '../../../../shared/wsl-paths' +import { usesHostGitForWslLinkedWorktree } from './wsl-linked-worktree-git-routing' import type { ResolvedCommand } from './wsl-command-resolution' let waitForWindowsHostGitEnvironment: (() => Promise) | null = null @@ -15,8 +16,17 @@ export async function awaitWindowsHostGitEnvironmentReady(options: { wslDistro?: string signal?: AbortSignal }): Promise { - const resolved = resolveGitCommand(['--version'], options) - await prepareWindowsHostGitEnvironment(resolved, undefined, options.signal) + if ( + wslDistroForCommand(options.cwd, options.wslDistro) && + !usesHostGitForWslLinkedWorktree(options.cwd, options.wslDistro) + ) { + return + } + await prepareWindowsHostGitEnvironment( + { binary: 'git', args: ['--version'], cwd: options.cwd, wsl: null, wslMode: null }, + undefined, + options.signal + ) } function refreshWindowsHostPath(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv | undefined { diff --git a/src/main/git/command-runner/host-routing/wsl-command-resolution.ts b/src/main/git/command-runner/host-routing/wsl-command-resolution.ts index 1d6c14ed6fd..f468c6d1853 100644 --- a/src/main/git/command-runner/host-routing/wsl-command-resolution.ts +++ b/src/main/git/command-runner/host-routing/wsl-command-resolution.ts @@ -1,3 +1,4 @@ +import type { GitExecutionContext } from './wsl-git-path-preparation' import { assertWslDistroAllowed } from '@orca/process-host/wsl-distro-allowlist' import { getDefaultWslDistro } from '../../../wsl/discovery/distro-catalog' import { parseWslPath } from '../../../wsl/filesystem/unc-path-access' @@ -18,6 +19,13 @@ import { import { translateArgForWsl, translateArgsForWsl } from './wsl-path-translation' import { resolveWslInteropSpawnCwd } from '../../../wsl/execution/wsl-interop-spawn-directory' import { noteWslDistroLaunched } from '../../../wsl/discovery/wsl-background-distro-gate' +import { + type WslAutomountSnapshot, + isWindowsDrivePath, + peekWslAutomountRoot, + quoteWslDrivePathForShell, + wslAutomountRootShellSetup +} from '../../../wsl/filesystem/wsl-automount-root' // Carry Git locale pins in the WSL command; these assignment values are shell-safe. const GIT_OUTPUT_LOCALE_SHELL_PREFIX = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV) @@ -53,6 +61,7 @@ export type ResolvedCommand = { wslMode: 'direct-git' | 'login-shell' | 'non-login-shell' | null /** Present only for a fenced (buffered) login-shell read. */ captured?: WslCapturedLoginShellCommand + executionContext?: GitExecutionContext termination?: WslProcessGroupTermination } @@ -87,6 +96,9 @@ export function resolveCommand( wslGitReadEnvironment?: WslGitReadEnvironment env?: NodeJS.ProcessEnv terminationBarrier?: boolean + /** Pure command-line sizing must not publish a distro wake. */ + noteWslLaunch?: boolean + wslPaths?: WslAutomountSnapshot /** Pre-quoted shell expression that names the program inside WSL, replacing `command`. */ wslShellCommand?: string /** Distinguish failed cd from rg's no-match exit 1. */ @@ -107,9 +119,18 @@ export function resolveCommand( // Every Git/gh/glab path into a distro resolves here, whichever spawn API then runs it. assertWslDistroAllowed('wsl.exe', ['-d', wsl.distro]) // Every WSL Git/CLI run is about to start the distro; gated polls stop trusting a 'stopped' read. - noteWslDistroLaunched(wsl.distro) + if (options.noteWslLaunch !== false) { + noteWslDistroLaunched(wsl.distro) + } - const translatedArgs = translateArgsForWsl(args) + const coldDrivePaths = + (options.wslPaths ? options.wslPaths.root : peekWslAutomountRoot(wsl.distro)) === null && + (args.some(isWindowsDrivePath) || Boolean(cwd && !cwdWsl && isWindowsDrivePath(cwd))) + // Git's awaited preparation must settle real paths for its output identity conversion too. + const translatedArgs = + coldDrivePaths && command !== 'git' + ? args + : translateArgsForWsl(args, wsl.distro, options.wslPaths) // WSL does not inherit unlisted Windows env vars; carry locale pins in the command. const localePrefix = command === 'git' @@ -121,17 +142,33 @@ export function resolveCommand( : '' const escapedCommand = options.wslShellCommand ?? quotePosixShell(command) // Quote each argument against shell splitting and expansion. - const escapedArgs = translatedArgs.map(quotePosixShell) - const linuxCwd = cwdWsl?.linuxPath ?? (cwd && wslDistroOverride ? translateArgForWsl(cwd) : null) + const quoteArg = (arg: string): string => + coldDrivePaths && command !== 'git' + ? (quoteWslDrivePathForShell(arg, wsl.distro) ?? + quotePosixShell(translateArgForWsl(arg, wsl.distro, options.wslPaths))) + : quotePosixShell(arg) + const escapedArgs = translatedArgs.map(quoteArg) + const quotedCwd = cwdWsl + ? quotePosixShell(cwdWsl.linuxPath) + : cwd && wslDistroOverride + ? coldDrivePaths && command !== 'git' + ? quoteArg(cwd) + : quotePosixShell(translateArgForWsl(cwd, wsl.distro, options.wslPaths)) + : null + const linuxCwd = + cwdWsl?.linuxPath ?? + (cwd && wslDistroOverride && !coldDrivePaths + ? translateArgForWsl(cwd, wsl.distro, options.wslPaths) + : null) const invocation = `${localePrefix}${editorPrefix}${escapedCommand} ${escapedArgs.join(' ')}` - const enterCwd = linuxCwd + const enterCwd = quotedCwd ? options.cwdFailureExitCode === undefined - ? `cd ${quotePosixShell(linuxCwd)} && ` - : `cd ${quotePosixShell(linuxCwd)} || exit ${Math.trunc(options.cwdFailureExitCode)}; ` + ? `cd ${quotedCwd} && ` + : `cd ${quotedCwd} || exit ${Math.trunc(options.cwdFailureExitCode)}; ` : '' - const shellCmd = `${enterCwd}${invocation}` + const shellCmd = `${coldDrivePaths && command !== 'git' ? wslAutomountRootShellSetup(options.cwdFailureExitCode) : ''}${enterCwd}${invocation}` - if (command === 'git' && options.wslGitReadEnvironment) { + if (command === 'git' && options.wslGitReadEnvironment && !coldDrivePaths) { const optionalLocks = options.env?.GIT_OPTIONAL_LOCKS return withWslProcessGroupTermination( { diff --git a/src/main/git/command-runner/host-routing/wsl-drive-checkout-git-routing.ts b/src/main/git/command-runner/host-routing/wsl-drive-checkout-git-routing.ts deleted file mode 100644 index 09a3c985882..00000000000 --- a/src/main/git/command-runner/host-routing/wsl-drive-checkout-git-routing.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { GitAdmissionTier } from '../../../../shared/rpc-contract/git-admission-tier-params' -import { - isWslLinkedWorktreeGitRoutingCandidate, - prepareWslLinkedWorktreeGitRouting, - usesHostGitForWslLinkedWorktree -} from './wsl-linked-worktree-git-routing' -import { pendingWslWindowsCheckoutGitConfig } from './wsl-windows-checkout-git-probe' - -/** Settle checkout ownership and Windows-Git config before synchronous routing; null means no drive-checkout command. */ -export function pendingWslDriveCheckoutGitRouting( - args: readonly string[], - options: { - cwd: string - wslDistro?: string - signal?: AbortSignal - admissionTier?: GitAdmissionTier - } -): Promise | null { - if (!isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { - return null - } - return (async () => { - await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { - signal: options.signal - }) - if (usesHostGitForWslLinkedWorktree(options.cwd, options.wslDistro)) { - return - } - await pendingWslWindowsCheckoutGitConfig(args, options) - })() -} diff --git a/src/main/git/command-runner/host-routing/wsl-drive-path-execution.test.ts b/src/main/git/command-runner/host-routing/wsl-drive-path-execution.test.ts new file mode 100644 index 00000000000..a79a7a20542 --- /dev/null +++ b/src/main/git/command-runner/host-routing/wsl-drive-path-execution.test.ts @@ -0,0 +1,397 @@ +import { buildNetworkSshPolicyEnv } from '../git-environment/git-ssh-policy-env' +import { captureGitExecutionContext } from './wsl-git-path-preparation' +import { + pendingWslWindowsCheckoutGitConfig, + resetWslWindowsCheckoutProbesForTests +} from './wsl-windows-checkout-git-probe' +import { PLAIN_WINDOWS_CLONE_SAMPLE } from './wsl-windows-checkout-line-endings.test-fixture' +import { spawnSync } from 'node:child_process' +import type * as Admission from '../admission/git-subprocess-admission' +import type * as ProcessHost from '@orca/process-host' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const { read, capture, admissionWait, environmentWait } = vi.hoisted(() => ({ + read: vi.fn(), + capture: vi.fn(), + admissionWait: vi.fn(), + environmentWait: vi.fn() +})) +vi.mock('../../../wsl/execution/wsl-guest-environment', () => ({ + getWslGuestEnvironment: (...args: unknown[]) => environmentWait(...args) +})) +vi.mock('../process-capture/exec-file-capture', () => ({ + execFileCapture: capture, + execFileCaptureToTermination: capture +})) +vi.mock('../admission/git-subprocess-admission', async (original) => { + const actual = await original() + return { + ...actual, + acquireGitAdmission: async (request: Parameters[0]) => { + await admissionWait() + return actual.acquireGitAdmission(request) + } + } +}) +vi.mock('@orca/process-host', async (original) => ({ + ...(await original()), + runProcess: read +})) + +import { + ensureWslAutomountRoot, + captureWslAutomountSnapshot, + settleWslAutomountSnapshot, + resetWslAutomountRootsForTests +} from '../../../wsl/filesystem/wsl-automount-root' +import { noteWslDistroMayHaveStopped } from '../../../wsl/discovery/wsl-background-distro-gate' +import { + gitResultPathTranslator, + translateArgForWsl, + translateWslOutputPaths +} from './wsl-path-translation' +import { bulkPathspecCommands } from '../../source-control/git-pathspec' +import { gitExecFileAsync, gitExecFileAsyncBuffer } from '../git-exec-file' +import { + setLocalGitRuntimeRouter, + setBackgroundWslGitGate, + resetWslGitAdmissionForTests +} from './local-git-runtime-route' +import { + prepareWslLinkedWorktreeGitRouting, + WSL_LINKED_WORKTREE_ROUTE_TTL_MS, + seedWslLinkedWorktreeGitRoutingForTests, + resetWslLinkedWorktreeGitRoutingForTests +} from './wsl-linked-worktree-git-routing' +import { + recordHostAutocrlf, + wslWindowsCheckoutKey, + withWslWindowsCheckoutGitConfig, + resetWslWindowsCheckoutGitConfigForTests +} from './wsl-windows-checkout-git-config' +import { resolveCommand } from './wsl-command-resolution' + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +function rootReply(args: string[], root: string) { + const command = args.at(-1) ?? '' + const nonce = /__ORCA_WSL_CAPTURE_BEGIN_ (\w+)__/.exec(command)?.[1] + return { + code: 0, + timedOut: false, + stderr: '', + stdout: `__ORCA_WSL_CAPTURE_BEGIN_${nonce}__${root}c/\n__ORCA_WSL_CAPTURE_END_${nonce}__` + } +} + +beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + resetWslAutomountRootsForTests() + read.mockReset() + environmentWait.mockReset().mockResolvedValue(null) + capture.mockReset().mockImplementation(async (_binary, _args, options) => { + options.onChildTerminated?.() + return { + stdout: options.encoding === 'buffer' ? Buffer.from('host result') : 'host result', + stderr: '' + } + }) + admissionWait.mockReset().mockResolvedValue(undefined) +}) +afterEach(() => { + resetWslAutomountRootsForTests() + resetWslLinkedWorktreeGitRoutingForTests() + resetWslWindowsCheckoutGitConfigForTests() + resetWslWindowsCheckoutProbesForTests() + resetWslGitAdmissionForTests() + setLocalGitRuntimeRouter(null) + vi.restoreAllMocks() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } +}) + +it('keeps selected-distro drive and UNC identities through cold reads, cancellation and restart', async () => { + const release = Promise.withResolvers() + read.mockImplementation(async ({ args }: { args: string[] }) => { + await release.promise + return rootReply(args, args[1] === 'Custom' ? '/qamnt/' : '/mnt/') + }) + const aborted = new AbortController() + const canceled = ensureWslAutomountRoot('Custom', aborted.signal) + const surviving = ensureWslAutomountRoot('Custom') + aborted.abort() + await expect(canceled).rejects.toBe(aborted.signal.reason) + release.resolve() + await surviving + await ensureWslAutomountRoot('Default') + + expect(translateArgForWsl(String.raw`C:\repo`, 'Custom')).toBe('/qamnt/c/repo') + expect(translateArgForWsl(String.raw`C:\repo`, 'Default')).toBe('/mnt/c/repo') + expect( + translateWslOutputPaths('worktree /qamnt/c/repo\n', String.raw`C:\repo`, { + wslDistro: 'Custom' + }) + ).toBe('worktree C:\\repo\n') + expect( + translateWslOutputPaths( + 'worktree /qamnt/c/repo\n', + String.raw`\\wsl.localhost\Custom\qamnt\c\repo` + ) + ).toBe('worktree \\\\wsl.localhost\\Custom\\qamnt\\c\\repo\n') + expect( + translateWslOutputPaths('worktree /mnt/c/ordinary\n', String.raw`C:\repo`, { + wslDistro: 'Custom' + }) + ).toBe('worktree \\\\wsl.localhost\\Custom\\mnt\\c\\ordinary\n') + + noteWslDistroMayHaveStopped('Custom') + read.mockImplementation(async ({ args }: { args: string[] }) => rootReply(args, '/new/')) + await ensureWslAutomountRoot('Custom') + expect(translateArgForWsl(String.raw`C:\repo`, 'Custom')).toBe('/new/c/repo') + expect(translateArgForWsl(String.raw`C:\repo`, 'Default')).toBe('/mnt/c/repo') +}) + +it.skipIf(process.platform === 'win32')( + 'runs cold immediate shell operands literally and never invokes the program after conversion fails', + () => { + const command = resolveCommand( + '/usr/bin/printf', + ['%s\n', "C:\\space\\$value\\O'Brien\nnext"], + undefined, + 'Custom', + { + cwdFailureExitCode: 77 + } + ) + const payload = command.args.at(-1) ?? '' + const success = spawnSync( + '/bin/bash', + ['-c', `wslpath() { printf %s /qamnt/c/; }; ${payload}`], + { + encoding: 'utf8' + } + ) + expect(success.status).toBe(0) + expect(success.stdout).toBe("/qamnt/c/space/$value/O'Brien\nnext\n") + const failure = spawnSync('/bin/bash', ['-c', `wslpath() { return 73; }; ${payload}`], { + encoding: 'utf8' + }) + expect(failure.status).toBe(77) + expect(failure.stdout).toBe('') + } +) + +it('lets a restarted request complete before the obsolete root read settles', async () => { + const obsolete = Promise.withResolvers() + let first = true + read.mockImplementation(async ({ args }: { args: string[] }) => { + if (first) { + first = false + await obsolete.promise + return rootReply(args, '/old/') + } + return rootReply(args, '/new/') + }) + const old = ensureWslAutomountRoot('Custom') + await Promise.resolve() + noteWslDistroMayHaveStopped('Custom') + await expect(ensureWslAutomountRoot('Custom')).resolves.toBe('/new/') + obsolete.resolve() + await expect(old).resolves.toBe('/new/') + expect(translateArgForWsl('C:\\repo', 'Custom')).toBe('/new/c/repo') +}) + +it('keeps cold UNC output usable without guessing a Windows mount', () => { + expect( + translateWslOutputPaths( + 'worktree /home/me/repo\n', + String.raw`\\wsl.localhost\Custom\home\me\repo` + ) + ).toBe('worktree \\\\wsl.localhost\\Custom\\home\\me\\repo\n') +}) + +it('sizes a cold host-owned linked checkout without a guest root', async () => { + seedWslLinkedWorktreeGitRoutingForTests(String.raw`C:\repo\linked`) + await expect( + bulkPathspecCommands(['add', '--'], ['file.txt'], String.raw`C:\repo\linked`, { + wslDistro: 'Custom' + }) + ).resolves.toEqual([['add', '--', ':(literal)file.txt']]) +}) + +it.each([750, 7500])( + 'sizes %s warm background paths without another distro admission', + async (count) => { + read.mockImplementation(async ({ args }: { args: string[] }) => rootReply(args, '/qamnt/')) + await ensureWslAutomountRoot('Custom') + setLocalGitRuntimeRouter(() => 'Custom') + const gate = vi.fn(async () => true) + setBackgroundWslGitGate(gate) + for (let i = 0; i < count; i++) { + await expect( + bulkPathspecCommands(['ls-files', '--'], ['file.txt'], String.raw`C:\repo`, { + admissionTier: 'background' + }) + ).resolves.toEqual([['ls-files', '--', ':(literal)file.txt']]) + } + expect(gate).not.toHaveBeenCalled() + noteWslDistroMayHaveStopped('Custom') + gate.mockResolvedValue(false) + await expect( + bulkPathspecCommands(['ls-files', '--'], [], String.raw`C:\repo`, { + admissionTier: 'background' + }) + ).resolves.toEqual([]) + expect(gate).not.toHaveBeenCalled() + await expect( + bulkPathspecCommands(['ls-files', '--'], ['file.txt'], String.raw`C:\repo`, { + admissionTier: 'background' + }) + ).rejects.toMatchObject({ reason: 'distro-stopped' }) + expect(read).toHaveBeenCalledTimes(1) + } +) + +it('never launches queued Git with the root invalidated during admission', async () => { + read.mockImplementation(async ({ args }: { args: string[] }) => rootReply(args, '/old/')) + await ensureWslAutomountRoot('Custom') + recordHostAutocrlf(wslWindowsCheckoutKey(String.raw`C:\repo`, 'Custom'), 'other', Date.now()) + admissionWait.mockImplementationOnce(async () => { + noteWslDistroMayHaveStopped('Custom') + }) + await expect( + gitExecFileAsync(['restore', '--worktree', '--', 'file'], { + cwd: String.raw`C:\repo`, + wslDistro: 'Custom' + }) + ).rejects.toThrow("couldn't read Windows drive paths") + expect(capture).not.toHaveBeenCalled() + read.mockImplementation(async ({ args }: { args: string[] }) => rootReply(args, '/new/')) + await expect( + gitExecFileAsync(['restore', '--worktree', '--', 'file'], { + cwd: String.raw`C:\repo`, + wslDistro: 'Custom' + }) + ).resolves.toEqual({ stdout: 'host result', stderr: '' }) + expect(capture.mock.calls[0]?.[1]?.join(' ')).toContain('/new/c/repo') +}) + +it.each([gitExecFileAsync, gitExecFileAsyncBuffer])( + 'retains a finite host-linked route across an admission longer than its TTL (%s)', + async (run) => { + const cwd = String.raw`C:\repo\linked` + const now = 1_000_000 + const clock = vi.spyOn(Date, 'now').mockReturnValue(now) + await prepareWslLinkedWorktreeGitRouting(cwd, 'Custom', { + fileSystem: { + stat: async () => ({ isDirectory: () => false, isFile: () => true }), + readFile: async () => 'gitdir: C:/repo/.git/worktrees/linked\n' + } + }) + admissionWait.mockImplementationOnce(async () => { + clock.mockReturnValue(now + WSL_LINKED_WORKTREE_ROUTE_TTL_MS + 1) + }) + await run(['status', '--short'], { cwd, wslDistro: 'Custom' }) + expect(capture.mock.calls[0]?.[0]).toBe('git') + expect(capture.mock.calls[0]?.[2]?.cwd).toBe(String.raw`C:\repo\linked`) + expect(read).not.toHaveBeenCalled() + } +) + +it('interprets completed worktree bytes in their producing epoch, and settles a cold UNC epoch', async () => { + read.mockImplementation(async ({ args }: { args: string[] }) => rootReply(args, '/old/')) + await ensureWslAutomountRoot('Custom') + const completed = { + executionContext: { kind: 'wsl' as const, paths: captureWslAutomountSnapshot('Custom') } + } + noteWslDistroMayHaveStopped('Custom') + read.mockImplementation(async ({ args }: { args: string[] }) => rootReply(args, '/new/')) + await ensureWslAutomountRoot('Custom') + const translate = await gitResultPathTranslator(completed, String.raw`C:\repo`, { + wslDistro: 'Custom' + }) + expect(translate('/old/c/wt')).toBe(String.raw`C:\wt`) + expect(translate('/old/c/wt\nsecond line')).toBe('C:\\wt\nsecond line') + resetWslAutomountRootsForTests() + read.mockImplementation(async ({ args }: { args: string[] }) => rootReply(args, '/mnt/')) + const cold = { + executionContext: { kind: 'wsl' as const, paths: captureWslAutomountSnapshot('Default') } + } + const fromUNC = await gitResultPathTranslator( + cold, + String.raw`\\wsl.localhost\Default\home\me\repo` + ) + expect(fromUNC('/mnt/c/wt')).toBe(String.raw`C:\wt`) +}) + +it('does not start optional metadata discovery after a stop between scheduling and invocation', async () => { + const snapshot = captureWslAutomountSnapshot('Custom') + const settlement = settleWslAutomountSnapshot(snapshot) + noteWslDistroMayHaveStopped('Custom') + await expect(settlement).resolves.toMatchObject({ root: null }) + expect(read).not.toHaveBeenCalled() + const toHostPath = await gitResultPathTranslator( + { executionContext: { kind: 'wsl', paths: snapshot } }, + String.raw`\\wsl.localhost\Custom\home\me\repo` + ) + expect(toHostPath('/qamnt/c/wt')).toBe(String.raw`\\wsl.localhost\Custom\qamnt\c\wt`) + read.mockImplementation(async ({ args }: { args: string[] }) => rootReply(args, '/new/')) + await expect(ensureWslAutomountRoot('Custom')).resolves.toBe('/new/') +}) + +it('fences the SSH policy probe after admission before any stale cwd reaches capture', async () => { + read.mockImplementation(async ({ args }: { args: string[] }) => rootReply(args, '/old/')) + await ensureWslAutomountRoot('Custom') + const options = { + cwd: String.raw`C:\repo`, + wslDistro: 'Custom', + executionContext: captureGitExecutionContext({ cwd: String.raw`C:\repo`, wslDistro: 'Custom' }) + } + admissionWait.mockImplementationOnce(async () => { + noteWslDistroMayHaveStopped('Custom') + }) + await expect(buildNetworkSshPolicyEnv(options, ['fetch'])).rejects.toThrow( + "couldn't read Windows drive paths" + ) + expect(capture).not.toHaveBeenCalled() +}) + +it('discards checkout EOL evidence when its prepared path expires during the preferred environment wait', async () => { + const cwd = String.raw`C:\repo` + const key = wslWindowsCheckoutKey(cwd, 'Custom') + read.mockImplementation(async ({ args }: { args: string[] }) => rootReply(args, '/old/')) + await ensureWslAutomountRoot('Custom') + recordHostAutocrlf(key, 'true', Date.now()) + environmentWait.mockImplementationOnce(async () => { + noteWslDistroMayHaveStopped('Custom') + return null + }) + const deps = { + checkoutIdentity: async () => 'clone', + admit: async () => ({ queueWaitMs: 0, release: () => {} }) + } + await pendingWslWindowsCheckoutGitConfig( + ['add', '--', 'file'], + { cwd, wslDistro: 'Custom' }, + deps + ) + expect(withWslWindowsCheckoutGitConfig(['add'], cwd, 'Custom')).not.toContain( + 'core.autocrlf=true' + ) + resetWslWindowsCheckoutProbesForTests() + read.mockImplementation(async ({ args }: { args: string[] }) => { + if (args.at(-1)?.includes('wslpath')) { + return rootReply(args, '/new/') + } + expect(args).toContain('/new/c/repo') + return { code: 0, timedOut: false, stdout: PLAIN_WINDOWS_CLONE_SAMPLE, stderr: '' } + }) + await ensureWslAutomountRoot('Custom') + await pendingWslWindowsCheckoutGitConfig( + ['add', '--', 'file'], + { cwd, wslDistro: 'Custom' }, + deps + ) + expect(withWslWindowsCheckoutGitConfig(['add'], cwd, 'Custom')).toContain('core.autocrlf=true') +}) diff --git a/src/main/git/command-runner/host-routing/wsl-git-path-preparation.ts b/src/main/git/command-runner/host-routing/wsl-git-path-preparation.ts new file mode 100644 index 00000000000..ace413dbe1d --- /dev/null +++ b/src/main/git/command-runner/host-routing/wsl-git-path-preparation.ts @@ -0,0 +1,111 @@ +import { createAbortError } from '../process-capture/abort-error' +import type { GitExecOptions } from '../git-exec-options' +import type { ResolvedCommand } from './wsl-command-resolution' +import type { GitAdmissionTier } from '../../../../shared/rpc-contract/git-admission-tier-params' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting, + usesHostGitForWslLinkedWorktree +} from './wsl-linked-worktree-git-routing' +import { pendingWslWindowsCheckoutGitConfig } from './wsl-windows-checkout-git-probe' +import { wslDistroForCommand } from '../../../../shared/wsl-paths' +import { + captureWslAutomountSnapshot, + assertWslAutomountSnapshotCurrent, + type WslAutomountSnapshot, + ensureWslAutomountRoot, + isWindowsDrivePath, + peekWslAutomountRoot +} from '../../../wsl/filesystem/wsl-automount-root' +import { noteWslDistroLaunched } from '../../../wsl/discovery/wsl-background-distro-gate' + +export type GitExecutionContext = Readonly< + { kind: 'host' } | { kind: 'wsl'; paths: WslAutomountSnapshot } +> + +export function captureGitExecutionContext(options: { + cwd: string + wslDistro?: string + hostGit?: true +}): GitExecutionContext { + const distro = wslDistroForCommand(options.cwd, options.wslDistro) + return distro && !usesHostGitForWslLinkedWorktree(options.cwd, options.wslDistro) + ? { kind: 'wsl', paths: captureWslAutomountSnapshot(distro) } + : { kind: 'host' } +} + +/** Keep checkout ownership fixed while validating only the values used by this launch. */ +export function assertPreparedGitCommand( + command: ResolvedCommand, + args: readonly string[], + options: GitExecOptions +): void { + options.signal?.throwIfAborted() + options.admissionSignal?.throwIfAborted() + let context = command.executionContext + const driveInputs = isWindowsDrivePath(options.cwd) || args.some(isWindowsDrivePath) + if (context?.kind === 'wsl') { + noteWslDistroLaunched(context.paths.distro) + if (!driveInputs) { + context = { kind: 'wsl', paths: captureWslAutomountSnapshot(context.paths.distro) } + command.executionContext = context + } + } + if (options.canStart?.(context) === false) { + throw createAbortError() + } + if (context?.kind === 'wsl' && driveInputs) { + assertWslAutomountSnapshotCurrent(context.paths) + } +} + +export function gitResultWithExecutionContext( + result: T, + context?: GitExecutionContext +): T & { executionContext?: GitExecutionContext } { + return Object.defineProperty(result, 'executionContext', { value: context }) +} + +/** Settle guest paths and checkout ownership before synchronous command resolution. */ +export function pendingWslGitPathPreparation( + args: readonly string[], + options: { + cwd: string + wslDistro?: string + hostGit?: true + signal?: AbortSignal + admissionTier?: GitAdmissionTier + } +): Promise | null { + const distro = wslDistroForCommand(options.cwd, options.wslDistro) + if (!distro) { + return null + } + const driveCheckout = isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro) + const needsRoot = driveCheckout || args.some(isWindowsDrivePath) + if (!driveCheckout && needsRoot) { + noteWslDistroLaunched(distro) + } + if (!driveCheckout && (!needsRoot || peekWslAutomountRoot(distro) !== null)) { + return null + } + return (async () => { + options.signal?.throwIfAborted() + if (driveCheckout) { + const host = await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { + signal: options.signal + }) + if (host) { + return { kind: 'host' } + } + } + noteWslDistroLaunched(distro) + if (peekWslAutomountRoot(distro) === null) { + await ensureWslAutomountRoot(distro, options.signal) + } + if (driveCheckout) { + await pendingWslWindowsCheckoutGitConfig(args, options) + } + return { kind: 'wsl', paths: captureWslAutomountSnapshot(distro) } + })() +} diff --git a/src/main/git/command-runner/host-routing/wsl-path-translation.ts b/src/main/git/command-runner/host-routing/wsl-path-translation.ts index 4c87e843684..7eef5018caf 100644 --- a/src/main/git/command-runner/host-routing/wsl-path-translation.ts +++ b/src/main/git/command-runner/host-routing/wsl-path-translation.ts @@ -1,46 +1,92 @@ -import { - isDrvfsLinuxPath, - toWindowsWslPath, - toWindowsWslUncPath -} from '../../../../shared/wsl-paths' +import type { GitExecutionContext } from './wsl-git-path-preparation' +import { toWindowsWslUncPath } from '../../../../shared/wsl-paths' import { parseWslPath } from '../../../wsl/filesystem/unc-path-access' +import { + type WslAutomountSnapshot, + settleWslAutomountSnapshot, + peekWslAutomountRoot, + windowsDrivePathForDistro, + wslDrivePathForDistro +} from '../../../wsl/filesystem/wsl-automount-root' -/** Windows arguments need Linux spellings for WSL Git: UNC paths become native and drives map under /mnt. */ -export function translateArgsForWsl(args: string[]): string[] { - return args.map(translateArgForWsl) +/** Windows operands use the selected distro's filesystem namespace. */ +export function translateArgsForWsl( + args: string[], + distro: string, + snapshot?: WslAutomountSnapshot +): string[] { + return args.map((arg) => translateArgForWsl(arg, distro, snapshot)) } -export function translateArgForWsl(arg: string): string { +export function translateArgForWsl( + arg: string, + distro: string, + snapshot?: WslAutomountSnapshot +): string { const wslInfo = parseWslPath(arg) if (wslInfo) { return wslInfo.linuxPath } - const driveMatch = arg.match(/^([A-Za-z]):[/\\](.*)$/) - if (driveMatch) { - const driveLetter = driveMatch[1].toLowerCase() - const rest = driveMatch[2].replace(/\\/g, '/') - return `/mnt/${driveLetter}/${rest}` - } - - return arg + return wslDrivePathForDistro(arg, distro, snapshot) ?? arg } /** Map Linux worktree output to UNC paths Node can open on Windows. */ export function translateWslOutputPaths( output: string, originalCwd: string, - options: { wslDistro?: string } = {} + options: { wslDistro?: string; executionContext?: GitExecutionContext } = {} ): string { - const wsl = parseWslPath(originalCwd) - const distro = wsl?.distro ?? options.wslDistro - if (!distro) { - return output - } - - // A repo saved under the distro's view of a drive keeps that spelling: its worktree ids use it. - const toWindows = wsl && isDrvfsLinuxPath(wsl.linuxPath) ? toWindowsWslUncPath : toWindowsWslPath - return output.replace(/(?<=worktree )(\/.+)$/gm, (_match, linuxPath: string) => - toWindows(linuxPath, distro) + return output.replace(/(?<=worktree )(\/.+)$/gm, (_match, path: string) => + translateWslOutputPath(path, originalCwd, options) ) } + +function translateWslOutputPath( + path: string, + originalCwd: string, + options: { wslDistro?: string; executionContext?: GitExecutionContext } +): string { + const wsl = parseWslPath(originalCwd) + const context = options.executionContext + if (context?.kind === 'host' || !path.startsWith('/')) { + return path + } + const snapshot = context?.kind === 'wsl' ? context.paths : undefined + const distro = snapshot?.distro ?? wsl?.distro ?? options.wslDistro + if (!distro) { + return path + } + + // A drive-folder repo saved as UNC keeps that namespace for its worktree identities. + if ( + wsl && + ((snapshot ? snapshot.root : peekWslAutomountRoot(distro)) === null || + windowsDrivePathForDistro(wsl.linuxPath, distro, snapshot)) + ) { + return toWindowsWslUncPath(path, distro) + } + return windowsDrivePathForDistro(path, distro, snapshot) ?? toWindowsWslUncPath(path, distro) +} + +/** Resolve identity from the command that produced these bytes, before later disk/setup awaits. */ +export async function gitResultPathTranslator( + result: { executionContext?: GitExecutionContext }, + cwd: string, + options: { wslDistro?: string; signal?: AbortSignal } = {} +): Promise<((path: string) => string) & { executionContext?: GitExecutionContext }> { + const original = result.executionContext + const context = + original?.kind === 'wsl' + ? { + kind: 'wsl' as const, + paths: await settleWslAutomountSnapshot(original.paths, options.signal) + } + : original + const toHostPath = (path: string): string => + translateWslOutputPath(path, cwd, { + ...options, + executionContext: context + }) + return Object.assign(toHostPath, { executionContext: context }) +} diff --git a/src/main/git/command-runner/host-routing/wsl-windows-checkout-git-probe.ts b/src/main/git/command-runner/host-routing/wsl-windows-checkout-git-probe.ts index c969f318ae3..5c0afd7478c 100644 --- a/src/main/git/command-runner/host-routing/wsl-windows-checkout-git-probe.ts +++ b/src/main/git/command-runner/host-routing/wsl-windows-checkout-git-probe.ts @@ -4,6 +4,12 @@ import { runProcess } from '@orca/process-host' import { resolveSelectedLocalCommand } from '../../../ipc/command-path/command-path-resolver' import { runWslProcess, type WslResult, type WslSpec } from '../../../wsl/execution/wsl-runner' import { translateArgForWsl } from './wsl-path-translation' +import { + captureWslAutomountSnapshot, + assertWslAutomountSnapshotCurrent, + ensureWslAutomountRoot, + peekWslAutomountRoot +} from '../../../wsl/filesystem/wsl-automount-root' import { acquireGitAdmission } from '../admission/git-subprocess-admission' import { nonInteractiveGitEnv } from '../git-environment/git-process-env' import type { GitAdmissionTier } from '../../../../shared/rpc-contract/git-admission-tier-params' @@ -156,12 +162,17 @@ async function probeSample( wslDistro: options.wslDistro, tier: options.tier }, - () => - (deps.runWsl ?? runWslProcess)({ + async () => { + if (peekWslAutomountRoot(options.wslDistro) === null) { + await ensureWslAutomountRoot(options.wslDistro) + } + const paths = captureWslAutomountSnapshot(options.wslDistro) + return (deps.runWsl ?? runWslProcess)({ + beforeStart: () => assertWslAutomountSnapshotCurrent(paths), distro: options.wslDistro, script: SAMPLE_SCRIPT, args: [ - translateArgForWsl(options.cwd), + translateArgForWsl(options.cwd, options.wslDistro, paths), String(LINE_ENDING_SAMPLE_LIMIT), String(LINE_ENDING_SCAN_LIMIT) ], @@ -169,6 +180,7 @@ async function probeSample( timeoutMs: SAMPLE_PROBE_TIMEOUT_MS, maxOutputBytes: 1024 * 1024 }) + } ).catch(() => null) let answer: LineEndingSampleAnswer | null = null if (result && !result.timedOut) { diff --git a/src/main/git/objects/local-git-object-quarantine.test.ts b/src/main/git/objects/local-git-object-quarantine.test.ts index 012fb699a97..2fb05fd600a 100644 --- a/src/main/git/objects/local-git-object-quarantine.test.ts +++ b/src/main/git/objects/local-git-object-quarantine.test.ts @@ -1,39 +1,10 @@ -import { describe, expect, it, vi } from 'vitest' -import type * as GitObjectQuarantineModule from '../../../shared/git/objects/git-object-quarantine' -import type { - GitObjectQuarantine, - GitObjectsDirectory -} from '../../../shared/git/objects/git-object-quarantine' +import { settleWslAutomountRoot } from '../../wsl/filesystem/__fixtures__/wsl-automount-root-fixture' +import { resetWslAutomountRootsForTests } from '../../wsl/filesystem/wsl-automount-root' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { inheritedLocalObjectStore, localGitObjectsDirectory } from './local-git-object-quarantine' -import '../command-runner/host-routing/wsl-linked-worktree-git-routing' - -const { readRepoCommonDirFromGitMock } = vi.hoisted(() => ({ - readRepoCommonDirFromGitMock: vi.fn() -})) - -vi.mock('../worktrees/catalog/worktree-list-reader', () => ({ - readRepoCommonDirFromGit: readRepoCommonDirFromGitMock -})) -vi.mock('../../../shared/git/objects/git-object-quarantine', async (importOriginal) => ({ - ...(await importOriginal()), - createGitObjectQuarantine: ( - resolve: () => Promise - ): GitObjectQuarantine => ({ - run: async (command) => { - const objects = await resolve() - return command( - objects - ? { - GIT_OBJECT_DIRECTORY: `${objects.gitPath}/scratch`, - GIT_ALTERNATE_OBJECT_DIRECTORIES: objects.gitPath - } - : undefined - ) - } - }) -})) - describe('localGitObjectsDirectory', () => { + beforeEach(() => settleWslAutomountRoot()) + afterEach(() => resetWslAutomountRootsForTests()) it('translates a WSL Git common dir into a path the Windows host can open', () => { expect(localGitObjectsDirectory('/home/me/repo/.git', 'Ubuntu')).toEqual({ hostPath: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo\\.git\\objects', diff --git a/src/main/git/objects/local-git-object-quarantine.ts b/src/main/git/objects/local-git-object-quarantine.ts index 37d5187f525..376e5448a04 100644 --- a/src/main/git/objects/local-git-object-quarantine.ts +++ b/src/main/git/objects/local-git-object-quarantine.ts @@ -1,3 +1,4 @@ +import type { GitExecutionContext } from '../command-runner/host-routing/wsl-git-path-preparation' import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' import type { GitAdmissionTier } from '../../../shared/rpc-contract/git-admission-tier-params' import { @@ -6,27 +7,48 @@ import { pathApiForGitPath, type GitObjectsDirectory } from '../../../shared/git/objects/git-object-quarantine' -import { toLinuxPath, toWindowsWslPath } from '../../../shared/wsl-paths' +import { toWindowsWslUncPath } from '../../../shared/wsl-paths' +import { + type WslAutomountSnapshot, + isWslAutomountSnapshotCurrent, + isWindowsDrivePath, + peekWslAutomountRoot, + windowsDrivePathForDistro, + wslDrivePathForDistro +} from '../../wsl/filesystem/wsl-automount-root' import { parseWslPath } from '../../wsl/filesystem/unc-path-access' import { usesHostGitForWslLinkedWorktree } from '../command-runner/host-routing/wsl-linked-worktree-git-routing' -import { readRepoCommonDirFromGit } from '../worktrees/catalog/worktree-list-reader' +import { readRepoCommonDirWithContext } from '../worktrees/catalog/worktree-list-reader' export type LocalGitObjectQuarantine = { /** `env` is a full process env for `gitExecFileAsync`, or undefined to run unquarantined. */ - run: (command: (env: NodeJS.ProcessEnv | undefined) => Promise) => Promise + run: ( + command: ( + env: NodeJS.ProcessEnv | undefined, + canStart?: (context?: GitExecutionContext) => boolean + ) => Promise + ) => Promise } /** `objects/` in Git's spelling and, for WSL Git, translated to one the main process can open. */ export function localGitObjectsDirectory( commonDir: string, - wslDistro: string | undefined + wslDistro: string | undefined, + snapshot?: WslAutomountSnapshot ): GitObjectsDirectory { const hostCommonDir = wslDistro && !isWindowsAbsolutePathLike(commonDir) - ? toWindowsWslPath(commonDir, wslDistro) + ? (((snapshot ? snapshot.root : peekWslAutomountRoot(wslDistro)) === null + ? null + : windowsDrivePathForDistro(commonDir, wslDistro, snapshot)) ?? + toWindowsWslUncPath(commonDir, wslDistro)) : commonDir - // Why: Git < 2.31 prints a relative common dir, resolved against a `C:\` repo path; WSL Git needs `/mnt/c`. - const gitCommonDir = wslDistro ? toLinuxPath(commonDir) : commonDir + // Older Git returns a relative common dir resolved against the host's repo path. + const gitCommonDir = wslDistro + ? (parseWslPath(commonDir)?.linuxPath ?? + wslDrivePathForDistro(commonDir, wslDistro, snapshot) ?? + commonDir) + : commonDir return { hostPath: pathApiForGitPath(hostCommonDir).join(hostCommonDir, 'objects'), gitPath: pathApiForGitPath(gitCommonDir).join(gitCommonDir, 'objects') @@ -61,27 +83,74 @@ export function createLocalGitObjectQuarantine( spawnOnWorkerThread?: boolean } = {} ): LocalGitObjectQuarantine { - const wslDistro = parseWslPath(repoPath)?.distro ?? options.wslDistro - const quarantine = createGitObjectQuarantine(async () => { - const inherited = inheritedLocalObjectStore(wslDistro) - if (!inherited) { - return undefined + let authority: GitExecutionContext | undefined + const invalidQuarantine = new Error( + 'The temporary Git object store belongs to an obsolete execution context.' + ) + const current = (): boolean => + authority?.kind !== 'wsl' || isWslAutomountSnapshotCurrent(authority.paths) + const validate = (context?: GitExecutionContext): boolean => { + options.signal?.throwIfAborted() + if ( + !current() || + !authority || + !context || + authority.kind !== context.kind || + (authority.kind === 'wsl' && + context.kind === 'wsl' && + authority.paths.distro.toLowerCase() !== context.paths.distro.toLowerCase()) + ) { + throw invalidQuarantine } - const commonDir = await readRepoCommonDirFromGit(repoPath, { + return true + } + const quarantine = createGitObjectQuarantine(async () => { + const result = await readRepoCommonDirWithContext(repoPath, { ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), ...(options.signal ? { signal: options.signal } : {}), ...(options.spawnOnWorkerThread ? { spawnOnWorkerThread: true } : {}), ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}) }) // Probe first to settle linked-worktree routing; host Git may need a different object-store spelling. - if (!commonDir || usesHostGitForWslLinkedWorktree(repoPath, options.wslDistro)) { + if ( + !result?.executionContext || + (result.executionContext.kind === 'host' && + usesHostGitForWslLinkedWorktree(repoPath, options.wslDistro)) + ) { return undefined } - return { ...localGitObjectsDirectory(commonDir, wslDistro), ...inherited } + authority = result.executionContext + const paths = authority?.kind === 'wsl' ? authority.paths : undefined + const wslDistro = paths?.distro + const inherited = inheritedLocalObjectStore(wslDistro) + if (!inherited || !current()) { + return undefined + } + if (wslDistro && isWindowsDrivePath(result.path) && !paths?.root) { + return undefined + } + return { ...localGitObjectsDirectory(result.path, wslDistro, paths), ...inherited } }) return { - run: (command) => - // The WSL runner injects these variables in the command, without WSLENV. - quarantine.run((env) => command(env ? { ...process.env, ...env } : undefined)) + async run(command) { + if (authority && !current()) { + return command(undefined) + } + try { + return await quarantine.run((env) => { + options.signal?.throwIfAborted() + if (env && !current()) { + throw invalidQuarantine + } + return command(env ? { ...process.env, ...env } : undefined, env ? validate : undefined) + }) + } catch (error) { + if (error !== invalidQuarantine) { + throw error + } + options.signal?.throwIfAborted() + return command(undefined) + } + } } } diff --git a/src/main/git/objects/local-git-quarantine-authority.test.ts b/src/main/git/objects/local-git-quarantine-authority.test.ts new file mode 100644 index 00000000000..eafbb02e9c0 --- /dev/null +++ b/src/main/git/objects/local-git-quarantine-authority.test.ts @@ -0,0 +1,255 @@ +import type * as Fs from 'node:fs/promises' +import type * as Capture from '../command-runner/process-capture/exec-file-capture' +import type * as Admission from '../command-runner/admission/git-subprocess-admission' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const fixture = vi.hoisted(() => ({ + allocate: vi.fn(), + remove: vi.fn(), + capture: vi.fn(), + beforeAdmission: vi.fn() +})) +vi.mock('node:fs/promises', async (original) => ({ + ...(await original()), + stat: async () => { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + }, + mkdtemp: fixture.allocate, + readdir: async () => [], + rm: fixture.remove +})) +vi.mock('../command-runner/process-capture/exec-file-capture', async (original) => ({ + ...(await original()), + execFileCapture: fixture.capture +})) +vi.mock('../command-runner/admission/git-subprocess-admission', async (original) => { + const actual = await original() + return { + ...actual, + acquireGitAdmission: async (request: Parameters[0]) => { + await fixture.beforeAdmission(request.args) + return actual.acquireGitAdmission(request) + } + } +}) + +import { settleWslAutomountRoot } from '../../wsl/filesystem/__fixtures__/wsl-automount-root-fixture' +import { resetWslAutomountRootsForTests } from '../../wsl/filesystem/wsl-automount-root' +import { + noteWslDistroMayHaveStopped, + WSL_STOP_SETTLE_MS, + _resetWslBackgroundDistroGateForTests +} from '../../wsl/discovery/wsl-background-distro-gate' +import { _resetRunningWslDistroCacheForTests } from '../../wsl/discovery/wsl-running-distro-cache' +import { gitExecFileAsync } from '../command-runner/git-exec-file' +import { _resetGitAdmissionForTests } from '../command-runner/admission/git-subprocess-admission' +import { + clearGitCapabilityStateForTests, + getLocalGitCapabilityCache +} from '../command-runner/host-routing/git-capability-state' +import { + setLocalGitRuntimeRouter, + resetWslGitAdmissionForTests +} from '../command-runner/host-routing/local-git-runtime-route' +import { resetWslLinkedWorktreeGitRoutingForTests } from '../command-runner/host-routing/wsl-linked-worktree-git-routing' +import { createLocalGitObjectQuarantine } from './local-git-object-quarantine' +import { localBranchHasNoUnmergedChanges } from '../refs/resolution/local-branch-merge-proof' +import { + __resetPRConflictSummaryCachesForTests, + getPRConflictSummary, + setPRConflictSummaryRemoteFetches +} from '../../github/pull-requests/conflicts/conflict-summary' + +const cwd = String.raw`\\wsl.localhost\Custom\home\me\repo` +const scratch = String.raw`C:\repo\.git\objects\tmp_objdir-orca-merge-tree-run` +const originalPlatform = process.platform + +async function restart(): Promise { + const now = performance.now() + noteWslDistroMayHaveStopped('Custom') + vi.spyOn(performance, 'now').mockReturnValue(now + WSL_STOP_SETTLE_MS + 1) + await settleWslAutomountRoot('Custom', '/new/') +} + +beforeEach(async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + resetWslAutomountRootsForTests() + __resetPRConflictSummaryCachesForTests() + clearGitCapabilityStateForTests() + await settleWslAutomountRoot('Custom', '/old/') + fixture.remove.mockReset().mockResolvedValue(undefined) + fixture.allocate.mockReset().mockResolvedValue(scratch) + fixture.beforeAdmission.mockReset().mockResolvedValue(undefined) + fixture.capture.mockReset().mockImplementation(async (_binary, args, options) => { + options.onChildTerminated?.() + return { + stdout: args.join(' ').includes('rev-parse') ? '/old/c/repo/.git\n' : 'safe', + stderr: '' + } + }) +}) +afterEach(() => { + resetWslAutomountRootsForTests() + _resetWslBackgroundDistroGateForTests() + _resetRunningWslDistroCacheForTests() + _resetGitAdmissionForTests() + resetWslGitAdmissionForTests() + resetWslLinkedWorktreeGitRoutingForTests() + setLocalGitRuntimeRouter(null) + vi.unstubAllEnvs() + vi.restoreAllMocks() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) +}) + +it.each(['allocation', 'admission'])( + 'falls back before launching an obsolete quarantine after %s, and cleans the exact scratch store', + async (boundary) => { + if (boundary === 'allocation') { + fixture.allocate.mockImplementationOnce(async () => { + await restart() + return scratch + }) + } else { + fixture.beforeAdmission.mockImplementation(async (args: string[]) => { + if (args.includes('merge-tree')) { + await restart() + } + }) + } + const quarantine = createLocalGitObjectQuarantine(cwd) + await expect( + quarantine.run((env, canStart) => + gitExecFileAsync(['merge-tree', '--write-tree', 'HEAD', 'other'], { cwd, env, canStart }) + ) + ).resolves.toMatchObject({ stdout: 'safe' }) + const launched = fixture.capture.mock.calls.filter(([, args]) => + args.join(' ').includes('merge-tree') + ) + expect(launched).toHaveLength(1) + expect(launched[0]?.[1]?.join(' ')).not.toContain('GIT_OBJECT_DIRECTORY=') + expect(fixture.remove).toHaveBeenCalledWith(scratch, { recursive: true, force: true }) + } +) + +it.each(['unforwarded', 'unsafe forwarded'])( + 'uses the actual router-selected guest namespace for conflict preview with %s inherited stores', + async (inheritance) => { + const driveCwd = String.raw`C:\repo` + resetWslAutomountRootsForTests() + await settleWslAutomountRoot('Custom', '/qamnt/') + setLocalGitRuntimeRouter(() => 'Custom') + getLocalGitCapabilityCache({ wslDistro: 'Custom' }).rememberSupported('merge-tree-write-tree') + vi.stubEnv('GIT_ALTERNATE_OBJECT_DIRECTORIES', String.raw`C:\other\objects`) + vi.stubEnv( + 'WSLENV', + inheritance === 'unsafe forwarded' ? 'GIT_ALTERNATE_OBJECT_DIRECTORIES' : '' + ) + setPRConflictSummaryRemoteFetches({ + refreshRemoteTrackingBaseForPreview: async () => ({ ok: true }) + }) + fixture.capture.mockImplementation(async (_binary, args: string[], options) => { + options.onChildTerminated?.() + return { + stdout: args.join(' ').includes('--git-common-dir') + ? '/qamnt/c/repo/.git\n' + : args.join(' ').includes('merge-tree') + ? 'tree-oid\0file.ts\0' + : 'safe', + stderr: '' + } + }) + await expect( + getPRConflictSummary(driveCwd, 'main', 'base-oid', 'head-oid') + ).resolves.toMatchObject({ + files: ['file.ts'] + }) + const launched = fixture.capture.mock.calls.filter(([, args]) => + args.join(' ').includes('merge-tree') + ) + expect(launched).toHaveLength(1) + expect(launched[0]?.[1]?.slice(0, 3)).toEqual(['-d', 'Custom', '--exec']) + const command = launched[0]?.[1]?.join(' ') ?? '' + if (inheritance === 'unforwarded') { + expect(command).toContain('/qamnt/c/repo/.git/objects/tmp_objdir-orca-merge-tree-run') + expect(command).toContain('/qamnt/c/repo/.git/objects') + expect(command).not.toContain('C:\\other\\objects') + expect(fixture.allocate).toHaveBeenCalledWith( + String.raw`C:\repo\.git\objects\tmp_objdir-orca-merge-tree-` + ) + expect(fixture.remove).toHaveBeenCalledWith(scratch, { recursive: true, force: true }) + } else { + expect(command).not.toContain('GIT_OBJECT_DIRECTORY=') + expect(fixture.allocate).not.toHaveBeenCalled() + } + } +) + +it('propagates cancellation without an unquarantined retry', async () => { + const controller = new AbortController() + fixture.allocate.mockImplementationOnce(async () => { + controller.abort(new Error('canceled')) + return scratch + }) + const command = vi.fn((env, canStart) => + gitExecFileAsync(['merge-tree', '--write-tree', 'HEAD', 'other'], { + cwd, + env, + canStart, + signal: controller.signal + }) + ) + await expect( + createLocalGitObjectQuarantine(cwd, { signal: controller.signal }).run(command) + ).rejects.toThrow('canceled') + expect(command).not.toHaveBeenCalled() + expect(fixture.remove).toHaveBeenCalledWith(scratch, { recursive: true, force: true }) +}) + +it.each(['branch proof', 'conflict preview'])( + 'forwards quarantine authority through the actual %s caller', + async (caller) => { + getLocalGitCapabilityCache({ cwd }).rememberSupported('merge-tree-write-tree') + fixture.capture.mockImplementation(async (_binary, args: string[], options) => { + options.onChildTerminated?.() + const command = args.join(' ') + return { + stdout: command.includes('--git-common-dir') + ? '/old/c/repo/.git\n' + : command.includes('merge-tree') && command.includes('--name-only') + ? 'tree-oid\0file.ts\0' + : 'safe', + stderr: '' + } + }) + fixture.beforeAdmission.mockImplementation(async (args: string[]) => { + if (args.includes('merge-tree')) { + await restart() + } + }) + if (caller === 'branch proof') { + await expect( + localBranchHasNoUnmergedChanges({ + cwd, + branchName: 'feature', + targetRefs: ['HEAD'], + refreshRemoteTargets: false + }) + ).resolves.toBe(true) + } else { + setPRConflictSummaryRemoteFetches({ + refreshRemoteTrackingBaseForPreview: async () => ({ ok: true }) + }) + await expect( + getPRConflictSummary(cwd, 'main', 'base-oid', 'head-oid') + ).resolves.toMatchObject({ + files: ['file.ts'] + }) + } + const launched = fixture.capture.mock.calls.filter(([, args]) => + args.join(' ').includes('merge-tree') + ) + expect(launched).toHaveLength(1) + expect(launched[0]?.[1]?.join(' ')).not.toContain('GIT_OBJECT_DIRECTORY=') + expect(fixture.remove).toHaveBeenCalledWith(scratch, { recursive: true, force: true }) + } +) diff --git a/src/main/git/refs/resolution/local-branch-merge-proof.ts b/src/main/git/refs/resolution/local-branch-merge-proof.ts index b49428cfac0..a66e77cc055 100644 --- a/src/main/git/refs/resolution/local-branch-merge-proof.ts +++ b/src/main/git/refs/resolution/local-branch-merge-proof.ts @@ -1,3 +1,4 @@ +import type { GitExecutionContext } from '../../command-runner/host-routing/wsl-git-path-preparation' import { branchHasNoUnmergedChangesOnAnyTarget, branchHasNoUnmergedChangesWithLazyTargetRefresh, @@ -28,11 +29,20 @@ export async function localBranchHasNoUnmergedChanges(args: { const options = routeLocalGitOptions({ ...args.options, cwd }) const quarantine = createLocalGitObjectQuarantine(cwd, options) const runGit = (argv: string[], execOptions?: GitBranchCleanupExecOptions) => { - const run = (env: NodeJS.ProcessEnv | undefined) => + const run = ( + env: NodeJS.ProcessEnv | undefined, + quarantineCanStart?: (context?: GitExecutionContext) => boolean + ) => gitExecFileAsync(argv, { ...gitExecOptions(cwd, options), ...(execOptions?.stdin !== undefined ? { stdin: execOptions.stdin } : {}), - ...(env ? { env } : {}) + ...(env ? { env } : {}), + canStart: (context) => { + if (gitExecOptions(cwd, options).canStart?.() === false) { + return false + } + return quarantineCanStart?.(context) ?? true + } }) return execOptions?.discardWrittenObjects ? quarantine.run(run) : run(undefined) } diff --git a/src/main/git/repositories/canonical-repo-key.ts b/src/main/git/repositories/canonical-repo-key.ts index 49ce4f19dbf..6d5a3cb649e 100644 --- a/src/main/git/repositories/canonical-repo-key.ts +++ b/src/main/git/repositories/canonical-repo-key.ts @@ -1,6 +1,6 @@ import type { LocalGitExecOptions } from '../refs/resolution/repo-default-base-ref' import type { GitExecOptions } from '../command-runner/git-exec-options' -import { toWslExecutionSpace } from '../../../shared/wsl-paths' +import { gitResultPathTranslator } from '../command-runner/host-routing/wsl-path-translation' import { gitExecFileAsync } from '../command-runner/git-exec-file' import { resolveRevParsePath } from '../worktrees/catalog/worktree-path-comparison' @@ -18,7 +18,7 @@ export function readGitCommonDir(stdout: string, repoPath: string): string | und .split('\n') .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) .findLast((line) => line.length > 0 && !line.startsWith('-')) - return commonDir ? resolveRevParsePath(toWslExecutionSpace(repoPath), commonDir) : undefined + return commonDir ? resolveRevParsePath(repoPath, commonDir) : undefined } function remember(cacheKey: string, value: string): string { @@ -46,13 +46,14 @@ export async function getCanonicalRepoKey( return remember(cacheKey, cached) } try { - const { stdout } = await gitExecFileAsync( + const result = await gitExecFileAsync( ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: repoPath, ...options } ) - const commonDir = readGitCommonDir(stdout, repoPath) + const commonDir = readGitCommonDir(result.stdout, repoPath) if (commonDir) { - return remember(cacheKey, `${runtimeKey}::${commonDir}`) + const toHostPath = await gitResultPathTranslator(result, repoPath, options) + return remember(cacheKey, `${runtimeKey}::${toHostPath(commonDir)}`) } } catch (error) { // Cancellation is unavailable identity, not a caller-path identity to cache. diff --git a/src/main/git/source-control/discard-changes.ts b/src/main/git/source-control/discard-changes.ts index 83c0713862c..43b7cef4268 100644 --- a/src/main/git/source-control/discard-changes.ts +++ b/src/main/git/source-control/discard-changes.ts @@ -66,7 +66,12 @@ export async function listTrackedPathSpecs( options: GitRuntimeOptions = {} ): Promise { const trackedPaths: string[] = [] - const commands = bulkPathspecCommands(['ls-files', '-z', '--'], filePaths, worktreePath, options) + const commands = await bulkPathspecCommands( + ['ls-files', '-z', '--'], + filePaths, + worktreePath, + options + ) for (const args of commands) { const { stdout } = await gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)) // Iterate instead of spreading: tracked directories can exceed the JS argument limit. @@ -85,7 +90,12 @@ async function cleanUntrackedPaths( options: GitRuntimeOptions = {} ): Promise { // Git pathspecs avoid recursive deletion through symlinked parents; empty chunks must never invoke clean. - const commands = bulkPathspecCommands(['clean', '-ffdx', '--'], filePaths, worktreePath, options) + const commands = await bulkPathspecCommands( + ['clean', '-ffdx', '--'], + filePaths, + worktreePath, + options + ) for (const args of commands) { await gitExecFileAsync(args, { ...gitOptionsForWorktree(worktreePath, options) }) } diff --git a/src/main/git/source-control/drive-checkout-suspect-status.ts b/src/main/git/source-control/drive-checkout-suspect-status.ts index 58eff81613f..690f3438788 100644 --- a/src/main/git/source-control/drive-checkout-suspect-status.ts +++ b/src/main/git/source-control/drive-checkout-suspect-status.ts @@ -144,11 +144,11 @@ async function readTargetedStatus( options: GetStatusOptions ): Promise { const parser = new StatusPorcelainParser() - const commands = bulkPathspecCommands( + const commands = await bulkPathspecCommands( TARGETED_STATUS_ARGS, relativePaths, worktreePath, - options.wslDistro ? { wslDistro: options.wslDistro } : {} + options ) // Why sequential: chunks exist only past the command-line cap, and each one already walks drvfs. for (const args of commands) { diff --git a/src/main/git/source-control/drive-checkout-suspect-writes.ts b/src/main/git/source-control/drive-checkout-suspect-writes.ts index 2c6a6dae98d..b859e760e75 100644 --- a/src/main/git/source-control/drive-checkout-suspect-writes.ts +++ b/src/main/git/source-control/drive-checkout-suspect-writes.ts @@ -18,7 +18,7 @@ async function listMergedTrackedPaths( options: GitRuntimeOptions ): Promise { const tracked = new Set() - for (const args of bulkPathspecCommands( + for (const args of await bulkPathspecCommands( ['ls-files', '-z', '--stage', '--'], paths, worktreePath, @@ -49,7 +49,7 @@ export async function redoWriteForDriveCheckoutSuspects( // Why tracked only: an untracked or ignored suspect fails the pathspec, and minimal stat never misses one. const tracked = await listMergedTrackedPaths(worktreePath, suspects, options) // Why argv over --pathspec-from-file: `add`/`restore` only gained it in Git 2.26; suspects are capped. - const commands = bulkPathspecCommands( + const commands = await bulkPathspecCommands( ['-c', 'core.checkStat=default', ...write, '--'], tracked, worktreePath, diff --git a/src/main/git/source-control/git-pathspec.ts b/src/main/git/source-control/git-pathspec.ts index ce2b7323504..a4a4c3128b4 100644 --- a/src/main/git/source-control/git-pathspec.ts +++ b/src/main/git/source-control/git-pathspec.ts @@ -5,7 +5,17 @@ import { import { resolveGitCommandWithoutProbe } from '../command-runner/host-routing/git-command-resolution' import { wslDistroForCommand } from '../../../shared/wsl-paths' import { WINDOWS_CHECKOUT_GIT_CONFIG_ARGS } from '../command-runner/host-routing/wsl-windows-checkout-git-config' -import type { GitRuntimeOptions } from '../command-runner/git-runtime-options' +import { + gitOptionsForWorktree, + type GitRuntimeOptions +} from '../command-runner/git-runtime-options' +import { + routeLocalGitOptions, + runAdmittedWslGit +} from '../command-runner/host-routing/local-git-runtime-route' +import { peekWslAutomountRoot } from '../../wsl/filesystem/wsl-automount-root' +import { usesHostGitForWslLinkedWorktree } from '../command-runner/host-routing/wsl-linked-worktree-git-routing' +import { pendingWslGitPathPreparation } from '../command-runner/host-routing/wsl-git-path-preparation' /** Ceiling on argv entries per invocation; under WSL the byte budget bites first. */ const BULK_CHUNK_SIZE = 100 @@ -50,7 +60,8 @@ function finishedCommandLineLength( cwd: worktreePath, ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}) }, - true + true, + false ) return commandLineLength([resolved.binary, ...resolved.args]) } @@ -59,13 +70,31 @@ function finishedCommandLineLength( * Split at whole pathspecs using the resolved command-line budget; oversized single paths still ship alone. * Earlier chunks remain applied on failure; per-path operations are idempotent, so retrying converges. */ -export function bulkPathspecCommands( +export async function bulkPathspecCommands( leadingArgs: readonly string[], filePaths: readonly string[], worktreePath: string, options: GitRuntimeOptions, pathSource: 'host' | 'git' = 'host' -): string[][] { +): Promise { + if (filePaths.length === 0) { + return [] + } + const routed = routeLocalGitOptions(gitOptionsForWorktree(worktreePath, options)) + const distro = wslDistroForCommand(worktreePath, routed.wslDistro) + if ( + distro && + peekWslAutomountRoot(distro) === null && + !usesHostGitForWslLinkedWorktree(worktreePath, routed.wslDistro) + ) { + await runAdmittedWslGit(routed, async () => { + const preparation = pendingWslGitPathPreparation(leadingArgs, routed) + if (preparation) { + await preparation + } + }) + } + options = routed // Budget belongs to the host that spawns; the overhead measured above belongs to the transport. const budget = process.platform === 'win32' ? MAX_COMMAND_LINE_CHARS : POSIX_COMMAND_LINE_BUDGET const baseLength = finishedCommandLineLength(leadingArgs, worktreePath, options) diff --git a/src/main/git/source-control/status-untracked-directories.ts b/src/main/git/source-control/status-untracked-directories.ts index 18681b5a25b..319351d30cf 100644 --- a/src/main/git/source-control/status-untracked-directories.ts +++ b/src/main/git/source-control/status-untracked-directories.ts @@ -68,11 +68,11 @@ export async function listUntrackedDirectoryFiles( if (directories.some((dir) => dir.startsWith('./') || dir.startsWith('../'))) { return null } - const commands = bulkPathspecCommands( + const commands = await bulkPathspecCommands( LIST_OTHER_FILES_ARGS, directories, worktreePath, - options.wslDistro ? { wslDistro: options.wslDistro } : {} + options ) // Each extra chunk rereads the index; that many directories favour one -uall scan. if (commands.length !== 1) { diff --git a/src/main/git/worktrees/catalog/worktree-list-reader.ts b/src/main/git/worktrees/catalog/worktree-list-reader.ts index 3d698a022f5..c3fc6870fac 100644 --- a/src/main/git/worktrees/catalog/worktree-list-reader.ts +++ b/src/main/git/worktrees/catalog/worktree-list-reader.ts @@ -1,3 +1,5 @@ +import { gitResultPathTranslator } from '../../command-runner/host-routing/wsl-path-translation' +import type { GitExecutionContext } from '../../command-runner/host-routing/wsl-git-path-preparation' import { throwIfSignalAborted, waitForPromiseWithSignal @@ -5,7 +7,6 @@ import { import { annotateWorktreeLocksFromAdmin } from '../../../../shared/git/worktrees/git-worktree-admin' import { stat } from 'node:fs/promises' import type { GitWorktreeInfo } from '../../../../shared/worktree/types' -import { toWslExecutionSpace } from '../../../../shared/wsl-paths' import { hasUnsupportedRevParsePathFormatEcho, isUnsupportedRevParsePathFormatError, @@ -19,18 +20,18 @@ import { getErrorCode, gitExecOptions } from '../worktree-operation-options' -import { - areWorktreePathsEqual, - resolveRevParsePath, - translateWorktreePath -} from './worktree-path-comparison' +import { areWorktreePathsEqual, resolveRevParsePath } from './worktree-path-comparison' import { gitExecFileAsync } from '../../command-runner/git-exec-file' const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8 type RepoLocation = { topLevel: string; commonDir: string; gitDir: string } -function parseRepoLocation(repoPath: string, output: string): RepoLocation | undefined { +function parseRepoLocation( + repoPath: string, + output: string, + toHostPath: (path: string) => string +): RepoLocation | undefined { // Older Git echoes unknown flags with exit 0; keep the last three path lines and preserve edge spaces. const lines = output .split('\n') @@ -41,9 +42,9 @@ function parseRepoLocation(repoPath: string, output: string): RepoLocation | und } const [topLevel, commonDir, gitDir] = lines.slice(-3) return { - topLevel: resolveRevParsePath(repoPath, topLevel), - commonDir: resolveRevParsePath(repoPath, commonDir), - gitDir: resolveRevParsePath(repoPath, gitDir) + topLevel: resolveRevParsePath(repoPath, toHostPath(topLevel)), + commonDir: resolveRevParsePath(repoPath, toHostPath(commonDir)), + gitDir: resolveRevParsePath(repoPath, toHostPath(gitDir)) } } @@ -59,7 +60,7 @@ export async function readRepoLocation( capabilities.runWithFallback( 'rev-parse-path-format', async () => { - const { stdout } = await gitExecFileAsync( + const result = await gitExecFileAsync( [ 'rev-parse', '--path-format=absolute', @@ -69,18 +70,26 @@ export async function readRepoLocation( ], gitExecOptions(repoPath, options) ) - if (hasUnsupportedRevParsePathFormatEcho(stdout)) { + if (hasUnsupportedRevParsePathFormatEcho(result.stdout)) { // Cache the unsupported-option signal even when parsing recovers. capabilities.rememberUnsupported('rev-parse-path-format') } - return parseRepoLocation(resolveBasePath, stdout) + return parseRepoLocation( + resolveBasePath, + result.stdout, + await gitResultPathTranslator(result, repoPath, options) + ) }, async () => { - const { stdout } = await gitExecFileAsync( + const result = await gitExecFileAsync( ['rev-parse', '--show-toplevel', '--git-common-dir', '--git-dir'], gitExecOptions(repoPath, options) ) - return parseRepoLocation(resolveBasePath, stdout) + return parseRepoLocation( + resolveBasePath, + result.stdout, + await gitResultPathTranslator(result, repoPath, options) + ) }, isUnsupportedRevParsePathFormatError ) @@ -91,17 +100,15 @@ export async function readRepoLocation( } /** Bare repos reject --show-toplevel; resolve older Git's relative common dir in Git execution space. */ -export async function readRepoCommonDirFromGit( +export async function readRepoCommonDirWithContext( repoPath: string, options: GitWorktreeExecOptions = {} -): Promise { - const resolveBasePath = toWslExecutionSpace(repoPath) +): Promise<{ path: string; executionContext?: GitExecutionContext } | undefined> { const readCommonDir = (stdout: string): string | undefined => { - const commonDir = stdout + return stdout .split('\n') .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) .findLast((line) => line.length > 0 && !line.startsWith('-')) - return commonDir ? resolveRevParsePath(resolveBasePath, commonDir) : undefined } try { return await withLocalGitCapabilityCacheForExecution( @@ -110,21 +117,35 @@ export async function readRepoCommonDirFromGit( capabilities.runWithFallback( 'rev-parse-path-format', async () => { - const { stdout } = await gitExecFileAsync( + const result = await gitExecFileAsync( ['rev-parse', '--path-format=absolute', '--git-common-dir'], gitExecOptions(repoPath, options) ) - if (hasUnsupportedRevParsePathFormatEcho(stdout)) { + if (hasUnsupportedRevParsePathFormatEcho(result.stdout)) { capabilities.rememberUnsupported('rev-parse-path-format') } - return readCommonDir(stdout) + const path = readCommonDir(result.stdout) + const toHostPath = await gitResultPathTranslator(result, repoPath, options) + return path + ? { + path: resolveRevParsePath(repoPath, toHostPath(path)), + executionContext: toHostPath.executionContext + } + : undefined }, async () => { - const { stdout } = await gitExecFileAsync( + const result = await gitExecFileAsync( ['rev-parse', '--git-common-dir'], gitExecOptions(repoPath, options) ) - return readCommonDir(stdout) + const path = readCommonDir(result.stdout) + const toHostPath = await gitResultPathTranslator(result, repoPath, options) + return path + ? { + path: resolveRevParsePath(repoPath, toHostPath(path)), + executionContext: toHostPath.executionContext + } + : undefined }, isUnsupportedRevParsePathFormatError ) @@ -134,17 +155,24 @@ export async function readRepoCommonDirFromGit( } } +export async function readRepoCommonDirFromGit( + repoPath: string, + options: GitWorktreeExecOptions = {} +): Promise { + return (await readRepoCommonDirWithContext(repoPath, options))?.path +} + /** Detached HEAD returns undefined. */ export async function readCheckedOutBranchRef( worktreePath: string, options: GitWorktreeExecOptions = {} ): Promise { try { - const { stdout } = await gitExecFileAsync( + const result = await gitExecFileAsync( ['symbolic-ref', '--quiet', 'HEAD'], gitExecOptions(worktreePath, options) ) - return stdout.trim() || undefined + return result.stdout.trim() || undefined } catch { return undefined } @@ -155,11 +183,11 @@ export async function readWorktreeHeadOid( options: GitWorktreeExecOptions = {} ): Promise { try { - const { stdout } = await gitExecFileAsync( + const result = await gitExecFileAsync( ['rev-parse', 'HEAD'], gitExecOptions(worktreePath, options) ) - return stdout.trim() + return result.stdout.trim() } catch { return '' } @@ -172,13 +200,12 @@ async function normalizeMainWorktreePath( ): Promise { const mainIndex = worktrees.findIndex((worktree) => worktree.isMainWorktree) const mainWorktree = worktrees[mainIndex] - // Compare WSL output and repo paths in the same execution space to avoid a needless rev-parse. - const comparablePath = toWslExecutionSpace(repoPath) - if (!mainWorktree || areWorktreePathsEqual(mainWorktree.path, comparablePath)) { + // Reader outputs already use host paths, like the repository identity. + if (!mainWorktree || areWorktreePathsEqual(mainWorktree.path, repoPath)) { return worktrees } - const location = await readRepoLocation(repoPath, comparablePath, options) + const location = await readRepoLocation(repoPath, repoPath, options) if (!location) { return worktrees } @@ -212,25 +239,22 @@ export async function readWorktreeList( capabilities.runWithFallback( 'worktree-list-z', async () => { - const { stdout } = await gitExecFileAsync( + const result = await gitExecFileAsync( ['worktree', 'list', '--porcelain', '-z'], execOptions ) return normalizeMainWorktreePath( repoPath, - parseWorktreeList(stdout, { nulDelimited: true }), + await translateWorktreeRows(result, repoPath, options, true), options ) }, async () => { // Git <2.36 rejects -z, which preserves embedded newlines in paths. - const { stdout } = await gitExecFileAsync( - ['worktree', 'list', '--porcelain'], - execOptions - ) + const result = await gitExecFileAsync(['worktree', 'list', '--porcelain'], execOptions) const normalized = await normalizeMainWorktreePath( repoPath, - parseWorktreeList(stdout), + await translateWorktreeRows(result, repoPath, options, false), options ) // Git <2.31 omits prunable fields; verify linked paths rather than trusting stale registrations. @@ -247,7 +271,7 @@ export async function readWorktreeList( async function annotatePrunableByExistence( worktrees: GitWorktreeInfo[], - repoPath: string, + _repoPath: string, options: GitWorktreeExecOptions = {} ): Promise { const annotated = [...worktrees] @@ -270,7 +294,7 @@ async function annotatePrunableByExistence( continue } try { - await stat(translateWorktreePath(worktree.path, repoPath, options)) + await stat(worktree.path) } catch (err) { if (getErrorCode(err) === 'ENOENT') { annotated[index] = { ...worktree, prunable: true } @@ -288,12 +312,17 @@ async function annotatePrunableByExistence( return annotated } -export async function readTranslatedWorktreeGraph( +export const readTranslatedWorktreeGraph = readWorktreeList + +async function translateWorktreeRows( + result: { stdout: string; executionContext?: GitExecutionContext }, repoPath: string, - options: GitWorktreeExecOptions = {} + options: GitWorktreeExecOptions, + nulDelimited: boolean ): Promise { - return (await readWorktreeList(repoPath, options)).map((worktree) => { - const translatedPath = translateWorktreePath(worktree.path, repoPath, options) - return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath } - }) + const toHostPath = await gitResultPathTranslator(result, repoPath, options) + return parseWorktreeList(result.stdout, { nulDelimited }).map((row) => ({ + ...row, + path: toHostPath(row.path) + })) } diff --git a/src/main/git/worktrees/catalog/worktree-listing-created-sparse-distro.test.ts b/src/main/git/worktrees/catalog/worktree-listing-created-sparse-distro.test.ts index f647f2c38dd..b1a01c1721e 100644 --- a/src/main/git/worktrees/catalog/worktree-listing-created-sparse-distro.test.ts +++ b/src/main/git/worktrees/catalog/worktree-listing-created-sparse-distro.test.ts @@ -40,8 +40,8 @@ const missing = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) // A drive-spelled checkout loses the distro needed to resolve its non-drvfs gitdir pointer. const REPO = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' -const GUEST_WORKTREE = '/mnt/c/wt/x' const HOST_WORKTREE = 'C:/wt/x' +const HOST_COMMON_DIR = '//wsl.localhost/Ubuntu/home/me/repo/.git' const GUEST_GIT_DIR = '/home/me/repo/.git/worktrees/x' const HOST_GIT_DIR = '//wsl.localhost/Ubuntu/home/me/repo/.git/worktrees/x' const HEAD_OID = 'a'.repeat(40) @@ -50,11 +50,11 @@ describe('describeCreatedWorktree on a drvfs-spelled WSL worktree', () => { beforeEach(() => { readRepoLocationMock.mockReset() readRepoLocationMock.mockResolvedValue({ - topLevel: GUEST_WORKTREE, - commonDir: '/home/me/repo/.git' + topLevel: HOST_WORKTREE, + commonDir: HOST_COMMON_DIR }) readRepoCommonDirFromGitMock.mockReset() - readRepoCommonDirFromGitMock.mockResolvedValue('/home/me/repo/.git') + readRepoCommonDirFromGitMock.mockResolvedValue(HOST_COMMON_DIR) readCheckedOutBranchRefMock.mockReset() readCheckedOutBranchRefMock.mockResolvedValue('refs/heads/feature') readWorktreeHeadOidMock.mockReset() @@ -99,9 +99,9 @@ describe('describeCreatedWorktree on a drvfs-spelled WSL worktree', () => { } }) - it('uses the repo disk witness in the WSL execution namespace', async () => { + it('uses the repo disk witness in the host filesystem namespace', async () => { const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - readRepoCommonDirFromGitMock.mockResolvedValue('/other/.git') + readRepoCommonDirFromGitMock.mockResolvedValue('//wsl.localhost/Ubuntu/other/.git') statMock.mockImplementation(async (target: string) => { const value = slashed(target) if (value === `${slashed(REPO)}/.git`) { diff --git a/src/main/git/worktrees/catalog/worktree-listing-unc-drvfs-identity.test.ts b/src/main/git/worktrees/catalog/worktree-listing-unc-drvfs-identity.test.ts index bf20378f12e..ec03134501c 100644 --- a/src/main/git/worktrees/catalog/worktree-listing-unc-drvfs-identity.test.ts +++ b/src/main/git/worktrees/catalog/worktree-listing-unc-drvfs-identity.test.ts @@ -1,3 +1,5 @@ +import { settleWslAutomountRoot } from '../../../wsl/filesystem/__fixtures__/wsl-automount-root-fixture' +import { resetWslAutomountRootsForTests } from '../../../wsl/filesystem/wsl-automount-root' import type * as FsPromises from 'node:fs/promises' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getRepoMainWorktreeId } from '../../../../shared/worktree/id' @@ -39,6 +41,11 @@ vi.mock('fs/promises', async () => { import { resetWorktreeRemovalState } from '../removal/remove-worktree-test-harness' import { listWorktreesStrict } from './worktree-listing' +import { readRepoCommonDirFromGit, readRepoLocation } from './worktree-list-reader' +import { + _resetCanonicalRepoKeyCacheForTests, + getCanonicalRepoKey +} from '../../repositories/canonical-repo-key' const REPO_ID = 'repo-1' const UNC_REPO = String.raw`\\wsl.localhost\Ubuntu\mnt\c\src\app` @@ -55,8 +62,11 @@ function eperm(path: unknown): Error { const originalPlatform = process.platform -beforeEach(() => { +beforeEach(async () => { + resetWslAutomountRootsForTests() + await settleWslAutomountRoot() resetWorktreeRemovalState() + _resetCanonicalRepoKeyCacheForTests() Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) gitExecFileAsyncMock.mockReset() gitExecFileAsyncMock.mockImplementation(async (args: string[]) => ({ @@ -76,6 +86,7 @@ beforeEach(() => { }) afterEach(() => { + resetWslAutomountRootsForTests() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) }) @@ -101,4 +112,31 @@ describe('listing a UNC drive-folder repo through WSL Git', () => { String.raw`C:\src\app-feature` ]) }) + + it('resolves old-Git relative metadata against the host identity on a custom automount', async () => { + resetWslAutomountRootsForTests() + await settleWslAutomountRoot('Ubuntu', '/qamnt/') + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => ({ + stdout: args.includes('--show-toplevel') + ? '--path-format=absolute\n/qamnt/c/src/app\n.git\n.git\n' + : '--path-format=absolute\n.git\n', + stderr: '' + })) + const cwd = String.raw`C:\src\app` + await expect(readRepoCommonDirFromGit(cwd, { wslDistro: 'Ubuntu' })).resolves.toBe( + String.raw`C:\src\app\.git` + ) + await expect(readRepoLocation(cwd, cwd, { wslDistro: 'Ubuntu' })).resolves.toEqual({ + topLevel: String.raw`C:\src\app`, + commonDir: String.raw`C:\src\app\.git`, + gitDir: String.raw`C:\src\app\.git` + }) + await expect(getCanonicalRepoKey(cwd, { wslDistro: 'Ubuntu' })).resolves.toBe( + String.raw`wsl:Ubuntu::C:\src\app\.git` + ) + gitExecFileAsyncMock.mockResolvedValue({ stdout: '/qamnt/c/src/app/.git\n', stderr: '' }) + await expect( + getCanonicalRepoKey(String.raw`C:\src\app\linked`, { wslDistro: 'Ubuntu' }) + ).resolves.toBe(String.raw`wsl:Ubuntu::C:\src\app\.git`) + }) }) diff --git a/src/main/git/worktrees/catalog/worktree-listing.ts b/src/main/git/worktrees/catalog/worktree-listing.ts index d00872da5ca..c77d50fa014 100644 --- a/src/main/git/worktrees/catalog/worktree-listing.ts +++ b/src/main/git/worktrees/catalog/worktree-listing.ts @@ -8,7 +8,6 @@ import { isDefinitiveAbsence } from '../../../../shared/definitive-filesystem-ab import { resolveGitMetadataPath } from '../../../../shared/git/metadata/git-metadata-path' import { parseGitdirMarkerPayload } from '../../../../shared/git/metadata/gitdir-marker-payload' import { isWorktreeCreatePreparation } from '../../../../shared/worktree/create-preparation' -import { toWslExecutionSpace } from '../../../../shared/wsl-paths' import type { GitWorktreeInfo } from '../../../../shared/worktree/types' import { readCheckedOutBranchRef, @@ -25,7 +24,7 @@ import { isNotGitRepositoryError, normalizeLocalBranchRef } from '../worktree-operation-options' -import { areWorktreePathsEqual, translateWorktreePath } from './worktree-path-comparison' +import { areWorktreePathsEqual } from './worktree-path-comparison' import { detectSparseCheckoutCached } from '../sparse-checkout/worktree-sparse-checkout-cache' const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8 @@ -86,10 +85,7 @@ export async function listWorktreesStrict( repoPath: string, options: GitWorktreeExecOptions = {} ): Promise { - const worktrees = (await readWorktreeList(repoPath, options)).map((worktree) => { - const translatedPath = translateWorktreePath(worktree.path, repoPath, options) - return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath } - }) + const worktrees = await readWorktreeList(repoPath, options) const visibleWorktrees = options.includeCreatePreparations ? worktrees : worktrees.filter((worktree) => !isWorktreeCreatePreparation(worktree)) @@ -138,7 +134,7 @@ async function readRepoCommonDirFromDisk( const dotGit = join(repoPath, '.git') try { const commonDir = await withDeadline(resolveRepoCommonDirFromDisk(repoPath, dotGit), timeoutMs) - return commonDir ? toWslExecutionSpace(commonDir) : undefined + return commonDir } catch (error) { // A bare repo has no `.git`; do not fabricate a candidate for it. if (isDefinitiveAbsence(error)) { @@ -248,7 +244,7 @@ export async function describeCreatedWorktree( timeout: options.timeout ?? WORKTREE_LIST_TIMEOUT_MS } const [created, repoGitCommonDir, checkedOutRef, head] = await Promise.all([ - readRepoLocation(worktreePath, toWslExecutionSpace(worktreePath), deadlined), + readRepoLocation(worktreePath, worktreePath, deadlined), readRepoCommonDirFromGit(repoPath, deadlined), readCheckedOutBranchRef(worktreePath, deadlined), readWorktreeHeadOid(worktreePath, deadlined) @@ -270,7 +266,7 @@ export async function describeCreatedWorktree( repoPath, [ { - path: translateWorktreePath(created.topLevel, repoPath, options), + path: created.topLevel, head, branch: expectedRef, isBare: false, diff --git a/src/main/git/worktrees/checkout/checkout.ts b/src/main/git/worktrees/checkout/checkout.ts index bd1b0626701..5a008b0e87f 100644 --- a/src/main/git/worktrees/checkout/checkout.ts +++ b/src/main/git/worktrees/checkout/checkout.ts @@ -7,7 +7,7 @@ import { } from '../../command-runner/git-runtime-options' import { gitExecFileAsync } from '../../command-runner/git-exec-file' import { gitEnvWithoutYesNoPrompt } from '../../command-runner/git-environment/git-no-prompt-env' -import { translateWorktreePath } from '../catalog/worktree-path-comparison' +import { gitResultPathTranslator } from '../../command-runner/host-routing/wsl-path-translation' import { parseCheckoutSkippedPaths } from './checkout-skipped-paths' import { runWithGitReadCacheInvalidation } from '../../source-control/git-read-cache-invalidation' @@ -49,7 +49,7 @@ export async function listLocalBranches( ): Promise { const execution = gitOptionsForWorktree(worktreePath, options) let extended = true - const { stdout } = await gitExecFileAsync( + const result = await gitExecFileAsync( [ 'for-each-ref', '--format=%(HEAD)%09%(refname:lstrip=2)%09%(committerdate:unix)%09%(worktreepath)', @@ -66,11 +66,12 @@ export async function listLocalBranches( execution ) }) + const toHostPath = await gitResultPathTranslator(result, worktreePath, options) let current: string | null = null const branches: string[] = [] const committedAt: Record = Object.create(null) const worktreePaths: Record = Object.create(null) - for (const line of stdout.split('\n')) { + for (const line of result.stdout.split('\n')) { if (line.length === 0) { continue } @@ -86,7 +87,7 @@ export async function listLocalBranches( committedAt[name] = Number(timestamp) * 1000 } if (occupiedPath) { - worktreePaths[name] = translateWorktreePath(occupiedPath, worktreePath, options) + worktreePaths[name] = toHostPath(occupiedPath) } } branches.sort((a, b) => { diff --git a/src/main/git/worktrees/checkout/commit-actions.ts b/src/main/git/worktrees/checkout/commit-actions.ts index d964d0118c4..45f22d2351c 100644 --- a/src/main/git/worktrees/checkout/commit-actions.ts +++ b/src/main/git/worktrees/checkout/commit-actions.ts @@ -88,7 +88,7 @@ async function finishEmptyCommitAction( ], execution ) - for (const args of bulkPathspecCommands( + for (const args of await bulkPathspecCommands( ['diff-files', '--quiet', '--ignore-submodules=none', '--'], paths.split('\0').filter(Boolean), execution.cwd, diff --git a/src/main/git/worktrees/creation/worktree-base-refresh.ts b/src/main/git/worktrees/creation/worktree-base-refresh.ts index 42860123029..0f111b8bbf3 100644 --- a/src/main/git/worktrees/creation/worktree-base-refresh.ts +++ b/src/main/git/worktrees/creation/worktree-base-refresh.ts @@ -13,7 +13,7 @@ import { type LocalBaseBranchGit } from '../../../../shared/worktree/local-base-branch-fast-forward' import { gitExecFileAsync } from '../../command-runner/git-exec-file' -import { translateWslOutputPaths } from '../../command-runner/host-routing/wsl-path-translation' +import { gitResultPathTranslator } from '../../command-runner/host-routing/wsl-path-translation' import { runWithGitReadCacheInvalidation } from '../../source-control/git-read-cache-invalidation' import type { AddWorktreeOptions, GitWorktreeExecOptions } from '../worktree-operation-options' @@ -117,11 +117,12 @@ function localBaseBranchGit(options: GitWorktreeExecOptions): LocalBaseBranchGit return { exec: (args, cwd) => gitExecFileAsync(args, execOptions(cwd)), listWorktrees: async (repoPath) => { - const { stdout } = await gitExecFileAsync( + const result = await gitExecFileAsync( ['worktree', 'list', '--porcelain'], execOptions(repoPath) ) - return parseWorktreeList(translateWslOutputPaths(stdout, repoPath, options)) + const toHostPath = await gitResultPathTranslator(result, repoPath, options) + return parseWorktreeList(result.stdout).map((row) => ({ ...row, path: toHostPath(row.path) })) } } } diff --git a/src/main/git/worktrees/removal/worktree-prune-guard.test.ts b/src/main/git/worktrees/removal/worktree-prune-guard.test.ts index c753ef7334d..2a717a24557 100644 --- a/src/main/git/worktrees/removal/worktree-prune-guard.test.ts +++ b/src/main/git/worktrees/removal/worktree-prune-guard.test.ts @@ -37,8 +37,8 @@ vi.mock('../../../../shared/wsl-paths', async (importOriginal) => ({ vi.mock('../../command-runner/host-routing/wsl-linked-worktree-git-routing', () => ({ usesHostGitForWslLinkedWorktree: () => false })) -vi.mock('../../command-runner/host-routing/wsl-drive-checkout-git-routing', () => ({ - pendingWslDriveCheckoutGitRouting: () => null +vi.mock('../../command-runner/host-routing/wsl-git-path-preparation', () => ({ + pendingWslGitPathPreparation: () => null })) import { PRUNE_GUARD_LOCK_REASON, pruneGitWorktrees } from './worktree-prune-guard' diff --git a/src/main/git/worktrees/removal/worktree-prune-guard.ts b/src/main/git/worktrees/removal/worktree-prune-guard.ts index 98c0de7bbd0..79843e87224 100644 --- a/src/main/git/worktrees/removal/worktree-prune-guard.ts +++ b/src/main/git/worktrees/removal/worktree-prune-guard.ts @@ -9,7 +9,11 @@ import { } from '../../../../shared/wsl-paths' import type { GitExecOptions } from '../../command-runner/git-exec-options' import { gitExecFileAsync } from '../../command-runner/git-exec-file' -import { pendingWslDriveCheckoutGitRouting } from '../../command-runner/host-routing/wsl-drive-checkout-git-routing' +import { pendingWslGitPathPreparation } from '../../command-runner/host-routing/wsl-git-path-preparation' +import { + routeLocalGitOptions, + runAdmittedWslGit +} from '../../command-runner/host-routing/local-git-runtime-route' import { usesHostGitForWslLinkedWorktree } from '../../command-runner/host-routing/wsl-linked-worktree-git-routing' /** Marks only the locks this module writes, so it never removes a user's own lock. */ @@ -28,7 +32,13 @@ export async function pruneGitWorktrees( return } // Decide which Git runs only once its routing is settled, as the prune itself will. - await pendingWslDriveCheckoutGitRouting(['worktree', 'prune'], options) + options = routeLocalGitOptions(options) + await runAdmittedWslGit(options, async () => { + const preparation = pendingWslGitPathPreparation(['worktree', 'prune'], options) + if (preparation) { + await preparation + } + }) const distro = usesHostGitForWslLinkedWorktree(options.cwd, options.wslDistro) ? null : wslDistroForCommand(options.cwd, options.wslDistro) diff --git a/src/main/github/pull-requests/conflicts/conflict-summary.test.ts b/src/main/github/pull-requests/conflicts/conflict-summary.test.ts index d1881deb6cb..5f076f7b138 100644 --- a/src/main/github/pull-requests/conflicts/conflict-summary.test.ts +++ b/src/main/github/pull-requests/conflicts/conflict-summary.test.ts @@ -60,7 +60,7 @@ function mockGitDispatch(overrides: Record = {}): void { if (!handler) { return Promise.reject(new Error(`unexpected git command: ${argv.join(' ')}`)) } - return handler(argv) + return handler(argv).then((result) => ({ ...result, executionContext: { kind: 'host' } })) }) } diff --git a/src/main/github/pull-requests/conflicts/conflict-summary.ts b/src/main/github/pull-requests/conflicts/conflict-summary.ts index 808ded34973..a58ccc7e8db 100644 --- a/src/main/github/pull-requests/conflicts/conflict-summary.ts +++ b/src/main/github/pull-requests/conflicts/conflict-summary.ts @@ -1,3 +1,4 @@ +import type { GitExecutionContext } from '../../../git/command-runner/host-routing/wsl-git-path-preparation' import type { PRConflictSummary } from '../../../../shared/github/pull-requests/pull-request-types' import { isUnsupportedMergeTreeMergeBaseError, @@ -246,10 +247,14 @@ async function loadConflictingFiles( { cwd: repoPath, wslDistro: localGitOptions.wslDistro }, (capabilities) => { const runMergeTree = (args: string[]): Promise<{ stdout: string }> => { - const run = (env: NodeJS.ProcessEnv | undefined) => + const run = ( + env: NodeJS.ProcessEnv | undefined, + quarantineCanStart?: (context?: GitExecutionContext) => boolean + ) => gitExecFileAsync(args, { ...gitOptionsForWorktree(repoPath, localGitOptions), - ...(env ? { env } : {}) + ...(env ? { env } : {}), + canStart: quarantineCanStart }) // Probe support before creating a quarantine that older Git cannot prune. return capabilities.isKnownSupported('merge-tree-write-tree') diff --git a/src/main/opencode/text-tasks/wsl/opencode-wsl-text-task.test.ts b/src/main/opencode/text-tasks/wsl/opencode-wsl-text-task.test.ts index 08e17715bbc..0be011264b6 100644 --- a/src/main/opencode/text-tasks/wsl/opencode-wsl-text-task.test.ts +++ b/src/main/opencode/text-tasks/wsl/opencode-wsl-text-task.test.ts @@ -1,7 +1,15 @@ +import { _resetRunningWslDistroCacheForTests } from '../../../wsl/discovery/wsl-running-distro-cache' +import { settleWslAutomountRoot } from '../../../wsl/filesystem/__fixtures__/wsl-automount-root-fixture' +import { resetWslAutomountRootsForTests } from '../../../wsl/filesystem/wsl-automount-root' +import { + _resetWslBackgroundDistroGateForTests, + noteWslDistroMayHaveStopped, + WSL_STOP_SETTLE_MS +} from '../../../wsl/discovery/wsl-background-distro-gate' import { ChildProcess } from 'node:child_process' import { PassThrough } from 'node:stream' import { z } from 'zod' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SourceControlAgentSpawnInput, SpawnedSourceControlAgentProcess @@ -66,6 +74,38 @@ function task(signal = new AbortController().signal) { } describe('runOpenCodeWslTextTask', () => { + beforeEach(async () => { + resetWslAutomountRootsForTests() + await settleWslAutomountRoot() + }) + afterEach(() => { + resetWslAutomountRootsForTests() + _resetWslBackgroundDistroGateForTests() + _resetRunningWslDistroCacheForTests() + vi.restoreAllMocks() + }) + + it('settles restarted guest operands before spawning after runtime preparation', async () => { + const guest = fakeGuest(async () => ({ ok: true, text: 'message' })) + await expect( + runOpenCodeWslTextTask(task(), { + prepare: async () => { + const now = performance.now() + noteWslDistroMayHaveStopped('Ubuntu') + vi.spyOn(performance, 'now').mockReturnValue(now + WSL_STOP_SETTLE_MS + 1) + await settleWslAutomountRoot('Ubuntu', '/qamnt/') + return prepare() + }, + spawnAgent: guest.spawnAgent + }) + ).resolves.toBe('message') + expect(guest.requests[0]?.runArgs).toEqual([ + '--model', + 'anthropic/claude-sonnet', + '--file', + '/qamnt/c/notes/context.txt' + ]) + }) it('cancels by ending the guest stdin and settles only after the guest exits', async () => { const controller = new AbortController() const guest = fakeGuest(async (_request, canceled) => { diff --git a/src/main/opencode/text-tasks/wsl/opencode-wsl-text-task.ts b/src/main/opencode/text-tasks/wsl/opencode-wsl-text-task.ts index f04f11f207b..fc4ba79b9ea 100644 --- a/src/main/opencode/text-tasks/wsl/opencode-wsl-text-task.ts +++ b/src/main/opencode/text-tasks/wsl/opencode-wsl-text-task.ts @@ -9,6 +9,12 @@ import { relayBundleCandidates } from '../../../wsl/execution/relay-bundle-paths import { ensureWslPinnedRuntime } from '../../../wsl/execution/wsl-pinned-runtime' import { runWslProcess, type WslSpec } from '../../../wsl/execution/wsl-runner' import { describeWslProcessFailure } from '../../../wsl/execution/wsl-process-failure' +import { + ensureWslAutomountRoot, + isWindowsDrivePath, + peekWslAutomountRoot +} from '../../../wsl/filesystem/wsl-automount-root' +import { noteWslDistroLaunched } from '../../../wsl/discovery/wsl-background-distro-gate' import { translateArgsForWsl } from '../../../git/command-runner/host-routing/wsl-path-translation' import { spawnSourceControlAgent } from '../../../text-generation/source-control-agent-launch' import { killSourceControlAgentProcess } from '../../../text-generation/source-control-agent-termination' @@ -113,34 +119,58 @@ function preparedGuestRuntime(distro: string): Promise /** Waits within the task's own deadline; the shared install keeps its separate bound. */ async function prepareOpenCodeWslTextTask( - distro: string, - signal: AbortSignal, + input: OpenCodeWslTextTask, deps: OpenCodeWslTextTaskDeps = {} ): Promise { + const { distro, signal } = input try { - return await waitForPromiseWithSignal((deps.prepare ?? preparedGuestRuntime)(distro), signal) + const runtime = await waitForPromiseWithSignal( + (deps.prepare ?? preparedGuestRuntime)(distro), + signal + ) + assertCurrentTextTask(input) + noteWslDistroLaunched(distro) + if ([input.cwd, ...input.prefixArgs, ...input.runArgs].some(isWindowsDrivePath)) { + if (peekWslAutomountRoot(distro) === null) { + await ensureWslAutomountRoot(distro, signal) + } + assertCurrentTextTask(input) + } + return runtime } catch (error) { if (signal.aborted) { throw signal.reason } + if (error instanceof OpenCodeTextTaskError) { + throw error + } throw new OpenCodeTextTaskError('startup', { cause: error }) } } +function assertCurrentTextTask(input: OpenCodeWslTextTask): void { + input.signal.throwIfAborted() + if (input.shouldExecute?.() === false) { + throw new OpenCodeTextTaskError('superseded') + } +} + export async function runOpenCodeWslTextTask( input: OpenCodeWslTextTask, deps: OpenCodeWslTextTaskDeps = {} ): Promise { const startedAt = Date.now() const { signal } = input - const runtime = await prepareOpenCodeWslTextTask(input.distro, signal, deps) - signal.throwIfAborted() - if (input.shouldExecute?.() === false) { - throw new OpenCodeTextTaskError('superseded') - } + assertCurrentTextTask(input) + const runtime = await prepareOpenCodeWslTextTask(input, deps) + assertCurrentTextTask(input) const nonce = randomBytes(16).toString('hex') let child: SpawnedSourceControlAgentProcess + let prefixArgs: string[] + let runArgs: string[] try { + prefixArgs = translateArgsForWsl([...input.prefixArgs], input.distro) + runArgs = translateArgsForWsl([...input.runArgs], input.distro) child = (deps.spawnAgent ?? spawnSourceControlAgent)({ // The helper Node starts without the user's startup options; OpenCode's login shell restores them. binary: '/usr/bin/env', @@ -189,10 +219,10 @@ export async function runOpenCodeWslTextTask( `${JSON.stringify({ nonce, binary: input.binary, - prefixArgs: translateArgsForWsl([...input.prefixArgs]), + prefixArgs, prompt: input.prompt, // Existing WSL launches translate Windows paths in every argument, e.g. `--file C:\x`. - runArgs: translateArgsForWsl([...input.runArgs]), + runArgs, legacyVariantFlag: input.legacyVariantFlag, operation: input.operation, commandEnv: input.commandEnv, diff --git a/src/main/wsl/discovery/wsl-distro-holder-command.ts b/src/main/wsl/discovery/wsl-distro-holder-command.ts index 3bee3f128d3..1e0fea47512 100644 --- a/src/main/wsl/discovery/wsl-distro-holder-command.ts +++ b/src/main/wsl/discovery/wsl-distro-holder-command.ts @@ -1,3 +1,5 @@ +export const WSL_DISTRO_BOOT_TIMEOUT_MS = 60_000 + import { ORCAD_WSL_HOLDER_BLOCKED_FILENAME, ORCAD_WSL_HOLDER_RELEASE_FILENAME diff --git a/src/main/wsl/discovery/wsl-distro-holder.ts b/src/main/wsl/discovery/wsl-distro-holder.ts index ea5fdd2b838..1f9dd67ac1b 100644 --- a/src/main/wsl/discovery/wsl-distro-holder.ts +++ b/src/main/wsl/discovery/wsl-distro-holder.ts @@ -7,7 +7,7 @@ import { } from './wsl-background-distro-gate' import { WslDistroNotRunningError } from '../orcad/wsl-orcad-connection-errors' import { resolveWslInteropSpawnCwd } from '../execution/wsl-interop-spawn-directory' -import { buildWslDistroHolderArgs } from './wsl-distro-holder-command' +import { buildWslDistroHolderArgs, WSL_DISTRO_BOOT_TIMEOUT_MS } from './wsl-distro-holder-command' import { listWslDistroHolderRecordDistros, readWslDistroHolderRecord, @@ -27,7 +27,6 @@ import type { WslDistroHolderState } from './wsl-distro-holder-types' -const DEFAULT_BOOT_TIMEOUT_MS = 60_000 const BOOT_POLL_INTERVAL_MS = 250 const RELEASE_EXIT_TIMEOUT_MS = 5_000 /** Replace unverifiable holders only after this monotonic grace while confirmed running. A duplicate costs a sleep and may keep the distro alive after release; log the old PID. */ @@ -60,7 +59,7 @@ export function createWslDistroHolder(dependencies: WslDistroHolderDependencies) const unverifiableSince = new Map() const sleep = dependencies.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) - const bootTimeoutMs = dependencies.bootTimeoutMs ?? DEFAULT_BOOT_TIMEOUT_MS + const bootTimeoutMs = dependencies.bootTimeoutMs ?? WSL_DISTRO_BOOT_TIMEOUT_MS const tails = new Map>() const inFlightEnsures = new Map< diff --git a/src/main/wsl/execution/wsl-runner.ts b/src/main/wsl/execution/wsl-runner.ts index 8887709aa28..31bb2b225fb 100644 --- a/src/main/wsl/execution/wsl-runner.ts +++ b/src/main/wsl/execution/wsl-runner.ts @@ -50,6 +50,8 @@ export type WslSpec = WslCommand & { signal?: AbortSignal /** Guest stdin, so secrets stay off the command line; the command must fit argv delivery. */ input?: string + /** Validate caller-owned actual operands after the optional environment wait. */ + beforeStart?: () => void } export type WslResult = { @@ -172,6 +174,7 @@ export async function runWslProcess(spec: WslSpec): Promise { // Probe and command share one deadline; never add an independent probe timeout. const remainingMs = Math.max(1, deadline - Date.now()) + spec.beforeStart?.() const result = await runProcess({ program: resolveWslExecutablePath(), args: buildWslExecArgs(spec.distro, argv, { user: spec.user }), diff --git a/src/main/wsl/filesystem/__fixtures__/wsl-automount-root-fixture.ts b/src/main/wsl/filesystem/__fixtures__/wsl-automount-root-fixture.ts new file mode 100644 index 00000000000..94b37d79390 --- /dev/null +++ b/src/main/wsl/filesystem/__fixtures__/wsl-automount-root-fixture.ts @@ -0,0 +1,16 @@ +import { followWslAutomountRoots, ensureWslAutomountRoot } from '../wsl-automount-root' +import { resolveRunningWslDistros } from '../../discovery/wsl-running-distro-cache' + +/** Feed an authoritative guest root through the production follower without a real WSL process. */ +export async function settleWslAutomountRoot(distro = 'Ubuntu', root = '/mnt/'): Promise { + const stop = followWslAutomountRoots( + () => [distro], + async () => root + ) + try { + await resolveRunningWslDistros(async () => [distro]) + await ensureWslAutomountRoot(distro) + } finally { + stop() + } +} diff --git a/src/main/wsl/filesystem/wsl-automount-root.ts b/src/main/wsl/filesystem/wsl-automount-root.ts index fe8b231a9cd..71d14f3c2d4 100644 --- a/src/main/wsl/filesystem/wsl-automount-root.ts +++ b/src/main/wsl/filesystem/wsl-automount-root.ts @@ -7,6 +7,7 @@ * outside Orca with no wake after it keeps the old root until the next noticed stop or wake. */ import { runProcess } from '@orca/process-host' +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { buildPosixStdoutFence } from '../../../shared/posix-stdout-fence' import { buildWslExecArgs } from '../../../shared/wsl-login-shell-command' import { @@ -19,10 +20,12 @@ import { onRunningWslDistrosStopped } from '../discovery/wsl-running-distro-cache' import { resolveWslExecutablePath } from '../execution/wsl-executable-path' +import { WSL_DISTRO_BOOT_TIMEOUT_MS } from '../discovery/wsl-distro-holder-command' export const DEFAULT_WSL_AUTOMOUNT_ROOT = '/mnt/' const READ_TIMEOUT_MS = 5_000 +export const ROOT_COMMAND = String.raw`wslpath -u 'C:\'` // A wake re-reads at most this often per distro, so terminals opened on a running distro stay free. export const WSL_AUTOMOUNT_WAKE_REREAD_INTERVAL_MS = 5 * 60_000 @@ -32,7 +35,10 @@ const roots = new Map() // Read since its last start, the default `/mnt/` too: one guest launch per start, not per probe. const readSinceStart = new Set() const readAt = new Map() -const pending = new Set() +const pending = new Map>() +const retryAt = new Map() +let stopLifecycle: (() => void) | undefined +let resetGeneration = 0 // Bumped on a stop, so a read still in flight then neither marks the restarted distro read. const stopCounts = new Map() const changeListeners = new Set<() => void>() @@ -41,6 +47,53 @@ function distroKey(distro: string): string { return distro.trim().toLowerCase() } +export type WslAutomountSnapshot = Readonly<{ + distro: string + root: string | null + generation: number + stopCount: number +}> + +export function captureWslAutomountSnapshot(distro: string): WslAutomountSnapshot { + observeLifecycle() + return Object.freeze({ + distro, + root: peekWslAutomountRoot(distro), + generation: resetGeneration, + stopCount: stopCounts.get(distroKey(distro)) ?? 0 + }) +} + +export function isWslAutomountSnapshotCurrent(snapshot: WslAutomountSnapshot): boolean { + return ( + snapshot.generation === resetGeneration && + snapshot.stopCount === (stopCounts.get(distroKey(snapshot.distro)) ?? 0) + ) +} + +export function assertWslAutomountSnapshotCurrent(snapshot: WslAutomountSnapshot): void { + if (!isWslAutomountSnapshotCurrent(snapshot)) { + throw rootUnavailable(snapshot.distro) + } +} + +/** Completed bytes may learn their own epoch's root, never a restarted distro's root. */ +export async function settleWslAutomountSnapshot( + snapshot: WslAutomountSnapshot, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + if (snapshot.root !== null || !isWslAutomountSnapshotCurrent(snapshot)) { + return snapshot + } + const root = await waitForPromiseWithSignal( + readRoot(snapshot.distro, readWslAutomountRoot), + signal + ) + signal?.throwIfAborted() + return isWslAutomountSnapshotCurrent(snapshot) ? Object.freeze({ ...snapshot, root }) : snapshot +} + /** Every root a known WSL project's distro uses, the default always among them. */ export function knownWslAutomountRoots(): string[] { return [...new Set([DEFAULT_WSL_AUTOMOUNT_ROOT, ...roots.values()])] @@ -57,94 +110,170 @@ function changed(): void { } } -/** - * Follows the running-distro probes for the distros `wanted` names (the WSL projects' distros): - * reads a root on a confirmed start, forgets it on a stop. Returns the unsubscribe. - */ -export function followWslAutomountRoots( - wanted: () => readonly string[], - read: WslAutomountRootReader = readWslAutomountRoot -): () => void { - const stopRunning = onConfirmedRunningWslDistros((running) => { - const wantedKeys = new Set(wanted().map(distroKey)) - for (const distro of running) { - const key = distroKey(distro) - if (!wantedKeys.has(key) || readSinceStart.has(key) || pending.has(key)) { - continue - } - // A list probed mid-stop still names the distro, and wsl.exe would boot it again. The cache - // stores this list before notifying, so this judges it; a later settled list reads. - if (!wasWslDistroLastSeenRunningSettled(distro)) { - continue - } - pending.add(key) - const stopCount = stopCounts.get(key) ?? 0 - void read(distro) - .then((root) => { - // Unread (null) retries on the next probe; a stop mid-read leaves it for the restart. - if (!root || (stopCounts.get(key) ?? 0) !== stopCount) { - return - } - readSinceStart.add(key) - readAt.set(key, performance.now()) - const previous = roots.get(key) ?? DEFAULT_WSL_AUTOMOUNT_ROOT - if (root === previous) { - return - } - if (root === DEFAULT_WSL_AUTOMOUNT_ROOT) { - roots.delete(key) - } else { - roots.set(key, root) - } - changed() - }) - // Unreadable: links under it fall back to `/mnt/`. - .catch(() => {}) - .finally(() => pending.delete(key)) +/** A settled root for execution; discovery may retain an older root during wake revalidation. */ +export function peekWslAutomountRoot(distro: string): string | null { + const key = distroKey(distro) + return readSinceStart.has(key) ? (roots.get(key) ?? DEFAULT_WSL_AUTOMOUNT_ROOT) : null +} + +function invalidate(distro: string): void { + const key = distroKey(distro) + stopCounts.set(key, (stopCounts.get(key) ?? 0) + 1) + readSinceStart.delete(key) + pending.delete(key) + readAt.delete(key) + retryAt.delete(key) + if (roots.delete(key)) { + changed() + } +} + +function observeLifecycle(): void { + if (stopLifecycle) { + return + } + const stopStopped = onRunningWslDistrosStopped((stopped) => stopped.forEach(invalidate)) + const stopNoted = onWslDistroGateNote((note) => { + if (note.kind === 'stopped') { + invalidate(note.distro) + return } - }) - const stopStopped = onRunningWslDistrosStopped((stopped) => { - let removed = false - for (const distro of stopped) { - const key = distroKey(distro) - stopCounts.set(key, (stopCounts.get(key) ?? 0) + 1) - readSinceStart.delete(key) - readAt.delete(key) - removed = roots.delete(key) || removed - } - if (removed) { - changed() - } - }) - // A wake may have restarted it unseen: the next settled probe reads again, its old root kept until then. - const stopWoken = onWslDistroGateNote((note) => { const key = distroKey(note.distro) const lastRead = readAt.get(key) if ( - note.kind === 'woken' && readSinceStart.has(key) && lastRead !== undefined && performance.now() - lastRead >= WSL_AUTOMOUNT_WAKE_REREAD_INTERVAL_MS ) { readSinceStart.delete(key) + pending.delete(key) + stopCounts.set(key, (stopCounts.get(key) ?? 0) + 1) + retryAt.delete(key) } }) - return () => { - stopRunning() + stopLifecycle = () => { stopStopped() - stopWoken() + stopNoted() } } +function readRoot(distro: string, read: WslAutomountRootReader): Promise { + const key = distroKey(distro) + const known = peekWslAutomountRoot(distro) + if (known !== null) { + return Promise.resolve(known) + } + const joined = pending.get(key) + if (joined) { + return joined + } + if ((retryAt.get(key) ?? 0) > performance.now()) { + return Promise.resolve(null) + } + const stopCount = stopCounts.get(key) ?? 0 + const generation = resetGeneration + const current = (): boolean => + generation === resetGeneration && (stopCounts.get(key) ?? 0) === stopCount + const work = Promise.resolve() + .then(() => (current() ? read(distro) : null)) + .catch(() => null) + .then((root) => { + if (!current()) { + return null + } + if (!root) { + retryAt.set(key, performance.now() + READ_TIMEOUT_MS) + return null + } + retryAt.delete(key) + readSinceStart.add(key) + readAt.set(key, performance.now()) + const previous = roots.get(key) ?? DEFAULT_WSL_AUTOMOUNT_ROOT + if (root === DEFAULT_WSL_AUTOMOUNT_ROOT) { + roots.delete(key) + } else { + roots.set(key, root) + } + if (previous !== root) { + changed() + } + return root + }) + .finally(() => { + if (pending.get(key) === work) { + pending.delete(key) + } + }) + pending.set(key, work) + return work +} + +export function rootUnavailable(distro: string): Error { + return new Error( + `Orca couldn't read Windows drive paths in WSL distro ${distro}. Try again when the distro is available.` + ) +} + +/** Only after the caller's WSL admission: this read may start the selected distro. */ +export async function ensureWslAutomountRoot( + distro: string, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + observeLifecycle() + const key = distroKey(distro) + for (;;) { + const generation = resetGeneration + const stopCount = stopCounts.get(key) ?? 0 + const root = await waitForPromiseWithSignal( + readRoot(distro, (name) => readWslAutomountRoot(name, WSL_DISTRO_BOOT_TIMEOUT_MS)), + signal + ) + signal?.throwIfAborted() + if (generation !== resetGeneration || stopCount !== (stopCounts.get(key) ?? 0)) { + continue + } + if (root === null) { + throw rootUnavailable(distro) + } + return root + } +} + +/** Observe project distros without waking stopped ones; foreground callers share these reads. */ +export function followWslAutomountRoots( + wanted: () => readonly string[], + read: WslAutomountRootReader = readWslAutomountRoot +): () => void { + observeLifecycle() + return onConfirmedRunningWslDistros((running) => { + const wantedKeys = new Set(wanted().map(distroKey)) + for (const distro of running) { + const key = distroKey(distro) + if ( + wantedKeys.has(key) && + !readSinceStart.has(key) && + !pending.has(key) && + wasWslDistroLastSeenRunningSettled(distro) + ) { + void readRoot(distro, read) + } + } + }) +} + /** The effective root from the distro itself: `wslpath` applies `wsl.conf` as WSL does. */ -export async function readWslAutomountRoot(distro: string): Promise { - const fence = buildPosixStdoutFence("wslpath -u 'C:\\'", 'WSL') +export async function readWslAutomountRoot( + distro: string, + timeoutMs = READ_TIMEOUT_MS +): Promise { + const fence = buildPosixStdoutFence(ROOT_COMMAND, 'WSL') const result = await runProcess({ program: resolveWslExecutablePath(), args: buildWslExecArgs(distro, ['sh', '-c', fence.command]), env: { ...process.env, WSL_UTF8: '1' }, cwd: resolveWslInteropSpawnCwd(), - timeoutMs: READ_TIMEOUT_MS, + timeoutMs, maxOutputBytes: 4_096 }) if (result.timedOut || result.code !== 0) { @@ -158,7 +287,7 @@ export function wslDrivePathUnderRoots(path: string, roots: readonly string[]): for (const root of roots) { const prefix = root.endsWith('/') ? root : `${root}/` const drive = path.startsWith(prefix) - ? /^([a-z])(\/.*)?$/i.exec(path.slice(prefix.length)) + ? /^([a-z])(\/[\s\S]*)?$/i.exec(path.slice(prefix.length)) : null if (drive) { return `${drive[1].toUpperCase()}:${(drive[2] ?? '/').replace(/\//g, '\\')}` @@ -174,6 +303,10 @@ export function parseWslAutomountRoot(drivePath: string | null): string | null { } export function resetWslAutomountRootsForTests(): void { + stopLifecycle?.() + stopLifecycle = undefined + resetGeneration += 1 + retryAt.clear() roots.clear() readSinceStart.clear() readAt.clear() @@ -181,3 +314,11 @@ export function resetWslAutomountRootsForTests(): void { stopCounts.clear() changeListeners.clear() } + +export { + isWindowsDrivePath, + wslDrivePathForDistro, + windowsDrivePathForDistro, + wslAutomountRootShellSetup, + quoteWslDrivePathForShell +} from './wsl-drive-paths' diff --git a/src/main/wsl/filesystem/wsl-drive-paths.ts b/src/main/wsl/filesystem/wsl-drive-paths.ts new file mode 100644 index 00000000000..935caa40764 --- /dev/null +++ b/src/main/wsl/filesystem/wsl-drive-paths.ts @@ -0,0 +1,67 @@ +import { quotePosixShell } from '../../../shared/shell/launch/shell-argument-quoting' +import { + peekWslAutomountRoot, + wslDrivePathUnderRoots, + rootUnavailable, + ROOT_COMMAND, + type WslAutomountSnapshot +} from './wsl-automount-root' +const DRIVE_PATH = /^([A-Za-z]):[/\\]([\s\S]*)$/ + +export function isWindowsDrivePath(path: string): boolean { + return DRIVE_PATH.test(path) +} + +/** Windows-drive operands in the selected distro's namespace; other path shapes return null. */ +export function wslDrivePathForDistro( + path: string, + distro: string, + snapshot?: WslAutomountSnapshot +): string | null { + const drive = DRIVE_PATH.exec(path) + if (!drive) { + return null + } + const root = snapshot ? snapshot.root : peekWslAutomountRoot(distro) + if (root === null) { + throw rootUnavailable(distro) + } + return `${root}${drive[1].toLowerCase()}/${drive[2].replace(/\\/g, '/')}` +} + +export function windowsDrivePathForDistro( + path: string, + distro: string, + snapshot?: WslAutomountSnapshot +): string | null { + const root = snapshot ? snapshot.root : peekWslAutomountRoot(distro) + if (root === null) { + throw rootUnavailable(distro) + } + // Guest mount names are case-sensitive, even though the files under a drive are not. + return /^[a-z](?:\/|$)/.test(path.slice(root.length)) + ? wslDrivePathUnderRoots(path, [root]) + : null +} + +/** A cold immediate launch resolves once in its existing guest shell, never on Electron main. */ +export function wslAutomountRootShellSetup(failureExitCode = 1): string { + const code = Math.trunc(failureExitCode) + return ( + `_orca_wsl_drive_root=$(${ROOT_COMMAND}) || exit ${code}; ` + + '_orca_wsl_drive_root=${_orca_wsl_drive_root%/}; ' + + `case "$_orca_wsl_drive_root" in /*/c|/c) _orca_wsl_drive_root=\${_orca_wsl_drive_root%c};; *) exit ${code};; esac; ` + ) +} + +export function quoteWslDrivePathForShell(path: string, distro: string): string | null { + const drive = DRIVE_PATH.exec(path) + if (!drive) { + return null + } + return peekWslAutomountRoot(distro) !== null + ? quotePosixShell(wslDrivePathForDistro(path, distro) ?? path) + : `"$_orca_wsl_drive_root"${quotePosixShell( + `${drive[1].toLowerCase()}/${drive[2].replace(/\\/g, '/')}` + )}` +}