mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
fix(git): honor selected WSL automount roots
This commit is contained in:
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
+17
-1
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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, '/')}`
|
||||
)}`
|
||||
}
|
||||
Reference in New Issue
Block a user