fix(git): honor selected WSL automount roots

This commit is contained in:
Brennan Benson
2026-11-12 15:42:20 -08:00
parent 365515d418
commit 81508684ca
47 changed files with 1925 additions and 470 deletions
@@ -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
@@ -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<void>((resolve) => {
reportTerminated = resolve
@@ -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<void> | null = null
try {
assertPreparedGitCommand(resolved, args, options)
let reportTerminated: () => void = () => {}
termination = new Promise<void>((resolve) => {
reportTerminated = resolve
})
const { stdout } = await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: 'buffer',
signal: options.signal,
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<typeof gitExecFileAsyncBufferInSpan>[1]
): Promise<{ stdout: Buffer }> {
const options = routeLocalGitOptions(callerOptions)
return runAdmittedWslGit(options, () =>
runUnlessGitCwdKnownMissing(options, () => gitExecFileAsyncBufferInSpan(args, options))
)
}
+36 -107
View File
@@ -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<void> | null }
): Promise<{ stdout: string; stderr: string }> {
): Promise<GitTextResult> {
// 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<void>((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<GitTextResult> => {
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<GitTextResult> {
const options = routeLocalGitOptions(callerOptions)
const releaseMutation = trackGitWorktreeMutation(args, options)
const terminationState: { current: Promise<void> | 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<void> | null = null
try {
let reportTerminated: () => void = () => {}
termination = new Promise<void>((resolve) => {
reportTerminated = resolve
})
const { stdout } = await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: 'buffer',
signal: options.signal,
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<typeof gitExecFileAsyncBufferInSpan>[1]
): Promise<{ stdout: Buffer }> {
const options = routeLocalGitOptions(callerOptions)
return runAdmittedWslGit(options, () =>
runUnlessGitCwdKnownMissing(options, () => gitExecFileAsyncBufferInSpan(args, options))
)
}
export { gitExecFileAsyncBuffer } from './git-exec-file-buffer'
@@ -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
+48 -19
View File
@@ -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<ChildProcess> {
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<ChildProcess> {
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<ReturnType<typeof acquireGitAdmission>> | 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,
@@ -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<GitStreamResult> {
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<void>((resolveTermination) => {
const reportTermination = (): void => {
@@ -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'])
})
})
@@ -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 {
@@ -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<T>(platform: NodeJS.Platform, fn: () => Promise<T>):
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) {
@@ -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<T>(platform: NodeJS.Platform, run: () => Promise<T>)
}
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()
@@ -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<void>) | null = null
@@ -15,8 +16,17 @@ export async function awaitWindowsHostGitEnvironmentReady(options: {
wslDistro?: string
signal?: AbortSignal
}): Promise<void> {
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 {
@@ -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(
{
@@ -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<void> | 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)
})()
}
@@ -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<typeof Admission>()
return {
...actual,
acquireGitAdmission: async (request: Parameters<typeof actual.acquireGitAdmission>[0]) => {
await admissionWait()
return actual.acquireGitAdmission(request)
}
}
})
vi.mock('@orca/process-host', async (original) => ({
...(await original<typeof ProcessHost>()),
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<void>()
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<void>()
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')
})
@@ -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<T extends object>(
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<GitExecutionContext> | 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) }
})()
}
@@ -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 })
}
@@ -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) {
@@ -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<typeof GitObjectQuarantineModule>()),
createGitObjectQuarantine: (
resolve: () => Promise<GitObjectsDirectory | undefined>
): 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',
@@ -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: <T>(command: (env: NodeJS.ProcessEnv | undefined) => Promise<T>) => Promise<T>
run: <T>(
command: (
env: NodeJS.ProcessEnv | undefined,
canStart?: (context?: GitExecutionContext) => boolean
) => Promise<T>
) => Promise<T>
}
/** `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)
}
}
}
}
@@ -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<typeof Fs>()),
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<typeof Capture>()),
execFileCapture: fixture.capture
}))
vi.mock('../command-runner/admission/git-subprocess-admission', async (original) => {
const actual = await original<typeof Admission>()
return {
...actual,
acquireGitAdmission: async (request: Parameters<typeof actual.acquireGitAdmission>[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<void> {
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 })
}
)
@@ -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)
}
@@ -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.
+12 -2
View File
@@ -66,7 +66,12 @@ export async function listTrackedPathSpecs(
options: GitRuntimeOptions = {}
): Promise<string[]> {
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<void> {
// 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) })
}
@@ -144,11 +144,11 @@ async function readTargetedStatus(
options: GetStatusOptions
): Promise<GitStatusEntry[]> {
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) {
@@ -18,7 +18,7 @@ async function listMergedTrackedPaths(
options: GitRuntimeOptions
): Promise<string[]> {
const tracked = new Set<string>()
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,
+33 -4
View File
@@ -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<string[][]> {
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)
@@ -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) {
@@ -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<string | undefined> {
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<string | undefined> {
return (await readRepoCommonDirWithContext(repoPath, options))?.path
}
/** Detached HEAD returns undefined. */
export async function readCheckedOutBranchRef(
worktreePath: string,
options: GitWorktreeExecOptions = {}
): Promise<string | undefined> {
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<string> {
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<GitWorktreeInfo[]> {
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<GitWorktreeInfo[]> {
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<GitWorktreeInfo[]> {
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)
}))
}
@@ -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`) {
@@ -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`)
})
})
@@ -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<GitWorktreeInfo[]> {
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,
+5 -4
View File
@@ -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<RuntimeGitLocalBranches> {
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<string, number> = Object.create(null)
const worktreePaths: Record<string, string> = 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) => {
@@ -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,
@@ -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) }))
}
}
}
@@ -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'
@@ -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)
@@ -60,7 +60,7 @@ function mockGitDispatch(overrides: Record<string, GitHandler> = {}): 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' } }))
})
}
@@ -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')
@@ -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) => {
@@ -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<OpenCodeWslGuestRuntime>
/** 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<OpenCodeWslGuestRuntime> {
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<string> {
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,
@@ -1,3 +1,5 @@
export const WSL_DISTRO_BOOT_TIMEOUT_MS = 60_000
import {
ORCAD_WSL_HOLDER_BLOCKED_FILENAME,
ORCAD_WSL_HOLDER_RELEASE_FILENAME
+2 -3
View File
@@ -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<string, number>()
const sleep =
dependencies.sleep ?? ((ms: number) => new Promise<void>((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<string, Promise<void>>()
const inFlightEnsures = new Map<
+3
View File
@@ -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<WslResult> {
// 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 }),
@@ -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<void> {
const stop = followWslAutomountRoots(
() => [distro],
async () => root
)
try {
await resolveRunningWslDistros(async () => [distro])
await ensureWslAutomountRoot(distro)
} finally {
stop()
}
}
+210 -69
View File
@@ -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<string, string>()
// Read since its last start, the default `/mnt/` too: one guest launch per start, not per probe.
const readSinceStart = new Set<string>()
const readAt = new Map<string, number>()
const pending = new Set<string>()
const pending = new Map<string, Promise<string | null>>()
const retryAt = new Map<string, number>()
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<string, number>()
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<WslAutomountSnapshot> {
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<string | null> {
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<string> {
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<string | null> {
const fence = buildPosixStdoutFence("wslpath -u 'C:\\'", 'WSL')
export async function readWslAutomountRoot(
distro: string,
timeoutMs = READ_TIMEOUT_MS
): Promise<string | null> {
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'
@@ -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, '/')}`
)}`
}