diff --git a/config/main-sync-spawn-baseline.txt b/config/main-sync-spawn-baseline.txt index a37f5b5dfd4..2560c8bd2bf 100644 --- a/config/main-sync-spawn-baseline.txt +++ b/config/main-sync-spawn-baseline.txt @@ -24,7 +24,7 @@ 1 src/main/daemon/pty-subprocess/shell-launch-plan.ts 1 src/main/daemon/terminal-scopes.ts 1 src/main/emulator/serve-sim-runtime-materializer.ts -1 src/main/git/command-runner/git-exec-file.ts +1 src/main/git/command-runner/git-exec-file-sync.ts 1 src/main/git/repo-detection-command.ts 1 src/main/git/runner.ts 1 src/main/ipc/worktree-logic.ts diff --git a/config/process-host-direct-import-baseline.txt b/config/process-host-direct-import-baseline.txt index 6293dc4e3c1..a77cf17b683 100644 --- a/config/process-host-direct-import-baseline.txt +++ b/config/process-host-direct-import-baseline.txt @@ -42,7 +42,7 @@ src/main/emulator/simctl-simulator-devices.ts src/main/emulator/simulator-app-visibility.ts src/main/external-editor-launch.ts src/main/git/command-runner/exec-file-capture.ts -src/main/git/command-runner/git-exec-file.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/spawn-command-capture.ts diff --git a/config/scripts/check-main-sync-spawn-ratchet.mjs b/config/scripts/check-main-sync-spawn-ratchet.mjs index 5df6cb8fc4f..fa56cdbb221 100644 --- a/config/scripts/check-main-sync-spawn-ratchet.mjs +++ b/config/scripts/check-main-sync-spawn-ratchet.mjs @@ -29,7 +29,11 @@ const GATED_FAMILIES = [ advice: { restrictWindowsPathSync: SECURE_ADVICE } }, { - modules: ['src/main/git/command-runner/git-exec-file', 'src/main/git/runner'], + modules: [ + 'src/main/git/command-runner/git-exec-file', + 'src/main/git/command-runner/git-exec-file-sync', + 'src/main/git/runner' + ], advice: { gitExecFileSync: 'use gitExecFileAsync' } }, { diff --git a/src/main/git/command-runner/git-admission-span.test.ts b/src/main/git/command-runner/git-admission-span.test.ts index 1e510d25743..fb1ca94b7ba 100644 --- a/src/main/git/command-runner/git-admission-span.test.ts +++ b/src/main/git/command-runner/git-admission-span.test.ts @@ -37,13 +37,14 @@ vi.mock('../../observability/instrumentation', () => ({ })) import { gitExecFileAsync, gitExecFileAsyncBuffer } from './git-exec-file' -import { withGitAdmission } from './git-spawn' +import { gitSpawnAfterWindowsEnvironmentReady, withGitAdmission } from './git-spawn' import { gitStreamStdout } from './git-stream-stdout' import { GitAdmissionScheduler, _gitAdmissionSnapshotForTests, _resetGitAdmissionForTests } from './git-subprocess-admission' +import { setLocalGitRuntimeRouter } from './local-git-runtime-route' type ExecCallback = (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void @@ -115,6 +116,7 @@ describe('git admission span coverage', () => { await expect(pending).resolves.toEqual({ stdout: 'ok', stderr: '' }) expect(span.setAttribute).toHaveBeenCalledWith('git.queue_wait_ms', 37) expect(span.setAttribute).toHaveBeenCalledWith('git.runs_in', 'host') + expect(span.setAttribute).toHaveBeenCalledWith('git.runs_in', 'host') expect(span.end).toHaveBeenCalledOnce() }) @@ -137,6 +139,7 @@ describe('git admission span coverage', () => { await expect(pending).resolves.toEqual({ stdout: Buffer.from('blob') }) expect(span.setAttribute).toHaveBeenCalledWith('git.queue_wait_ms', 37) expect(span.setAttribute).toHaveBeenCalledWith('git.runs_in', 'host') + expect(span.setAttribute).toHaveBeenCalledWith('git.runs_in', 'host') expect(span.end).toHaveBeenCalledOnce() }) @@ -151,6 +154,7 @@ describe('git admission span coverage', () => { child.emit('close', 0, null) await expect(pending).resolves.toEqual({ stoppedEarly: false }) expect(span.setAttribute).toHaveBeenCalledWith('git.queue_wait_ms', 37) + expect(span.setAttribute).toHaveBeenCalledWith('git.runs_in', 'host') expect(span.end).toHaveBeenCalledOnce() }) @@ -162,9 +166,26 @@ describe('git admission span coverage', () => { await expect(pending).resolves.toBe(child) expect(span.setAttribute).toHaveBeenCalledWith('git.queue_wait_ms', 37) + // Without the Git router the spawn is host Git as before, so no second resolution for the span. + expect(span.setAttribute).not.toHaveBeenCalledWith('git.runs_in', expect.anything()) expect(span.end).not.toHaveBeenCalled() child.emit('close', 0, null) expect(span.end).toHaveBeenCalledOnce() expect(span.fail).not.toHaveBeenCalled() }) + + it('names which Git ran on the span of a spawn, with the Git router on', async () => { + setLocalGitRuntimeRouter(() => undefined) + try { + const child = mockChild() + spawnMock.mockReturnValue(child) + await expect( + gitSpawnAfterWindowsEnvironmentReady(['status'], { cwd: '/repo' }) + ).resolves.toBe(child) + expect(span.setAttribute).toHaveBeenCalledWith('git.runs_in', 'host') + child.emit('close', 0, null) + } finally { + setLocalGitRuntimeRouter(null) + } + }) }) diff --git a/src/main/git/command-runner/git-exec-file-sync.test.ts b/src/main/git/command-runner/git-exec-file-sync.test.ts new file mode 100644 index 00000000000..2974f3e477e --- /dev/null +++ b/src/main/git/command-runner/git-exec-file-sync.test.ts @@ -0,0 +1,57 @@ +import type * as ChildProcess from 'node:child_process' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { execFileSync } = vi.hoisted(() => ({ + execFileSync: vi.fn((_binary: string, _args: readonly string[], _options?: unknown) => 'out') +})) +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFileSync +})) + +import { gitExecFileSync } from './git-exec-file-sync' +import { untranslatedGitOutputEnv } from './git-process-env' +import { setLocalGitRuntimeRouter } from './local-git-runtime-route' + +const originalPlatform = process.platform + +afterEach(() => { + execFileSync.mockClear() + setLocalGitRuntimeRouter(null) + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) +}) + +describe('gitExecFileSync', () => { + it('runs host Git even in a WSL project, so the main thread never waits on wsl.exe', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + setLocalGitRuntimeRouter(() => 'Ubuntu') + gitExecFileSync(['rev-parse', '--git-path', 'hooks'], { + cwd: String.raw`C:\src\app`, + hostGit: true + }) + const [binary] = execFileSync.mock.calls[0] ?? [''] + expect(binary.toLowerCase()).not.toContain('wsl') + }) + + it('spawns exactly what it did before hostGit existed on POSIX', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + gitExecFileSync(['rev-parse', '--git-path', 'orca/setup-runner.sh'], { + cwd: '/test/worktree', + hostGit: true + }) + expect(execFileSync.mock.calls).toEqual([ + [ + 'git', + ['rev-parse', '--git-path', 'orca/setup-runner.sh'], + { + cwd: '/test/worktree', + encoding: 'utf-8', + env: untranslatedGitOutputEnv(), + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 15_000, + windowsHide: true + } + ] + ]) + }) +}) diff --git a/src/main/git/command-runner/git-exec-file-sync.ts b/src/main/git/command-runner/git-exec-file-sync.ts new file mode 100644 index 00000000000..d93a96ec741 --- /dev/null +++ b/src/main/git/command-runner/git-exec-file-sync.ts @@ -0,0 +1,40 @@ +import { execFileSync, type SpawnOptions } from 'node:child_process' +import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe' +import { untranslatedGitOutputEnv } from './git-process-env' +import { resolveCommand } from './wsl-command-resolution' + +// Bound synchronous Git: a dead network drive can freeze the main thread. +const GIT_EXEC_SYNC_TIMEOUT_MS = 15_000 + +type HostGitOnly = true + +/** + * Return trimmed stdout; prefer async execution to avoid blocking main. Host Git, never routed: + * routing would turn this one main-thread launch into a synchronous wsl.exe. + */ +export function gitExecFileSync( + args: string[], + options: { + cwd: string + /** Every caller is host-only; see the routing guard test. */ + hostGit: HostGitOnly + encoding?: BufferEncoding + stdio?: SpawnOptions['stdio'] + timeout?: number + } +): string { + const resolved = resolveCommand('git', args, options.cwd) + const spawnStartedAt = performance.now() + try { + return execFileSync(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: options.encoding ?? 'utf-8', + env: untranslatedGitOutputEnv(), + stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'], + timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS, + windowsHide: true + }) as string + } finally { + recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) + } +} diff --git a/src/main/git/command-runner/git-exec-file.ts b/src/main/git/command-runner/git-exec-file.ts index f47ea9fdf31..b114c92bcce 100644 --- a/src/main/git/command-runner/git-exec-file.ts +++ b/src/main/git/command-runner/git-exec-file.ts @@ -1,12 +1,10 @@ -import { execFileSync, type SpawnOptions } from 'node:child_process' import { withGitSpan } from '../../observability/instrumentation' -import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe' import { resolveGitFetchHeadCommand, runWithGitFetchHeadLock } from '../../../shared/git-fetch-head-lock' import { pendingWslDriveCheckoutGitRouting } from '../wsl-drive-checkout-git-routing' -import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution' +import type { ResolvedCommand } from './wsl-command-resolution' import { annotateWslHostFailure, isTransientWslFailure } from './wsl-host-failure' import type { GitAdmissionTier, GitExecOptions } from './git-exec-options' import { execFileCapture, execFileCaptureToTermination } from './exec-file-capture' @@ -28,6 +26,8 @@ import { createAbortError } from './abort-error' import { readCapturedGitBuffer, readCapturedGitString } from './captured-git-output' import { runUnlessGitCwdKnownMissing } from './missing-git-spawn-cwd' import { trackGitWorktreeMutation } from '../git-worktree-mutation-state' +import { routeLocalGitOptions, runAdmittedWslGit } from './local-git-runtime-route' +import { gitRunsIn } from './git-runs-in' async function gitExecFileAsyncUnlocked( args: string[], @@ -78,7 +78,7 @@ async function gitExecFileAsyncUnlocked( }) span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs) // Which Git ran: a project's runtime can route it into a distro whatever its cwd. - span?.setAttribute('git.runs_in', resolved.wsl ? `wsl:${resolved.wsl.distro}` : 'host') + span?.setAttribute('git.runs_in', gitRunsIn(resolved)) const timeoutMs = gitCommandTimeoutMs(args, options.timeout, options.timeoutMsForTest) const capture = ( command: ResolvedCommand @@ -177,21 +177,24 @@ async function gitExecFileAsyncUnlocked( export function gitExecFileAsync( args: string[], - options: GitExecOptions + callerOptions: GitExecOptions ): Promise<{ stdout: string; stderr: string }> { + const options = routeLocalGitOptions(callerOptions) const releaseMutation = trackGitWorktreeMutation(args, options) const terminationState: { current: Promise | null } = { current: null } const command = resolveGitFetchHeadCommand(args, options.cwd) // Missing folders start no child and must not record a git.exec span. - const result = runUnlessGitCwdKnownMissing(options, () => - command.needsLock - ? runWithGitFetchHeadLock( - command.cwd, - options.signal, - () => gitExecFileAsyncUnlocked(args, options, terminationState), - command.gitDir - ) - : gitExecFileAsyncUnlocked(args, options, terminationState) + const result = runAdmittedWslGit(options, () => + runUnlessGitCwdKnownMissing(options, () => + command.needsLock + ? runWithGitFetchHeadLock( + command.cwd, + options.signal, + () => gitExecFileAsyncUnlocked(args, options, terminationState), + command.gitDir + ) + : gitExecFileAsyncUnlocked(args, options, terminationState) + ) ) return result.finally(() => { // A timed-out caller can settle before its child exits. @@ -242,7 +245,7 @@ async function gitExecFileAsyncBufferInSpan( signal: options.signal }) span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs) - span?.setAttribute('git.runs_in', resolved.wsl ? `wsl:${resolved.wsl.distro}` : 'host') + span?.setAttribute('git.runs_in', gitRunsIn(resolved)) const timeoutMs = gitCommandTimeoutMs(args, options.timeout, options.timeoutMsForTest) let termination: Promise | null = null try { @@ -278,38 +281,12 @@ async function gitExecFileAsyncBufferInSpan( }) } -// Bound synchronous Git: a dead network drive can freeze the main thread. -const GIT_EXEC_SYNC_TIMEOUT_MS = 15_000 - export function gitExecFileAsyncBuffer( args: string[], - options: Parameters[1] + callerOptions: Parameters[1] ): Promise<{ stdout: Buffer }> { - return runUnlessGitCwdKnownMissing(options, () => gitExecFileAsyncBufferInSpan(args, options)) -} - -/** Return trimmed stdout; prefer async execution to avoid blocking main. */ -export function gitExecFileSync( - args: string[], - options: { - cwd: string - encoding?: BufferEncoding - stdio?: SpawnOptions['stdio'] - timeout?: number - } -): string { - const resolved = resolveCommand('git', args, options.cwd) - const spawnStartedAt = performance.now() - try { - return execFileSync(resolved.binary, resolved.args, { - cwd: resolved.cwd, - encoding: options.encoding ?? 'utf-8', - env: untranslatedGitOutputEnv(), - stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'], - timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS, - windowsHide: true - }) as string - } finally { - recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) - } + const options = routeLocalGitOptions(callerOptions) + return runAdmittedWslGit(options, () => + runUnlessGitCwdKnownMissing(options, () => gitExecFileAsyncBufferInSpan(args, options)) + ) } diff --git a/src/main/git/command-runner/git-exec-options.ts b/src/main/git/command-runner/git-exec-options.ts index 9a9e5783a01..3963c0ebefe 100644 --- a/src/main/git/command-runner/git-exec-options.ts +++ b/src/main/git/command-runner/git-exec-options.ts @@ -20,6 +20,8 @@ export type GitExecOptions = { /** Rechecks owner eligibility after admission, before starting the child. */ canStart?: () => boolean wslDistro?: string + /** Runs the host's Git even in a WSL-runtime project (local-git-runtime-route.ts); say why. */ + hostGit?: true preferWslDirectGit?: boolean useConfiguredSshCommandForNetwork?: boolean terminationBarrier?: boolean diff --git a/src/main/git/command-runner/git-runs-in.ts b/src/main/git/command-runner/git-runs-in.ts new file mode 100644 index 00000000000..74caf0ea6df --- /dev/null +++ b/src/main/git/command-runner/git-runs-in.ts @@ -0,0 +1,6 @@ +import type { ResolvedCommand } from './wsl-command-resolution' + +/** The `git.runs_in` span attribute: which Git a call ran, `host` or `wsl:`. */ +export function gitRunsIn(resolved: Pick): string { + return resolved.wsl ? `wsl:${resolved.wsl.distro}` : 'host' +} diff --git a/src/main/git/command-runner/git-spawn-wsl-admission.test.ts b/src/main/git/command-runner/git-spawn-wsl-admission.test.ts new file mode 100644 index 00000000000..5ec515132b3 --- /dev/null +++ b/src/main/git/command-runner/git-spawn-wsl-admission.test.ts @@ -0,0 +1,64 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { withGitAdmission } from './git-spawn' +import { + admitWslGit, + resetWslGitAdmissionForTests, + setLocalGitRuntimeRouter, + WslGitSkippedError +} from './local-git-runtime-route' + +const originalPlatform = process.platform +const CWD = String.raw`C:\wsl-project` + +function failingChild(stderr: string): ChildProcess { + const child = Object.assign(new EventEmitter(), { pid: 42, stderr: new EventEmitter() }) + setTimeout(() => { + child.stderr.emit('data', Buffer.from(stderr)) + child.emit('close', 1, null) + }, 0) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: withGitAdmission uses only pid, stderr and the error/close events. + return child as unknown as ChildProcess +} + +beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) +}) + +afterEach(() => { + setLocalGitRuntimeRouter(null) + resetWslGitAdmissionForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) +}) + +async function spawnAndClose(stderr: string, wslDistro?: string): Promise { + const child = await withGitAdmission( + ['status'], + { cwd: CWD, ...(wslDistro ? { wslDistro } : {}) }, + () => failingChild(stderr) + ) + await new Promise((resolve) => child.once('close', resolve)) +} + +describe('withGitAdmission and a missing distro', () => { + it('records a spawned WSL Git that reports its distro missing, so the next call fails at once', async () => { + setLocalGitRuntimeRouter(() => 'Ubuntu') + await spawnAndClose('There is no distribution with the supplied name.\n') + await expect(admitWslGit({ cwd: CWD, wslDistro: 'Ubuntu' })).rejects.toBeInstanceOf( + WslGitSkippedError + ) + }) + + it('records nothing for an ordinary Git failure', async () => { + setLocalGitRuntimeRouter(() => 'Ubuntu') + await spawnAndClose('fatal: not a git repository\n') + expect(admitWslGit({ cwd: CWD, wslDistro: 'Ubuntu' })).toBeNull() + }) + + it('records nothing with the router off, even for an explicit distro', async () => { + await spawnAndClose('There is no distribution with the supplied name.\n', 'Ubuntu') + setLocalGitRuntimeRouter(() => 'Ubuntu') + expect(admitWslGit({ cwd: CWD, wslDistro: 'Ubuntu' })).toBeNull() + }) +}) diff --git a/src/main/git/command-runner/git-spawn.ts b/src/main/git/command-runner/git-spawn.ts index 004a73232cc..775f4b46aab 100644 --- a/src/main/git/command-runner/git-spawn.ts +++ b/src/main/git/command-runner/git-spawn.ts @@ -10,17 +10,27 @@ import type { GitAdmissionTier } from './git-exec-options' import { acquireGitAdmission } from './git-subprocess-admission' import { classifyGitCommand } from '../../../shared/git-command-classification' import { buildNetworkSshPolicyEnv } from './git-ssh-policy-env' +import { + admitWslGit, + isLocalGitRuntimeRouterInstalled, + noteWslGitFailure, + routeLocalGitOptions +} from './local-git-runtime-route' +import { gitRunsIn } from './git-runs-in' export type GitSpawnOptions = SpawnOptions & { cwd: string wslDistro?: string + /** Runs the host's Git even in a WSL-runtime project (local-git-runtime-route.ts); say why. */ + hostGit?: true admissionTier?: GitAdmissionTier } export async function gitSpawnAfterWindowsEnvironmentReady( args: string[], - options: GitSpawnOptions + callerOptions: GitSpawnOptions ): Promise { + const options = routeLocalGitOptions(callerOptions) if (options.signal?.aborted) { throw createAbortError() } @@ -50,9 +60,15 @@ export async function gitSpawnAfterWindowsEnvironmentReady( export async function withGitAdmission( args: string[], - options: GitSpawnOptions, - spawnChild: () => ChildProcess = () => gitSpawn(args, options) + callerOptions: GitSpawnOptions, + spawnChild?: () => ChildProcess ): Promise { + const options = routeLocalGitOptions(callerOptions) + const spawnRouted = spawnChild ?? (() => gitSpawn(args, options)) + const admission = admitWslGit(options) + if (admission) { + await admission + } const span = startGitSpan({ args, cwd: options.cwd }) let grant: Awaited> | null = null try { @@ -64,19 +80,46 @@ 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 } : {}) + }) + ) + ) + } if (options.signal?.aborted) { grant.release() span.fail(createAbortError()) throw createAbortError() } - const child = spawnChild() + const child = spawnRouted() let finalized = false let liveError: Error | null = null + // wsl.exe says a distro is missing on stderr; keep a tail so the failure can name it. + let stderrTail = '' + const keepStderr = (chunk: Buffer | string): void => { + stderrTail = `${stderrTail}${String(chunk)}`.slice(-2_048) + } + if (options.wslDistro && isLocalGitRuntimeRouterInstalled()) { + child.stderr?.on('data', keepStderr) + } const finalize = (error?: Error): void => { if (finalized) { return } finalized = true + child.stderr?.off('data', keepStderr) + if (error) { + noteWslGitFailure( + options, + stderrTail ? new Error(`${error.message}\n${stderrTail}`) : error + ) + } child.off('error', handleError) child.off('close', handleClose) grant?.release() @@ -107,12 +150,14 @@ export async function withGitAdmission( } catch (error) { grant?.release() span.fail(error instanceof Error ? error : new Error(String(error))) + noteWslGitFailure(options, error) throw error } } -export function gitSpawn(args: string[], options: GitSpawnOptions): ChildProcess { - const { wslDistro, admissionTier: _admissionTier, ...spawnOptions } = options +export function gitSpawn(args: string[], callerOptions: GitSpawnOptions): ChildProcess { + const options = routeLocalGitOptions(callerOptions) + const { wslDistro, admissionTier: _admissionTier, hostGit: _hostGit, ...spawnOptions } = options const resolved = resolveGitCommand(args, { cwd: options.cwd, ...(wslDistro ? { wslDistro } : {}), diff --git a/src/main/git/command-runner/git-stream-stdout.ts b/src/main/git/command-runner/git-stream-stdout.ts index 4aaefed94d5..2bd8f482e17 100644 --- a/src/main/git/command-runner/git-stream-stdout.ts +++ b/src/main/git/command-runner/git-stream-stdout.ts @@ -24,6 +24,8 @@ import { nonInteractiveGitEnv, untranslatedGitOutputEnv } from './git-process-en import { gitSpawn } from './git-spawn' import { acquireGitAdmission } from './git-subprocess-admission' import { GitCommandTimeoutError, gitCommandTimeoutMs } from './git-command-timeout' +import { routeLocalGitOptions, runAdmittedWslGit } from './local-git-runtime-route' +import { gitRunsIn } from './git-runs-in' /** Result of a streamed git command; `stoppedEarly` is true when onStdout asked to stop before the child exited. */ export type GitStreamResult = { stoppedEarly: boolean } @@ -32,6 +34,8 @@ export type GitStreamOptions = { cwd: string env?: NodeJS.ProcessEnv wslDistro?: string + /** Runs the host's Git even in a WSL-runtime project (local-git-runtime-route.ts); say why. */ + hostGit?: true preferWslDirectGit?: boolean signal?: AbortSignal /** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */ @@ -48,10 +52,20 @@ export type GitStreamOptions = { /** Stream bounded stdout so large repositories cannot overflow V8 and parsers can stop early. */ export async function gitStreamStdout( args: string[], - options: GitStreamOptions + callerOptions: GitStreamOptions ): Promise { + const options = routeLocalGitOptions(callerOptions) const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER const timeoutMs = gitCommandTimeoutMs(args, options.timeoutMs, options.timeoutMsForTest) + return runAdmittedWslGit(options, () => streamInSpan(args, options, maxBuffer, timeoutMs)) +} + +function streamInSpan( + args: string[], + options: GitStreamOptions, + maxBuffer: number, + timeoutMs: number | undefined +): Promise { return withGitSpan({ args, cwd: options.cwd }, async (span) => { const checkoutRoutingReady = pendingWslDriveCheckoutGitRouting(args, options) if (checkoutRoutingReady) { @@ -87,6 +101,7 @@ export async function gitStreamStdout( signal: options.signal }) span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs) + span?.setAttribute('git.runs_in', gitRunsIn(resolved)) const terminationState: { current: Promise | null } = { current: null } const stream = (command: ResolvedCommand): Promise => new Promise((resolve, reject) => { diff --git a/src/main/git/command-runner/local-git-runtime-route-guard.test.ts b/src/main/git/command-runner/local-git-runtime-route-guard.test.ts new file mode 100644 index 00000000000..bc0b677235a --- /dev/null +++ b/src/main/git/command-runner/local-git-runtime-route-guard.test.ts @@ -0,0 +1,108 @@ +/** + * Guards the runtime routing of local Git: every runner entry point that takes Git options routes + * them, and every deliberate `hostGit: true` says why on the line above. + */ +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' + +const RUNNER_DIR = join(__dirname) +const ENTRY_FILES = ['git-exec-file.ts', 'git-spawn.ts', 'git-stream-stdout.ts'] +// Tests, mocks, harnesses and fixtures stand in for Git; they run none. +const TEST_SUPPORT = /\.test\.ts$|mock|harness|fixture/i +const MAIN_DIR = join(__dirname, '..', '..') +const REPO_ROOT = join(MAIN_DIR, '..', '..') + +const COMMENT_LINE = /^\s*(\*|\/\/|\/\*)/ +const HOST_GIT = 'hostGit: true' + +let hostGitSources: string[] | null = null + +/** Files whose code, not only a comment, sets `hostGit: true`; one read of src/main per run. */ +function hostGitCallSources(): string[] { + hostGitSources ??= readdirSync(MAIN_DIR, { recursive: true, encoding: 'utf8' }) + .filter((file) => file.endsWith('.ts') && !TEST_SUPPORT.test(file)) + .map((file) => join(MAIN_DIR, file)) + .filter((file) => { + const source = readFileSync(file, 'utf8') + return ( + source.includes(HOST_GIT) && + source.split('\n').some((line) => line.includes(HOST_GIT) && !COMMENT_LINE.test(line)) + ) + }) + .map((file) => relative(REPO_ROOT, file).replaceAll('\\', '/')) + return hostGitSources +} + +const HOST_ONLY_ENTRIES = new Set() + +describe('local Git runtime routing guard', () => { + it('routes the options of every exported runner entry point', () => { + const checked: string[] = [] + for (const file of ENTRY_FILES) { + const source = readFileSync(join(RUNNER_DIR, file), 'utf8') + for (const body of source.split(/\nexport (?:async )?function /).slice(1)) { + const name = body.slice(0, body.indexOf('(')) + if (HOST_ONLY_ENTRIES.has(name)) { + continue + } + checked.push(name) + expect(body.slice(0, 400), `${file}: ${name}`).toContain( + 'routeLocalGitOptions(callerOptions)' + ) + } + } + expect(checked).toHaveLength(6) + }) + + it('has a reason above every deliberate host-Git call', () => { + const files = hostGitCallSources() + expect(files.length).toBeGreaterThan(0) + for (const file of files) { + const lines = readFileSync(join(REPO_ROOT, file), 'utf8').split('\n') + lines.forEach((line, index) => { + // Code only: a doc comment may name the option. + if (!line.includes('hostGit: true') || COMMENT_LINE.test(line)) { + return + } + const above = [lines[index - 1] ?? '', lines[index - 2] ?? ''].join('\n') + expect(above, `${file}:${index + 1}`).toMatch(/\/\/ Host Git on purpose: \S/) + }) + } + }) + + it('keeps deliberate host Git to the calls that run before a project owns the folder', () => { + expect([...hostGitCallSources()].sort()).toEqual([ + // Repo detection, read-only. + 'src/main/git/repo-detection-command.ts', + // Clone and init make a new repo. + 'src/main/ipc/repos/repo-clone-lifecycle.ts', + 'src/main/ipc/repos/repo-creation-handlers.ts', + // The checks whether Git for Windows can take a project over. + 'src/main/ipc/wsl-drive-project-handlers.ts', + 'src/main/ipc/wsl-drive-unc-conversion-handlers.ts', + // The remote read while a project is being added. + 'src/main/repo-icon-autodetect.ts', + 'src/main/runtime/runtime-repository-clone-controller.ts', + 'src/main/runtime/runtime-repository-registration-controller.ts', + // The setup runner's path lookup, synchronous; with the router on it reads the link instead. + 'src/main/worktree-runner-script.ts' + ]) + }) + + it('keeps synchronous Git host-only, so routing never puts wsl.exe on the main thread', () => { + const sync = readFileSync(join(RUNNER_DIR, 'git-exec-file-sync.ts'), 'utf8') + expect(sync).not.toContain('routeLocalGitOptions') + expect(sync).toContain('hostGit: HostGitOnly') + }) + + it('names which Git ran on every runner span', () => { + for (const file of ENTRY_FILES) { + const source = readFileSync(join(RUNNER_DIR, file), 'utf8') + const spans = source.match(/\b(withGitSpan|startGitSpan)\(/g) ?? [] + const runsIn = source.match(/'git\.runs_in'/g) ?? [] + expect(spans.length, file).toBeGreaterThan(0) + expect(runsIn.length, file).toBe(spans.length) + } + }) +}) diff --git a/src/main/git/command-runner/local-git-runtime-route.test.ts b/src/main/git/command-runner/local-git-runtime-route.test.ts new file mode 100644 index 00000000000..05d06c510dd --- /dev/null +++ b/src/main/git/command-runner/local-git-runtime-route.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + admitWslGit, + noteWslGitFailure, + resetWslGitAdmissionForTests, + routeLocalGitOptions, + setBackgroundWslGitGate, + setLocalGitRuntimeRouter, + WslGitSkippedError +} from './local-git-runtime-route' + +const originalPlatform = process.platform + +beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + setLocalGitRuntimeRouter((cwd) => (cwd.startsWith('C:\\wsl-project') ? 'Ubuntu' : undefined)) +}) + +afterEach(() => { + setLocalGitRuntimeRouter(null) + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) +}) + +describe('routeLocalGitOptions', () => { + it("runs a WSL-runtime project's Git in its distro when the caller passed only a folder", () => { + expect(routeLocalGitOptions({ cwd: String.raw`C:\wsl-project\src` })).toEqual({ + cwd: String.raw`C:\wsl-project\src`, + wslDistro: 'Ubuntu' + }) + }) + + it.each([ + ['a host-Git project', { cwd: String.raw`C:\windows-project` }], + ['an explicit distro', { cwd: String.raw`C:\wsl-project`, wslDistro: 'Debian' }], + ['a deliberate host-Git call', { cwd: String.raw`C:\wsl-project`, hostGit: true as const }], + ['a UNC folder, which names its own distro', { cwd: String.raw`\\wsl.localhost\Ubuntu\x` }] + ])('leaves %s as it is', (_name, options) => { + expect(routeLocalGitOptions(options)).toBe(options) + }) + + it('does nothing off Windows or without a router', () => { + const options = { cwd: String.raw`C:\wsl-project` } + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + expect(routeLocalGitOptions(options)).toBe(options) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + setLocalGitRuntimeRouter(null) + expect(routeLocalGitOptions(options)).toBe(options) + }) +}) + +describe('admitWslGit', () => { + afterEach(() => { + resetWslGitAdmissionForTests() + }) + + const routed = { cwd: String.raw`C:\wsl-project`, wslDistro: 'Ubuntu' } + const missing = new Error('There is no distribution with the supplied name.') + + it('fails at once, without wsl.exe, for a distro WSL just said is missing', async () => { + noteWslGitFailure(routed, missing, 1_000) + await expect(admitWslGit(routed, 2_000)).rejects.toBeInstanceOf(WslGitSkippedError) + expect(admitWslGit(routed, 31_001)).toBeNull() + }) + + it('remembers only a missing distro, not any other failure', () => { + noteWslGitFailure(routed, new Error('fatal: not a git repository'), 1_000) + expect(admitWslGit(routed, 2_000)).toBeNull() + }) + + it("holds background Git on a drive project until the project's distro runs", async () => { + const running = new Set() + setBackgroundWslGitGate(async (distro) => running.has(distro)) + await expect(admitWslGit({ ...routed, admissionTier: 'background' })).rejects.toThrow('stopped') + running.add('Ubuntu') + await expect(admitWslGit({ ...routed, admissionTier: 'background' })).resolves.toBeUndefined() + // Foreground Git, and a folder inside the distro, start it as before. + expect(admitWslGit({ ...routed, admissionTier: 'interactive' })).toBeNull() + expect( + admitWslGit({ + cwd: String.raw`\\wsl.localhost\Ubuntu\x`, + wslDistro: 'Ubuntu', + admissionTier: 'background' + }) + ).toBeNull() + }) +}) + +describe('the missing-distro cache with the router off', () => { + afterEach(() => { + resetWslGitAdmissionForTests() + }) + + it('is never armed or consulted, so every call runs as before D1c', () => { + const routed = { cwd: String.raw`C:\wsl-project`, wslDistro: 'Ubuntu' } + setLocalGitRuntimeRouter(null) + noteWslGitFailure(routed, new Error('WSL_E_DISTRO_NOT_FOUND'), 1_000) + expect(admitWslGit(routed, 2_000)).toBeNull() + // Turning the router on later finds nothing recorded while it was off. + setLocalGitRuntimeRouter(() => 'Ubuntu') + expect(admitWslGit(routed, 2_000)).toBeNull() + }) +}) diff --git a/src/main/git/command-runner/local-git-runtime-route.ts b/src/main/git/command-runner/local-git-runtime-route.ts new file mode 100644 index 00000000000..9dcfcd3f1ee --- /dev/null +++ b/src/main/git/command-runner/local-git-runtime-route.ts @@ -0,0 +1,138 @@ +/** + * Every local Git call follows its project's runtime, not only the callers that remember to pass + * it: a `C:\` project whose runtime is WSL runs WSL Git unless it chose Git for Windows. Mixing the + * two on one checkout is what lets one Git prune the other's worktrees (risk R36). A UNC cwd already + * names its distro; a caller that must run the host's Git anyway says so with `hostGit: true`. + */ +import { isWslDistroNotFoundFailure } from '../../wsl/wsl-distro-unsupported' +import type { GitAdmissionTier } from './git-exec-options' + +type RoutableGitOptions = { + cwd: string + wslDistro?: string + /** Runs the host's Git even in a WSL-runtime project; the call site says why. */ + hostGit?: true +} + +/** The distro a local folder's project runs Git in, or undefined for the host's Git. */ +export type LocalGitRuntimeRouter = (cwd: string) => string | undefined + +let router: LocalGitRuntimeRouter | null = null +let isDistroRunning: ((distro: string) => Promise) | null = null + +/** Main installs the store-backed router once the profile loads; null in tests and other hosts. */ +export function setLocalGitRuntimeRouter(next: LocalGitRuntimeRouter | null): void { + router = next +} + +/** Background Git on a WSL `C:\` project waits for its distro instead of booting it. */ +export function setBackgroundWslGitGate(next: ((distro: string) => Promise) | null): void { + isDistroRunning = next +} + +/** True with the D1c router on, so callers can pick routing-aware paths only then. */ +export function isLocalGitRuntimeRouterInstalled(): boolean { + return router !== null +} + +const WINDOWS_DRIVE_PATH = /^[A-Za-z]:[\\/]/ + +export function routeLocalGitOptions(options: T): T { + if ( + !router || + process.platform !== 'win32' || + options.wslDistro || + options.hostGit || + !WINDOWS_DRIVE_PATH.test(options.cwd) + ) { + return options + } + const wslDistro = router(options.cwd) + return wslDistro ? { ...options, wslDistro } : options +} + +export class WslGitSkippedError extends Error { + constructor( + readonly distro: string, + readonly reason: 'distro-missing' | 'distro-stopped' + ) { + super( + reason === 'distro-missing' + ? `WSL distro ${distro} is not installed, so its Git can't run.` + : `WSL distro ${distro} is stopped; background Git waits until it runs.` + ) + this.name = 'WslGitSkippedError' + } +} + +// Long enough that a poller's next ticks fail fast, short enough to notice a reinstall. +const MISSING_DISTRO_TTL_MS = 30_000 +const missingUntil = new Map() + +function distroKey(distro: string): string { + return distro.trim().toLowerCase() +} + +/** + * The checks before a WSL Git call starts, or null when there are none: a distro WSL just said is + * missing fails at once rather than spawning wsl.exe again, and background Git on a Windows-drive + * project never boots a stopped distro. + */ +export function admitWslGit( + options: RoutableGitOptions & { admissionTier?: GitAdmissionTier }, + now: number = Date.now() +): Promise | null { + const distro = options.wslDistro + // Only with the router installed (D1c); otherwise every call runs exactly as before it. + if (!distro || !router) { + return null + } + const until = missingUntil.get(distroKey(distro)) + if (until !== undefined && until > now) { + return Promise.reject(new WslGitSkippedError(distro, 'distro-missing')) + } + if ( + options.admissionTier !== 'background' || + !isDistroRunning || + !WINDOWS_DRIVE_PATH.test(options.cwd) + ) { + return null + } + return isDistroRunning(distro).then((running) => { + if (!running) { + throw new WslGitSkippedError(distro, 'distro-stopped') + } + }) +} + +/** Records a WSL Git failure that says the distro is gone; any other failure is left alone. */ +export function noteWslGitFailure( + options: Pick, + error: unknown, + now: number = Date.now() +): void { + if (router && options.wslDistro && isWslDistroNotFoundFailure(error)) { + missingUntil.set(distroKey(options.wslDistro), now + MISSING_DISTRO_TTL_MS) + } +} + +/** `run` after admitWslGit, recording a missing distro when WSL reports one. */ +export function runAdmittedWslGit( + options: RoutableGitOptions & { admissionTier?: GitAdmissionTier }, + run: () => Promise +): Promise { + if (!router) { + return run() + } + const admission = admitWslGit(options) + const started = admission ? admission.then(run) : run() + return started.catch((error: unknown) => { + noteWslGitFailure(options, error) + throw error + }) +} + +export function resetWslGitAdmissionForTests(): void { + missingUntil.clear() + isDistroRunning = null +} diff --git a/src/main/git/command-runner/wsl-command-resolution-allowlist.test.ts b/src/main/git/command-runner/wsl-command-resolution-allowlist.test.ts index a9c39cf026b..fbcf75397b3 100644 --- a/src/main/git/command-runner/wsl-command-resolution-allowlist.test.ts +++ b/src/main/git/command-runner/wsl-command-resolution-allowlist.test.ts @@ -13,7 +13,7 @@ vi.mock('node:child_process', async (original) => ({ vi.mock('../../wsl-background-distro-gate', () => ({ noteWslDistroLaunched: vi.fn() })) import { resolveCommand } from './wsl-command-resolution' -import { gitExecFileSync } from './git-exec-file' +import { gitExecFileSync } from './git-exec-file-sync' import { gitSpawn } from './git-spawn' import { wslAwareSpawn } from './wsl-aware-spawn' @@ -70,7 +70,7 @@ describe('resolveCommand under the internal WSL distro allowlist', () => { it('refuses through every direct child_process git spawn path before a child starts', () => { vi.stubEnv(ALLOWLIST, 'orca-test-a') - expect(() => gitExecFileSync(['status'], { cwd: UBUNTU_REPO })).toThrow( + expect(() => gitExecFileSync(['status'], { cwd: UBUNTU_REPO, hostGit: true })).toThrow( expect.objectContaining(REFUSED) ) expect(() => gitSpawn(['status'], { cwd: UBUNTU_REPO })).toThrow( diff --git a/src/main/git/repo-detection-command.test.ts b/src/main/git/repo-detection-command.test.ts index e3e1120feed..318356a7346 100644 --- a/src/main/git/repo-detection-command.test.ts +++ b/src/main/git/repo-detection-command.test.ts @@ -26,6 +26,7 @@ describe('repository command execution', () => { expect(gitExecFileSync).not.toHaveBeenCalled() expect(gitExecFileAsync).toHaveBeenCalledExactlyOnceWith(['rev-parse', '--show-toplevel'], { cwd: '/repo', + hostGit: true, timeout: 15_000 }) finish({ stdout: '/repo\n', stderr: '' }) diff --git a/src/main/git/repo-detection-command.ts b/src/main/git/repo-detection-command.ts index 778688e1547..7808844aab6 100644 --- a/src/main/git/repo-detection-command.ts +++ b/src/main/git/repo-detection-command.ts @@ -15,6 +15,8 @@ export async function runGitRepoCommands(commands: GitRepoCommands): Promi try { const { stdout } = await gitExecFileAsync(command.args, { cwd: command.cwd, + // Host Git on purpose: read-only detection of whether and where a folder is a repo. + hostGit: true, timeout: 15_000 }) step = commands.next(stdout) @@ -30,7 +32,8 @@ export function runGitRepoCommandsSync(commands: GitRepoCommands): T { while (!step.done) { const command = step.value try { - step = commands.next(gitExecFileSync(command.args, { cwd: command.cwd })) + // Host Git on purpose: read-only detection of whether and where a folder is a repo. + step = commands.next(gitExecFileSync(command.args, { cwd: command.cwd, hostGit: true })) } catch (error) { step = commands.throw(error) } diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 9dc369ec7da..857bce55439 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -17,11 +17,8 @@ export { promptGuardShellEnv, untranslatedGitOutputEnv } from './command-runner/git-process-env' -export { - gitExecFileAsync, - gitExecFileAsyncBuffer, - gitExecFileSync -} from './command-runner/git-exec-file' +export { gitExecFileAsync, gitExecFileAsyncBuffer } from './command-runner/git-exec-file' +export { gitExecFileSync } from './command-runner/git-exec-file-sync' export { commandExecFileAsync } from './command-runner/command-exec-file' export { gitStreamStdout, type GitStreamResult } from './command-runner/git-stream-stdout' export { diff --git a/src/main/git/worktree-git-path.test.ts b/src/main/git/worktree-git-path.test.ts new file mode 100644 index 00000000000..2763326867d --- /dev/null +++ b/src/main/git/worktree-git-path.test.ts @@ -0,0 +1,77 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readWorktreeGitPath } from './worktree-git-path' + +let root: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-worktree-git-path-')) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +const ROOTS = ['/mnt/', '/'] +// The mapped Windows git dirs exist only on Windows; these tests stand them in. +const exists = (): boolean => true + +describe('readWorktreeGitPath', () => { + it("puts a main checkout's file in its .git folder", () => { + mkdirSync(join(root, '.git')) + expect(readWorktreeGitPath(root, 'orca/setup-runner.sh', ROOTS)).toMatch( + /[\\/]\.git[\\/]orca[\\/]setup-runner\.sh$/ + ) + }) + + it("maps a WSL-made link, under a custom mount root too, to the worktree's own git dir", () => { + writeFileSync(join(root, '.git'), 'gitdir: /mnt/c/src/app/.git/worktrees/fix\n') + expect(readWorktreeGitPath(root, 'orca/setup-runner.sh', ROOTS, exists)).toBe( + String.raw`C:\src\app\.git\worktrees\fix\orca\setup-runner.sh` + ) + writeFileSync(join(root, '.git'), 'gitdir: /c/src/app/.git/worktrees/fix\n') + expect(readWorktreeGitPath(root, 'orca/x.sh', ROOTS, exists)).toBe( + String.raw`C:\src\app\.git\worktrees\fix\orca\x.sh` + ) + }) + + it('keeps a Windows-made link and reads a missing or unreadable one as unknown', () => { + writeFileSync(join(root, '.git'), 'gitdir: C:/src/app/.git/worktrees/fix\n') + expect(readWorktreeGitPath(root, 'orca/x.sh', ROOTS, exists)).toBe( + String.raw`C:\src\app\.git\worktrees\fix\orca\x.sh` + ) + writeFileSync(join(root, '.git'), 'gitdir: /home/me/app/.git/worktrees/fix\n') + expect(readWorktreeGitPath(root, 'orca/x.sh', ROOTS)).toBeNull() + rmSync(join(root, '.git')) + expect(readWorktreeGitPath(root, 'orca/x.sh', ROOTS)).toBeNull() + }) + + it('leaves common-dir paths to real Git', () => { + mkdirSync(join(root, '.git')) + for (const path of [ + 'hooks/pre-commit', + 'info/exclude', + 'objects', + 'refs/heads/x', + 'config', + 'packed-refs' + ]) { + expect(readWorktreeGitPath(root, path, ROOTS)).toBeNull() + } + expect(readWorktreeGitPath(root, 'HEAD', ROOTS)).not.toBeNull() + }) + + it('leaves a stale or pruned link to real Git, rather than recreating its git dir', () => { + const gitdir = join(root, 'repo', '.git', 'worktrees', 'fix') + mkdirSync(join(root, 'wt')) + // Relative, as Git writes with worktree.useRelativePaths; an absolute POSIX link would read as WSL's. + writeFileSync(join(root, 'wt', '.git'), 'gitdir: ../repo/.git/worktrees/fix\n') + expect(readWorktreeGitPath(join(root, 'wt'), 'orca/x.sh', ROOTS)).toBeNull() + mkdirSync(gitdir, { recursive: true }) + expect(readWorktreeGitPath(join(root, 'wt'), 'orca/x.sh', ROOTS)).toBe( + join(gitdir, 'orca', 'x.sh') + ) + }) +}) diff --git a/src/main/git/worktree-git-path.ts b/src/main/git/worktree-git-path.ts new file mode 100644 index 00000000000..634a87f8643 --- /dev/null +++ b/src/main/git/worktree-git-path.ts @@ -0,0 +1,76 @@ +/** + * `git rev-parse --git-path ` for a per-worktree file, read from the worktree's own `.git` + * instead of running Git: the setup runner needs it synchronously, and a synchronous Git in a WSL + * project would be a synchronous wsl.exe on the main thread. Null when the link isn't one this can + * read, so the caller falls back to Git. + */ +import { readFileSync, statSync } from 'node:fs' +import { join, resolve, win32 } from 'node:path' +import { parseWslUncPath, toWindowsWslUncPath } from '../../shared/wsl-paths' +import { wslDrivePathUnderRoots } from '../wsl/wsl-automount-root' + +// Git keeps these per worktree; everything else under the git dir (`hooks/`, `info/`, `objects/`, +// `refs/`, `config`, `packed-refs`, …) lives in the common dir, which only real Git resolves. +const PER_WORKTREE_ENTRIES: ReadonlySet = new Set([ + 'orca', + 'HEAD', + 'ORIG_HEAD', + 'FETCH_HEAD', + 'MERGE_HEAD', + 'index', + 'logs' +]) + +export function readWorktreeGitPath( + worktreePath: string, + relativePath: string, + automountRoots: readonly string[], + isDirectory: (path: string) => boolean = directoryExists +): string | null { + if (!PER_WORKTREE_ENTRIES.has(relativePath.split('/')[0] ?? '')) { + return null + } + const gitdir = readGitDir(worktreePath, automountRoots) + // A stale or pruned link: real Git reports it, where a write here would recreate half of it. + if (!gitdir || !isDirectory(gitdir)) { + return null + } + // A drive or UNC git dir keeps Windows separators wherever this runs. + const joinPath = /^([A-Za-z]:|[\\/]{2})/.test(gitdir) ? win32.join : join + return joinPath(gitdir, ...relativePath.split('/')) +} + +function readGitDir(worktreePath: string, automountRoots: readonly string[]): string | null { + const dotGit = join(worktreePath, '.git') + let link: string + try { + if (statSync(dotGit).isDirectory()) { + return dotGit + } + link = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, 'utf8'))?.[1]?.trim() ?? '' + } catch { + return null + } + if (!link) { + return null + } + if (!/^[\\/]/.test(link) || /^[\\/]{2}/.test(link)) { + // A drive path, a UNC path, or one relative to the worktree. + return /^[A-Za-z]:[\\/]/.test(link) ? win32.normalize(link) : resolve(worktreePath, link) + } + // WSL Git wrote the distro's view: a drive under its mount root, or a folder inside the distro. + const drive = wslDrivePathUnderRoots(link, automountRoots) + if (drive) { + return drive + } + const distro = parseWslUncPath(worktreePath)?.distro + return distro ? toWindowsWslUncPath(link, distro) : null +} + +function directoryExists(path: string): boolean { + try { + return statSync(path).isDirectory() + } catch { + return false + } +} diff --git a/src/main/hooks-setup-runner-script.test.ts b/src/main/hooks-setup-runner-script.test.ts index 3544d70f7fb..4762e597f6f 100644 --- a/src/main/hooks-setup-runner-script.test.ts +++ b/src/main/hooks-setup-runner-script.test.ts @@ -111,7 +111,7 @@ describe('createSetupRunnerScript', () => { expect(gitExecFileSyncMock).toHaveBeenCalledWith( ['rev-parse', '--git-path', 'orca/setup-runner.sh'], - { cwd: 'C:\\repo-worktree' } + { cwd: 'C:\\repo-worktree', hostGit: true } ) expect(writeFileSyncMock).toHaveBeenCalledWith( 'C:\\repo\\.git\\orca\\setup-runner.sh', @@ -150,7 +150,7 @@ describe('createSetupRunnerScript', () => { expect(gitExecFileSyncMock).toHaveBeenCalledWith( ['rev-parse', '--git-path', 'orca/setup-runner.cmd'], - { cwd: 'C:\\repo-worktree' } + { cwd: 'C:\\repo-worktree', hostGit: true } ) expect(writeFileSyncMock).toHaveBeenCalledWith( 'C:\\repo\\.git\\orca\\setup-runner.cmd', @@ -246,7 +246,7 @@ describe('createSetupRunnerScript', () => { expect(gitExecFileSyncMock).toHaveBeenCalledWith( ['rev-parse', '--git-path', 'orca/setup-runner.cmd'], - { cwd: 'C:\\repo-worktree' } + { cwd: 'C:\\repo-worktree', hostGit: true } ) expect(writeFileSyncMock).toHaveBeenCalledWith( 'C:\\repo\\.git\\orca\\setup-runner.cmd', @@ -281,7 +281,7 @@ describe('createSetupRunnerScript', () => { expect(gitExecFileSyncMock).toHaveBeenCalledWith( ['rev-parse', '--git-path', 'orca/setup-runner.sh'], - { cwd: '/test/worktree' } + { cwd: '/test/worktree', hostGit: true } ) expect(writeFileSyncMock).toHaveBeenCalledWith( '/test/repo/.git/orca/setup-runner.sh', diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index caf31a31594..2bfc06c0c35 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -309,7 +309,7 @@ describe('runHook', () => { } }) - it('writes Windows-path setup runners through WSL git when the project runtime targets WSL', async () => { + it('writes Windows-path setup runners for a project whose runtime targets WSL', async () => { gitExecFileSyncMock.mockReset() gitExecFileSyncMock.mockReturnValue('/mnt/c/Users/jinwo/git/orca/.git/orca/setup-runner.sh\n') @@ -338,9 +338,10 @@ describe('runHook', () => { expect(gitExecFileSyncMock).toHaveBeenCalledWith( ['rev-parse', '--git-path', 'orca/setup-runner.sh'], + // The lookup is synchronous, so it runs host Git (as on main) rather than a blocking wsl.exe. { cwd: 'C:\\Users\\jinwo\\git\\orca-feature', - wslDistro: 'Ubuntu' + hostGit: true } ) expect(result.runnerScriptPath).toContain('setup-runner.sh') diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 8a0d043abe5..a45d42b2c9c 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -3,6 +3,7 @@ import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { registerAppHandlers } from '../app' import { registerWslMoveWiring } from '../wsl-move-driver-wiring' import { registerWslDriveProjectHandlers } from '../wsl-drive-project-handlers' +import { installProjectRuntimeGitRouter } from '../../project-runtime-git-router' import { registerCliHandlers } from '../cli' import { registerPreflightHandlers } from '../preflight' import type { Store } from '../../persistence/loading-store/store' @@ -262,6 +263,7 @@ export function registerCoreHandlers( }) registerWslMoveWiring(store, runtime) registerWslDriveProjectHandlers(store, runtime) + installProjectRuntimeGitRouter(store) registerEphemeralVmHandlers(store, pluginService) // Session history and terminal resume are not chats; a refused host leaves nothing to check. const ensureStructuredSessionOwnership = () => diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index 88063c34db6..bf95a2ee5f7 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -297,11 +297,14 @@ describe('repos:create', () => { expect(mkdirMock).toHaveBeenNthCalledWith(1, '/tmp', { recursive: true }) expect(mkdirMock).toHaveBeenNthCalledWith(2, tmpPath('gitproj'), { recursive: false }) - expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['init'], { cwd: tmpPath('gitproj') }) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['init'], { + cwd: tmpPath('gitproj'), + hostGit: true + }) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['commit', '--allow-empty', '-m', 'Initial commit'], - { cwd: tmpPath('gitproj') } + { cwd: tmpPath('gitproj'), hostGit: true } ) expect(mockStore.addRepo).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/main/ipc/repos/repo-clone-lifecycle.ts b/src/main/ipc/repos/repo-clone-lifecycle.ts index bc1fec2eb8d..de20b9c0ebb 100644 --- a/src/main/ipc/repos/repo-clone-lifecycle.ts +++ b/src/main/ipc/repos/repo-clone-lifecycle.ts @@ -157,6 +157,8 @@ export function registerRepoCloneHandlers(mainWindow: BrowserWindow, store: Stor ['clone', '--progress', '--', args.url, clonePath], { cwd: args.destination, + // Host Git on purpose: the clone makes a new repo no project owns, even inside one. + hostGit: true, admissionTier: 'interactive', // Suppress Windows Git Credential Manager OAuth windows during noninteractive cloning (#7652). env: promptGuardGitEnv(), diff --git a/src/main/ipc/repos/repo-creation-handlers.ts b/src/main/ipc/repos/repo-creation-handlers.ts index a214ced6205..c543995a761 100644 --- a/src/main/ipc/repos/repo-creation-handlers.ts +++ b/src/main/ipc/repos/repo-creation-handlers.ts @@ -170,10 +170,13 @@ export function registerRepoCreationHandlers(mainWindow: BrowserWindow, store: S // The failing Git step controls attribution and commit-only identity hints. let step: 'init' | 'commit' = 'init' try { - await gitExecFileAsync(['init'], { cwd: targetPath }) + // Host Git on purpose: the new repo has no project yet, even inside one. + await gitExecFileAsync(['init'], { cwd: targetPath, hostGit: true }) step = 'commit' await gitExecFileAsync(['commit', '--allow-empty', '-m', 'Initial commit'], { - cwd: targetPath + cwd: targetPath, + // Host Git on purpose: the new repo has no project yet, even inside one. + hostGit: true }) } catch (err) { // Rollback may remove only what this request created; preserve preexisting folders. diff --git a/src/main/ipc/wsl-drive-project-handlers.ts b/src/main/ipc/wsl-drive-project-handlers.ts index 4b1dfba957b..bd3a30a93bc 100644 --- a/src/main/ipc/wsl-drive-project-handlers.ts +++ b/src/main/ipc/wsl-drive-project-handlers.ts @@ -62,8 +62,9 @@ function gatedDeps(store: Store): WslDriveProjectDeps { } return runtime }, - // No wslDistro: this runs the host's Git even for a WSL-runtime project. - runWindowsGit: async (args, cwd) => (await gitExecFileAsync(args, { cwd })).stdout, + // Host Git on purpose: this checks whether Git for Windows can take the project over. + runWindowsGit: async (args, cwd) => + (await gitExecFileAsync(args, { cwd, hostGit: true })).stdout, readWorktreeLinks } } diff --git a/src/main/ipc/wsl-drive-unc-conversion-handlers.ts b/src/main/ipc/wsl-drive-unc-conversion-handlers.ts index 4b20a8eb7a9..e870597d72f 100644 --- a/src/main/ipc/wsl-drive-unc-conversion-handlers.ts +++ b/src/main/ipc/wsl-drive-unc-conversion-handlers.ts @@ -68,8 +68,9 @@ export function registerWslDriveUncConversionHandlers( }, pause: (scope) => pauseWslMoveScope(createWslMoveDriverDeps(store, runtime, 'user'), scope), - // No wslDistro: this runs the host's Git, in the drive path. - runWindowsGit: async (gitArgs, cwd) => (await gitExecFileAsync(gitArgs, { cwd })).stdout, + // Host Git on purpose: Git for Windows must be able to own the converted project. + runWindowsGit: async (gitArgs, cwd) => + (await gitExecFileAsync(gitArgs, { cwd, hostGit: true })).stdout, readWorktreeLinks, restart: () => { if (!event.sender.isDestroyed()) { diff --git a/src/main/project-runtime-git-router-store.test.ts b/src/main/project-runtime-git-router-store.test.ts new file mode 100644 index 00000000000..359b24a17e9 --- /dev/null +++ b/src/main/project-runtime-git-router-store.test.ts @@ -0,0 +1,90 @@ +/** + * The router against the real Store: a runtime switch or a new worktree is routed on the very next + * Git call, because the Store's write drops the table synchronously. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../shared/wsl-host-internal-gate' +import { hostWorktreeTarget } from '../shared/worktree/host-qualified-identity' +import { + isLocalGitRuntimeRouterInstalled, + routeLocalGitOptions, + setLocalGitRuntimeRouter +} from './git/command-runner/local-git-runtime-route' +import { fixture } from './persistence/loading-store/profile-state-delayed-authority-fixture' +import { installProjectRuntimeGitRouter } from './project-runtime-git-router' + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('./ssh/config/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const originalPlatform = process.platform +const REPO = String.raw`C:\src\app` + +beforeEach(() => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) +}) + +afterEach(() => { + setLocalGitRuntimeRouter(null) + vi.unstubAllEnvs() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) +}) + +const distroFor = (cwd: string): string | undefined => + routeLocalGitOptions<{ cwd: string; wslDistro?: string }>({ cwd }).wslDistro + +describe('project runtime Git routing on the real Store', () => { + it.each([ + ['the WSL hosts gate is closed', '', 'win32'], + ['not on Windows', '1', 'linux'] + ])('installs no router, and drops one, when %s', async (_case, gate, platform) => { + const { store } = await fixture() + const onStateWritten = vi.spyOn(store, 'onStateWritten') + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, gate) + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + setLocalGitRuntimeRouter(() => 'Ubuntu') + installProjectRuntimeGitRouter(store) + expect(isLocalGitRuntimeRouterInstalled()).toBe(false) + expect(onStateWritten).not.toHaveBeenCalled() + expect(distroFor(REPO)).toBeUndefined() + }) + + it('never runs a Git call on a stale route after a runtime switch or a new worktree', async () => { + const { store } = await fixture() + store.updateSettings({ localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } }) + store.addRepo({ + id: 'app', + path: REPO, + displayName: 'app', + badgeColor: '', + addedAt: 0, + kind: 'git' + }) + installProjectRuntimeGitRouter(store) + expect(distroFor(REPO)).toBe('Ubuntu') + + const project = store.getProjects().find((entry) => entry.sourceRepoIds.includes('app')) + store.updateProject(project!.id, { + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu', git: 'windows-host' } + }) + expect(distroFor(REPO)).toBeUndefined() + + store.updateProject(project!.id, { + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Debian' } + }) + expect(distroFor(REPO)).toBe('Debian') + + const worktree = String.raw`D:\trees\app-fix` + expect(distroFor(worktree)).toBeUndefined() + store.setWorktreeMeta(hostWorktreeTarget({ id: `app::${worktree}`, hostId: 'local' }), { + isPinned: true + }) + expect(distroFor(`${worktree}\\src`)).toBe('Debian') + }) +}) diff --git a/src/main/project-runtime-git-router.test.ts b/src/main/project-runtime-git-router.test.ts new file mode 100644 index 00000000000..f7685833d75 --- /dev/null +++ b/src/main/project-runtime-git-router.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest' +import type { Project } from '../shared/project-types' +import type { Repo } from '../shared/repo-types' +import { + createProjectRuntimeGitRouter, + type DotGitReader, + type ProjectRuntimeGitRouterStore +} from './project-runtime-git-router' + +function repo(id: string, path: string): Repo { + return { id, path, displayName: id, badgeColor: '', addedAt: 0, kind: 'git' } +} + +function project(id: string, preference?: Project['localWindowsRuntimePreference']): Project { + return { + id, + displayName: id, + badgeColor: '', + sourceRepoIds: [id], + createdAt: 0, + updatedAt: 0, + ...(preference ? { localWindowsRuntimePreference: preference } : {}) + } +} + +function world() { + const state = { + repos: [ + repo('wsl', String.raw`C:\src\app`), + repo('nested', String.raw`C:\src\app\vendor\lib`), + repo('converted', String.raw`C:\src\fast`), + { ...repo('remote', String.raw`C:\src\remote`), executionHostId: 'ssh:box' as const } + ], + projects: [ + project('wsl'), + project('nested', { kind: 'windows-host' }), + project('converted', { kind: 'wsl', distro: 'Ubuntu', git: 'windows-host' }), + project('remote') + ], + worktreeMeta: new Map([[String.raw`wsl::D:\trees\app-fix`, {}]]) + } + const store: ProjectRuntimeGitRouterStore = { + getRepos: () => state.repos, + getProjects: () => state.projects, + getSettings: () => ({ localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } }), + getAllWorktreeMeta: () => Object.fromEntries(state.worktreeMeta) + } + // A null link is a main checkout's `.git` folder. + const links = new Map() + const realpaths = new Map() + let walks = 0 + const readDotGit: DotGitReader = (folder) => { + walks += 1 + const root = [...links.keys()].find((path) => folder.startsWith(path)) + return root ? { root, gitdir: links.get(root) ?? null } : null + } + const automountRoots = ['/mnt/'] + const router = createProjectRuntimeGitRouter(store, { + readDotGit, + realpath: (folder) => realpaths.get(folder) ?? null, + automountRoots: () => automountRoots + }) + return { state, router, links, realpaths, automountRoots, walks: () => walks } +} + +describe('createProjectRuntimeGitRouter', () => { + it("routes a project's folders and its known worktrees by its runtime", () => { + const { router } = world() + expect(router(String.raw`C:\src\app`)).toBe('Ubuntu') + expect(router(String.raw`c:/SRC/app/src`)).toBe('Ubuntu') + expect(router(String.raw`D:\trees\app-fix\pkg`)).toBe('Ubuntu') + }) + + it('keeps host Git for a converted project, a nested Windows project and unknown folders', () => { + const { router } = world() + expect(router(String.raw`C:\src\fast`)).toBeUndefined() + expect(router(String.raw`C:\src\app\vendor\lib\x`)).toBeUndefined() + expect(router(String.raw`C:\src\remote`)).toBeUndefined() + expect(router(String.raw`C:\elsewhere`)).toBeUndefined() + }) + + it('takes a runtime switch on the very next call once the write drops the table', () => { + const { state, router } = world() + expect(router(String.raw`C:\src\app`)).toBe('Ubuntu') + state.projects = state.projects.map((entry) => + entry.id === 'wsl' + ? { + ...entry, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu', git: 'windows-host' } + } + : entry + ) + router.invalidate() + expect(router(String.raw`C:\src\app`)).toBeUndefined() + state.projects = state.projects.map((entry) => + entry.id === 'converted' + ? { ...entry, localWindowsRuntimePreference: { kind: 'wsl', distro: 'Debian' } } + : entry + ) + router.invalidate() + expect(router(String.raw`C:\src\fast`)).toBe('Debian') + }) + + it('routes a worktree created a moment ago, before its metadata exists, by its .git link', () => { + const { router, links } = world() + links.set(String.raw`E:\new\feature`, 'C:/src/app/.git/worktrees/feature') + expect(router(String.raw`E:\new\feature\src`)).toBe('Ubuntu') + links.set(String.raw`E:\new\fast-wt`, 'C:/src/fast/.git/worktrees/fast-wt') + expect(router(String.raw`E:\new\fast-wt`)).toBeUndefined() + }) + + it('rebuilds on a miss at most once per write, in case a write came without its event', () => { + const { state, router } = world() + expect(router(String.raw`C:\src\app`)).toBe('Ubuntu') + state.repos = [...state.repos, repo('added', String.raw`F:\added`)] + state.projects = [...state.projects, project('added')] + expect(router(String.raw`F:\added\src`)).toBe('Ubuntu') + state.repos = [...state.repos, repo('later', String.raw`H:\later`)] + state.projects = [...state.projects, project('later')] + expect(router(String.raw`H:\later`)).toBeUndefined() + router.invalidate() + expect(router(String.raw`H:\later`)).toBe('Ubuntu') + }) + + it('routes by the saved preference even when the distro is missing, so Git fails in WSL', () => { + const { state, router } = world() + state.projects = state.projects.map((entry) => + entry.id === 'wsl' + ? { ...entry, localWindowsRuntimePreference: { kind: 'wsl', distro: 'Gone' } } + : entry + ) + router.invalidate() + expect(router(String.raw`C:\src\app`)).toBe('Gone') + }) + + it('routes a worktree WSL Git made, whose link is the distro view of the drive', () => { + const { router, links } = world() + links.set(String.raw`E:\new\wsl-made`, '/mnt/c/src/app/.git/worktrees/wsl-made') + expect(router(String.raw`E:\new\wsl-made\pkg`)).toBe('Ubuntu') + links.set(String.raw`E:\new\relative`, '../../src/app/.git/worktrees/relative') + links.set(String.raw`C:\x\y`, '../../src/app/.git/worktrees/relative') + expect(router(String.raw`C:\x\y`)).toBe('Ubuntu') + links.set(String.raw`E:\new\in-distro`, '/home/me/app/.git/worktrees/in-distro') + expect(router(String.raw`E:\new\in-distro`)).toBeUndefined() + links.set(String.raw`E:\new\unc`, '//wsl.localhost/Ubuntu/home/me/app/.git/worktrees/unc') + expect(router(String.raw`E:\new\unc`)).toBeUndefined() + }) + + it('routes a main checkout reached through a symlink, junction or subst drive by its real folder', () => { + const { router, links, realpaths } = world() + links.set(String.raw`S:\app`, null) + realpaths.set(String.raw`S:\app`, String.raw`C:\src\app`) + expect(router(String.raw`S:\app\src`)).toBe('Ubuntu') + }) + + it('traces each unknown folder once per write, and again after the next write', () => { + const { router, walks } = world() + router(String.raw`G:\scratch`) + router(String.raw`G:\scratch`) + expect(walks()).toBe(1) + router.invalidate() + router(String.raw`G:\scratch`) + expect(walks()).toBe(2) + }) + + it("maps a link under a distro's custom mount root into a known WSL project's drive tree", () => { + const { router, links, automountRoots } = world() + links.set(String.raw`E:\new\rooted`, '/c/src/app/.git/worktrees/rooted') + // `[automount] root=/` not read yet: the link names no known project. + expect(router(String.raw`E:\new\rooted`)).toBeUndefined() + automountRoots.push('/') + router.invalidate() + expect(router(String.raw`E:\new\rooted`)).toBe('Ubuntu') + // Under that root, a path that maps to no project's drive tree still routes nothing. + links.set(String.raw`E:\new\elsewhere`, '/c/other/.git/worktrees/elsewhere') + expect(router(String.raw`E:\new\elsewhere`)).toBeUndefined() + }) + + it('names the distros its projects run Git in, for reading their mount roots', () => { + const { router } = world() + expect(router.distros()).toEqual(['Ubuntu']) + }) +}) diff --git a/src/main/project-runtime-git-router.ts b/src/main/project-runtime-git-router.ts new file mode 100644 index 00000000000..b2c851b7bfc --- /dev/null +++ b/src/main/project-runtime-git-router.ts @@ -0,0 +1,277 @@ +/** + * The store-backed router behind local-git-runtime-route.ts: a folder inside a local project's + * repo or one of its worktrees runs Git where that project's runtime does. + * + * Never stale in the dangerous direction (host Git on a WSL project's checkout): the table is + * dropped on every store write that can change it, before the writer's next statement, and a + * folder the table doesn't know is traced through its `.git` link to the repo it belongs to. + */ +import { win32 } from 'node:path' +import { readFileSync, realpathSync, statSync } from 'node:fs' +import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' +import { resolveProjectExecutionRuntime } from '../shared/project-execution-runtime' +import type { GlobalSettings } from '../shared/global-settings-types' +import type { Project } from '../shared/project-types' +import { isFolderRepo } from '../shared/repo-kind' +import type { Repo } from '../shared/repo-types' +import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../shared/worktree/id' +import { + setBackgroundWslGitGate, + setLocalGitRuntimeRouter, + type LocalGitRuntimeRouter +} from './git/command-runner/local-git-runtime-route' +import { isWslDistroRunningForBackgroundWork } from './wsl-background-distro-gate' +import { wslHostsGateOpen } from './wsl/wsl-host-gate' +import { + followWslAutomountRoots, + knownWslAutomountRoots, + onWslAutomountRootsChanged, + wslDrivePathUnderRoots +} from './wsl/wsl-automount-root' + +export type ProjectRuntimeGitRouterStore = { + getRepos: () => Repo[] + getProjects: () => Project[] + getSettings: () => Partial> + getAllWorktreeMeta: () => Record +} + +/** The `.git` entry at or above a folder: a main checkout's directory or a worktree's link file. */ +export type DotGitReader = (folder: string) => { root: string; gitdir: string | null } | null + +export type ProjectRuntimeGitRouterFs = { + readDotGit: DotGitReader + /** The real folder behind a symlink, junction or `subst` drive; null when it can't be read. */ + realpath: (folder: string) => string | null + /** Each WSL project distro's drive mount root (`/mnt/` by default), for links WSL Git wrote. */ + automountRoots: () => readonly string[] +} + +type Route = { root: string; wslDistro: string | undefined } + +export type ProjectRuntimeGitRouter = LocalGitRuntimeRouter & { + /** Drops the table and every traced folder; the next lookup rebuilds from the store. */ + invalidate: () => void + /** The distros the projects' Git runs in. */ + distros: () => string[] +} + +export function createProjectRuntimeGitRouter( + store: ProjectRuntimeGitRouterStore, + fs: ProjectRuntimeGitRouterFs = { + readDotGit: readNearestDotGit, + realpath: readRealpath, + automountRoots: knownWslAutomountRoots + } +): ProjectRuntimeGitRouter { + let routes: Route[] | null = null + // Bumped by each write, so a miss rebuilds the table at most once per write. + let generation = 0 + let rebuiltForGeneration = -1 + // Each folder is traced once per write; the walk stats the disk, on the main thread. + const traced = new Map() + const table = (): Route[] => (routes ??= buildRoutes(store)) + const lookup = (folder: string): Route | undefined => + table().find( + ({ root }) => folder === root || folder.startsWith(root.endsWith('/') ? root : `${root}/`) + ) + const trace = (cwd: string): string | undefined => { + const found = fs.readDotGit(cwd) + if (!found) { + return undefined + } + if (found.gitdir !== null) { + // A worktree created a moment ago has no metadata yet: follow its link to its repo. + const repos = linkedRepoPaths(found.root, found.gitdir, fs.automountRoots()) + return repos + .map((repo) => lookup(normalizeRuntimePathForComparison(repo))) + .find((route) => route !== undefined)?.wslDistro + } + const target = found.root + const known = lookup(normalizeRuntimePathForComparison(target)) + if (known) { + return known.wslDistro + } + // A symlink, junction or `subst` drive can name a WSL project's checkout under another path. + const real = fs.realpath(target) + return real ? lookup(normalizeRuntimePathForComparison(real))?.wslDistro : undefined + } + const route = (cwd: string): string | undefined => { + const folder = normalizeRuntimePathForComparison(cwd) + let known = lookup(folder) + if (!known && rebuiltForGeneration !== generation) { + // A miss rebuilds once, in case a write reached the store without a change event. + rebuiltForGeneration = generation + routes = null + known = lookup(folder) + } + if (known) { + return known.wslDistro + } + if (!traced.has(folder)) { + traced.set(folder, trace(cwd)) + } + return traced.get(folder) + } + const invalidate = (): void => { + routes = null + traced.clear() + generation += 1 + } + return Object.assign(route, { + invalidate, + distros: () => [...new Set(table().flatMap(({ wslDistro }) => (wslDistro ? [wslDistro] : [])))] + }) +} + +/** + * The main checkouts a linked worktree's `gitdir` may point into, most likely first. Windows Git + * writes a drive path; WSL Git writes the distro's view, `/…`, and the + * root differs per distro, so each known root is a candidate the caller checks against its + * projects. A link into the distro itself (`/home/…`, `//wsl.localhost/…`) names no drive folder. + */ +function linkedRepoPaths( + root: string, + gitdir: string, + automountRoots: readonly string[] +): string[] { + const windowsForms = /^[\\/]/.test(gitdir) + ? automountRoots.flatMap((mount) => wslDrivePathUnderRoots(gitdir, [mount]) ?? []) + : [win32.resolve(root, gitdir)] + return windowsForms.flatMap((form) => { + const match = /^(.*)[\\/]worktrees[\\/][^\\/]+[\\/]?$/.exec(form) + const commonDir = match ? win32.resolve(match[1]) : null + return commonDir && win32.basename(commonDir).toLowerCase() === '.git' + ? [win32.dirname(commonDir)] + : [] + }) +} + +// Deep enough for any checkout, bounded so a cwd on a dead mapped drive stats only so far. +const MAX_WALK_DEPTH = 16 + +/** Windows paths only: the router routes drive-path folders. */ +function readNearestDotGit(folder: string): { root: string; gitdir: string | null } | null { + let current = folder + for (let depth = 0; depth < MAX_WALK_DEPTH; depth += 1) { + const entry = win32.join(current, '.git') + try { + if (statSync(entry).isDirectory()) { + return { root: current, gitdir: null } + } + const link = /^gitdir:\s*(.+)$/m.exec(readFileSync(entry, 'utf8')) + return { root: current, gitdir: link ? link[1].trim() : null } + } catch { + // No `.git` here; keep walking up. + } + const parent = win32.dirname(current) + if (parent === current) { + return null + } + current = parent + } + return null +} + +function readRealpath(folder: string): string | null { + try { + return realpathSync.native(folder) + } catch { + return null + } +} + +function buildRoutes(store: ProjectRuntimeGitRouterStore): Route[] { + const projects = store.getProjects() + const globalDefault = store.getSettings().localWindowsRuntimeDefault + const localRepos = new Map( + store + .getRepos() + .filter((repo) => getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID) + .map((repo) => [repo.id, repo]) + ) + const distroByRepo = new Map( + [...localRepos.values()].map((repo) => [repo.id, wslGitDistro(repo, projects, globalDefault)]) + ) + // Host-Git routes stay too, so a project nested in a WSL one keeps its own Git. + const routes: Route[] = [...localRepos.values()].map((repo) => ({ + root: normalizeRuntimePathForComparison(repo.path), + wslDistro: distroByRepo.get(repo.id) + })) + for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { + const repoId = getRepoIdFromWorktreeId(worktreeId) + const parsed = localRepos.has(repoId) ? splitWorktreeIdForFilesystem(worktreeId) : null + if (parsed) { + routes.push({ + root: normalizeRuntimePathForComparison(parsed.worktreePath), + wslDistro: distroByRepo.get(repoId) + }) + } + } + // Longest first, so a worktree nested in another project's folder takes its own runtime. + return routes.sort((left, right) => right.root.length - left.root.length) +} + +/** + * From the saved preference alone, not WSL's discovery caches: a missing distro makes the call + * fail in WSL, which is safe, where falling back to host Git on a WSL checkout is not. + */ +function wslGitDistro( + repo: Repo, + projects: readonly Project[], + globalWindowsRuntimeDefault: unknown +): string | undefined { + if (isFolderRepo(repo)) { + return undefined + } + const project = projects.find((entry) => entry.sourceRepoIds.includes(repo.id)) + const resolution = resolveProjectExecutionRuntime({ + appPlatform: 'win32', + projectId: project?.id ?? repo.id, + projectRuntimePreference: project?.localWindowsRuntimePreference, + globalWindowsRuntimeDefault + }) + return resolution.status === 'resolved' && + resolution.runtime.kind === 'wsl' && + resolution.runtime.git !== 'windows-host' + ? resolution.runtime.distro + : undefined +} + +const ROUTE_DOMAINS: ReadonlySet = new Set([ + 'repos', + 'projects', + 'settings', + 'worktreeMeta', + 'worktreeMetaByIdentity', + 'worktreeIdentityAliases' +]) + +/** + * Ships with WSL hosts (D1c): until then a WSL-runtime `C:\` project keeps today's mix, because + * routing all of its Git through WSL is ~300x slower there before it can switch to Git for Windows. + */ +export function installProjectRuntimeGitRouter( + store: ProjectRuntimeGitRouterStore & { + onStateWritten: (listener: (dirtyDomains: readonly string[] | undefined) => void) => () => void + } +): void { + if (process.platform !== 'win32' || !wslHostsGateOpen()) { + setLocalGitRuntimeRouter(null) + setBackgroundWslGitGate(null) + return + } + setBackgroundWslGitGate(isWslDistroRunningForBackgroundWork) + const router = createProjectRuntimeGitRouter(store) + // Synchronous with the write, so no Git call can run between a change and the table's drop. + store.onStateWritten((domains) => { + if (!domains || domains.some((domain) => ROUTE_DOMAINS.has(domain))) { + router.invalidate() + } + }) + // A distro's mount root changes how links its Git wrote map back to a drive. + followWslAutomountRoots(router.distros) + onWslAutomountRootsChanged(router.invalidate) + setLocalGitRuntimeRouter(router) +} diff --git a/src/main/repo-git-remote-identity-enrichment.test.ts b/src/main/repo-git-remote-identity-enrichment.test.ts index de131f743ab..014e7db4bc4 100644 --- a/src/main/repo-git-remote-identity-enrichment.test.ts +++ b/src/main/repo-git-remote-identity-enrichment.test.ts @@ -8,6 +8,7 @@ import { type GitRemoteIdentityProbe, probeGitRemoteIdentity } from './repo-git- import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../shared/wsl-host-internal-gate' import type * as DistroGate from './wsl-background-distro-gate' import type { WslDistroRunningState } from './wsl-background-distro-gate' +import { setLocalGitRuntimeRouter } from './git/command-runner/local-git-runtime-route' import { enrichMissingRepoGitRemoteIdentities, flushRepoGitRemoteIdentityEnrichmentForTests, @@ -186,6 +187,22 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { expect(probeGitRemoteIdentity).not.toHaveBeenCalled() }) + it.each([ + ['keeps its tier without the Git router', null, undefined], + ['waits as background work with the Git router on', () => undefined, 'background'] + ])('sweeps a local repo: %s', async (_case, router, tier) => { + vi.mocked(probeGitRemoteIdentity).mockResolvedValue(resolvedProbe) + setLocalGitRuntimeRouter(router) + try { + enrichMissingRepoGitRemoteIdentities(makeStore(makeRepo())) + } finally { + setLocalGitRuntimeRouter(null) + } + + expect(vi.mocked(probeGitRemoteIdentity).mock.calls[0]?.[2]?.admissionTier).toBe(tier) + await flushRepoGitRemoteIdentityEnrichmentForTests() + }) + it('probes an SSH row that carries only executionHostId on its own host', async () => { vi.mocked(probeGitRemoteIdentity).mockResolvedValue(resolvedProbe) const store = makeStore(makeRepo({ executionHostId: 'ssh:builder' })) diff --git a/src/main/repo-git-remote-identity-enrichment.ts b/src/main/repo-git-remote-identity-enrichment.ts index 93d135cdd68..e66b14784ac 100644 --- a/src/main/repo-git-remote-identity-enrichment.ts +++ b/src/main/repo-git-remote-identity-enrichment.ts @@ -12,6 +12,7 @@ import { getProjectProviderIdentity } from '../shared/project-host-setup-project import { getStoredRepoExecutionHostId } from './repo-execution-host' import { probeGitRemoteIdentity } from './repo-git-remote-identity' import { getProjectRuntimeReadGitOptions } from './project-runtime-git-options' +import { isLocalGitRuntimeRouterInstalled } from './git/command-runner/local-git-runtime-route' import type { ProjectRuntimeResolutionStore } from './local-project-runtime-resolution' import { isWslMoveRepoBlocked } from './persistence/moving-wsl-projects/wsl-move-background-backoff' import { blockedWslDistroForPath, wslDistroForPath } from './wsl/wsl-unc-access-gate' @@ -174,6 +175,8 @@ async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo) } const result = await probeGitRemoteIdentity(repo.path, hostId, { signal: controller.signal, + // Only the D1c router reads it, to wait for a stopped distro; main's sweep keeps its tier. + ...(isLocalGitRuntimeRouterInstalled() ? { admissionTier: 'background' as const } : {}), ...git }) // Aborted probes resolve as unavailable; prevent retired probes from re-seeding deadlines. diff --git a/src/main/repo-git-remote-identity.ts b/src/main/repo-git-remote-identity.ts index 56ad4e246f0..71eacfb4c1f 100644 --- a/src/main/repo-git-remote-identity.ts +++ b/src/main/repo-git-remote-identity.ts @@ -12,6 +12,10 @@ export type GitRemoteIdentityProbeOptions = { timeoutMs?: number /** The distro a local project's runtime runs Git in. */ wslDistro?: string + /** Before the project exists: its remote is read with the host's Git. */ + hostGit?: true + /** A sweep's read waits for a stopped distro rather than booting it. */ + admissionTier?: 'background' } /** no-remote proves absence; unavailable leaves the repo identity unknown. */ @@ -35,7 +39,9 @@ export async function runGitProbeOnHost( cwd: repoPath, timeout: options.timeoutMs ?? LOCAL_PROBE_TIMEOUT_MS, signal: options.signal, - ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}) + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.hostGit ? { hostGit: options.hostGit } : {}), + ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}) }) } diff --git a/src/main/repo-icon-autodetect.ts b/src/main/repo-icon-autodetect.ts index f5c8a6f6c9b..30d77e856be 100644 --- a/src/main/repo-icon-autodetect.ts +++ b/src/main/repo-icon-autodetect.ts @@ -141,7 +141,12 @@ export async function detectRepoIconAndUpstream({ ? await getRepoUpstream(repoPath, remoteRead.connectionId) : null const gitRemoteIdentity = - kind === 'git' ? await detectGitRemoteIdentity(repoPath, executionHostId) : null + kind === 'git' + ? await detectGitRemoteIdentity(repoPath, executionHostId, { + // Host Git on purpose: this runs while the project is being added, before it exists. + hostGit: true + }) + : null const repoIcon = await detectRepoIcon({ repoPath, kind, executionHostId, upstream }) return { ...(repoIcon ? { repoIcon } : {}), diff --git a/src/main/runtime/runtime-repository-clone-controller.ts b/src/main/runtime/runtime-repository-clone-controller.ts index ddbb74facff..5fcee0414b1 100644 --- a/src/main/runtime/runtime-repository-clone-controller.ts +++ b/src/main/runtime/runtime-repository-clone-controller.ts @@ -113,6 +113,8 @@ export class RuntimeRepositoryCloneController { ['clone', '--progress', '--', trimmedUrl, clonePath], { cwd: trimmedDestination, + // Host Git on purpose: the clone makes a new repo no project owns, even inside one. + hostGit: true, admissionTier: 'interactive', env: promptGuardGitEnv(), stdio: ['ignore', 'ignore', 'pipe'] diff --git a/src/main/runtime/runtime-repository-registration-controller.ts b/src/main/runtime/runtime-repository-registration-controller.ts index 28e50fa5a5c..4da48b8d8a7 100644 --- a/src/main/runtime/runtime-repository-registration-controller.ts +++ b/src/main/runtime/runtime-repository-registration-controller.ts @@ -223,10 +223,13 @@ export class RuntimeRepositoryRegistrationController { ): Promise { let step: 'init' | 'commit' = 'init' try { - await gitExecFileAsync(['init'], { cwd: targetPath }) + // Host Git on purpose: the new repo has no project yet, even inside one. + await gitExecFileAsync(['init'], { cwd: targetPath, hostGit: true }) step = 'commit' await gitExecFileAsync(['commit', '--allow-empty', '-m', 'Initial commit'], { - cwd: targetPath + cwd: targetPath, + // Host Git on purpose: the new repo has no project yet, even inside one. + hostGit: true }) return null } catch (error) { diff --git a/src/main/worktree-runner-script-link.test.ts b/src/main/worktree-runner-script-link.test.ts new file mode 100644 index 00000000000..4527a9dfb47 --- /dev/null +++ b/src/main/worktree-runner-script-link.test.ts @@ -0,0 +1,89 @@ +/** + * The setup runner's path lookup with the D1c Git router on: read from the worktree's link, no Git + * process; with it off, the host Git lookup main runs. + */ +import type * as GitRunner from './git/runner' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeHookTestRepo } from './hooks-test-fixtures' +import { setLocalGitRuntimeRouter } from './git/command-runner/local-git-runtime-route' + +vi.mock('fs', () => ({ + readFileSync: vi.fn(), + existsSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + rmSync: vi.fn(), + chmodSync: vi.fn() +})) + +const { gitExecFileSyncMock, readWorktreeGitPathMock } = vi.hoisted(() => ({ + gitExecFileSyncMock: vi.fn(() => '/mnt/c/src/app/.git/orca/setup-runner.sh\n'), + readWorktreeGitPathMock: vi.fn( + (_worktree: string, _relative: string, _roots: readonly string[]): string | null => + String.raw`C:\src\app\.git\worktrees\fix\orca\setup-runner.sh` + ) +})) + +vi.mock('./git/runner', async () => ({ + ...(await vi.importActual('./git/runner')), + gitExecFileSync: gitExecFileSyncMock +})) +vi.mock('./git/worktree-git-path', () => ({ readWorktreeGitPath: readWorktreeGitPathMock })) + +const originalPlatform = process.platform +const WORKTREE = String.raw`C:\src\app-fix` + +beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + gitExecFileSyncMock.mockClear() + readWorktreeGitPathMock.mockClear() +}) + +afterEach(() => { + setLocalGitRuntimeRouter(null) + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) +}) + +async function create() { + const { createSetupRunnerScript } = await import('./worktree-runner-script') + return createSetupRunnerScript( + { ...makeHookTestRepo(), path: String.raw`C:\src\app` }, + WORKTREE, + 'echo hello', + { wslDistro: 'Ubuntu' } + ) +} + +describe('createSetupRunnerScript git path for a WSL-runtime drive project', () => { + it('reads the worktree link, starting no Git, with the router on', async () => { + setLocalGitRuntimeRouter(() => 'Ubuntu') + const result = await create() + expect(readWorktreeGitPathMock).toHaveBeenCalledWith( + WORKTREE, + 'orca/setup-runner.sh', + expect.arrayContaining(['/mnt/']) + ) + expect(gitExecFileSyncMock).not.toHaveBeenCalled() + expect(result.runnerScriptPath).toBe( + String.raw`C:\src\app\.git\worktrees\fix\orca\setup-runner.sh` + ) + }) + + it('falls back to the host Git lookup when the link cannot be read', async () => { + setLocalGitRuntimeRouter(() => 'Ubuntu') + readWorktreeGitPathMock.mockReturnValueOnce(null) + const result = await create() + expect(gitExecFileSyncMock).toHaveBeenCalledOnce() + expect(result.runnerScriptPath).toBe(String.raw`C:\src\app\.git\orca\setup-runner.sh`) + }) + + it('runs the host Git lookup main runs, with the router off', async () => { + const result = await create() + expect(readWorktreeGitPathMock).not.toHaveBeenCalled() + expect(gitExecFileSyncMock).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.sh'], + { cwd: WORKTREE, hostGit: true } + ) + expect(result.runnerScriptPath).toBe(String.raw`C:\src\app\.git\orca\setup-runner.sh`) + }) +}) diff --git a/src/main/worktree-runner-script.ts b/src/main/worktree-runner-script.ts index 40cac5e477f..a58b89155da 100644 --- a/src/main/worktree-runner-script.ts +++ b/src/main/worktree-runner-script.ts @@ -6,6 +6,9 @@ import { scriptDeclaresPosixShell } from '../shared/setup-script-shebang' import { resolveWindowsShellStartupFamily } from '../shared/windows-terminal-shell' import { resolveWindowsGitBashShellPath } from './git-bash' import { gitExecFileSync } from './git/runner' +import { isLocalGitRuntimeRouterInstalled } from './git/command-runner/local-git-runtime-route' +import { readWorktreeGitPath } from './git/worktree-git-path' +import { knownWslAutomountRoots } from './wsl/wsl-automount-root' import { isWslPath, toWindowsWslPath, toLinuxPath } from './wsl' import { getHookRuntimeTarget, getHookWslContext } from './hook-runtime-target' import { SETUP_RUNNER_PATH_ENV_KEYS, getSetupRunnerEnvVars } from './setup-hook-env-vars' @@ -19,10 +22,11 @@ import type { SetupRunnerShell } from '../shared/setup-runner-command' type SetupRunnerShellSettings = Record | undefined -function getGitPath(cwd: string, relativePath: string, runtimeTarget?: HookRuntimeTarget): string { +function getGitPath(cwd: string, relativePath: string): string { return gitExecFileSync(['rev-parse', '--git-path', relativePath], { cwd, - ...(runtimeTarget?.wslDistro ? { wslDistro: runtimeTarget.wslDistro } : {}) + // Host Git on purpose: a read-only path lookup, kept off a synchronous wsl.exe. + hostGit: true }).trim() } @@ -84,10 +88,14 @@ function createWorktreeRunnerScript(args: { // Linked worktrees have .git files; resolve the per-worktree gitdir through Git. const runnerExtension = runnerShell.family === 'cmd' ? 'cmd' : 'sh' const gitRelPath = `orca/${runnerBaseName}.${runnerExtension}` - let runnerScriptPath = getGitPath(worktreePath, gitRelPath, runtimeTarget) + // With the D1c Git router, read the link: if WSL Git made the worktree, host Git can't. + const fromLink = isLocalGitRuntimeRouterInstalled() + ? readWorktreeGitPath(worktreePath, gitRelPath, knownWslAutomountRoots()) + : null + let runnerScriptPath = fromLink ?? getGitPath(worktreePath, gitRelPath) // Convert WSL Git's Linux path to a path Windows filesystem calls can reach. - if (wslWorktree) { + if (wslWorktree && !fromLink) { const wslInfo = getHookWslContext(worktreePath, runtimeTarget) if (wslInfo?.distro) { runnerScriptPath = toWindowsWslPath(runnerScriptPath.trim(), wslInfo.distro) diff --git a/src/main/wsl/wsl-automount-root.test.ts b/src/main/wsl/wsl-automount-root.test.ts new file mode 100644 index 00000000000..9eeebdadd70 --- /dev/null +++ b/src/main/wsl/wsl-automount-root.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// The gate's running reads go through wsl.exe; these tests feed the cache their own lists. +vi.mock('../wsl', () => ({ listRunningWslDistrosAsync: vi.fn(), listWslDistrosAsync: vi.fn() })) + +import { + _resetWslBackgroundDistroGateForTests, + noteWslDistroMayHaveStopped, + WSL_STOP_SETTLE_MS +} from '../wsl-background-distro-gate' +import { + _resetRunningWslDistroCacheForTests, + resolveRunningWslDistros +} from '../wsl-running-distro-cache' +import { + followWslAutomountRoots, + knownWslAutomountRoots, + onWslAutomountRootsChanged, + parseWslAutomountRoot, + resetWslAutomountRootsForTests +} from './wsl-automount-root' + +let now = 1_000 +const unfollows: (() => void)[] = [] + +function follow(read: (distro: string) => Promise): void { + unfollows.push(followWslAutomountRoots(() => ['Ubuntu'], read)) +} + +/** One confirmed running-distro list, probed at the current test clock. */ +async function listRunning(distros: string[]): Promise { + await resolveRunningWslDistros(async () => distros) +} + +beforeEach(() => { + now = 1_000 + vi.spyOn(performance, 'now').mockImplementation(() => now) +}) + +afterEach(() => { + // The real cache keeps its listeners across tests. + unfollows.splice(0).forEach((unfollow) => unfollow()) + resetWslAutomountRootsForTests() + _resetWslBackgroundDistroGateForTests() + _resetRunningWslDistroCacheForTests() + vi.restoreAllMocks() +}) + +describe('parseWslAutomountRoot', () => { + it.each([ + ['/mnt/c/', '/mnt/'], + ['/c/', '/'], + ['/windows/c', '/windows/'], + ['not a path', null], + [null, null] + ])('%s → %s', (value, expected) => { + expect(parseWslAutomountRoot(value)).toBe(expected) + }) +}) + +describe('followWslAutomountRoots', () => { + it("reads a project distro's root once it runs, and again after it restarts", async () => { + const read = vi.fn(async () => '/') + const changed = vi.fn() + onWslAutomountRootsChanged(changed) + follow(read) + await listRunning(['Ubuntu', 'Debian']) + await vi.waitFor(() => expect(knownWslAutomountRoots()).toEqual(['/mnt/', '/'])) + // Only the project distros, and only once while it runs. + await listRunning(['Ubuntu']) + expect(read).toHaveBeenCalledExactlyOnceWith('Ubuntu') + expect(changed).toHaveBeenCalledOnce() + await listRunning([]) + expect(knownWslAutomountRoots()).toEqual(['/mnt/']) + await listRunning(['Ubuntu']) + await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(2)) + }) + + it('never reads from a list probed while the distro was still stopping, only from a settled one', async () => { + const read = vi.fn(async () => '/') + follow(read) + noteWslDistroMayHaveStopped('Ubuntu') + // wsl.exe still lists a terminating distro; reading its root now would boot it again. + now += WSL_STOP_SETTLE_MS - 1 + await listRunning(['Ubuntu']) + expect(read).not.toHaveBeenCalled() + + now += 1 + await listRunning(['Ubuntu']) + expect(read).toHaveBeenCalledExactlyOnceWith('Ubuntu') + }) + + it('keeps the default root when a distro cannot be read', async () => { + const read = vi.fn(async () => null) + follow(read) + await listRunning(['Ubuntu']) + await vi.waitFor(() => expect(read).toHaveBeenCalled()) + expect(knownWslAutomountRoots()).toEqual(['/mnt/']) + }) +}) diff --git a/src/main/wsl/wsl-automount-root.ts b/src/main/wsl/wsl-automount-root.ts new file mode 100644 index 00000000000..ab42df886a2 --- /dev/null +++ b/src/main/wsl/wsl-automount-root.ts @@ -0,0 +1,137 @@ +/** + * Where each WSL project's distro mounts Windows drives (`[automount] root`, `/mnt/` by default), + * so a worktree link WSL Git wrote as `c/src/app/.git/worktrees/x` maps back to `C:\…`. + * Read only from a distro confirmed running after any stop settled, never waking a stopped one, + * and read again after it stops, since `wsl.conf` changes take effect on the next start. + */ +import { runProcess } from '@orca/process-host' +import { buildPosixStdoutFence } from '../../shared/posix-stdout-fence' +import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' +import { wasWslDistroLastSeenRunningSettled } from '../wsl-background-distro-gate' +import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' +import { + onConfirmedRunningWslDistros, + onRunningWslDistrosStopped +} from '../wsl-running-distro-cache' +import { resolveWslExecutablePath } from './wsl-executable-path' + +export const DEFAULT_WSL_AUTOMOUNT_ROOT = '/mnt/' + +const READ_TIMEOUT_MS = 5_000 + +export type WslAutomountRootReader = (distro: string) => Promise + +const roots = new Map() +const pending = new Set() +const changeListeners = new Set<() => void>() + +function distroKey(distro: string): string { + return distro.trim().toLowerCase() +} + +/** 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()])] +} + +export function onWslAutomountRootsChanged(listener: () => void): () => void { + changeListeners.add(listener) + return () => changeListeners.delete(listener) +} + +function changed(): void { + for (const listener of changeListeners) { + listener() + } +} + +/** + * 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) || roots.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) + void read(distro) + .then((root) => { + if (root && root !== DEFAULT_WSL_AUTOMOUNT_ROOT) { + roots.set(key, root) + changed() + } + }) + // Unreadable: links under it fall back to `/mnt/`. + .catch(() => {}) + .finally(() => pending.delete(key)) + } + }) + const stopStopped = onRunningWslDistrosStopped((stopped) => { + let removed = false + for (const distro of stopped) { + removed = roots.delete(distroKey(distro)) || removed + } + if (removed) { + changed() + } + }) + return () => { + stopRunning() + stopStopped() + } +} + +/** The effective root from the distro itself: `wslpath` applies `wsl.conf` as WSL does. */ +export async function readWslAutomountRoot(distro: string): Promise { + const fence = buildPosixStdoutFence("wslpath -u 'C:\\'", 'WSL') + 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, + maxOutputBytes: 4_096 + }) + if (result.timedOut || result.code !== 0) { + return null + } + return parseWslAutomountRoot(fence.readStdout(result.stdout)) +} + +/** A WSL path under one of `roots` as its Windows drive path (`/mnt/c/x` → `C:\\x`); null otherwise. */ +export function wslDrivePathUnderRoots(path: string, roots: readonly string[]): string | null { + for (const root of roots) { + const prefix = root.endsWith('/') ? root : `${root}/` + const drive = path.startsWith(prefix) + ? /^([a-z])(\/.*)?$/i.exec(path.slice(prefix.length)) + : null + if (drive) { + return `${drive[1].toUpperCase()}:${(drive[2] ?? '/').replace(/\//g, '\\')}` + } + } + return null +} + +/** `/mnt/c/` → `/mnt/`; `/c/` → `/`; null for anything else. */ +export function parseWslAutomountRoot(drivePath: string | null): string | null { + const match = drivePath?.trim().match(/^(\/(?:[^\n]*\/)?)[a-zA-Z]\/?$/) + return match ? match[1] : null +} + +export function resetWslAutomountRootsForTests(): void { + roots.clear() + pending.clear() + changeListeners.clear() +}