From 32e804dd6f9fb4824449a840180bda349b62b037 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:52:18 -0700 Subject: [PATCH] fix(wsl): every local Git call follows its project's runtime unless it says why not (D1c-6) (#33531) * fix(wsl): every local Git call follows its project's runtime unless it says why not (D1c-6) * fix(wsl): rebuild Git routes on the store's own writes, never on a timer, and route a new worktree by its link * fix(wsl): route WSL-made worktree links, aliased checkouts and sync Git; host Git only where marked; no wsl.exe storms * fix(wsl): map WSL-made worktree links through each project distro's automount root * fix(wsl): arm the missing-distro cache only with the Git router on, and feed it spawn failures * fix(wsl): keep synchronous Git host-only, read the setup runner's path from the link, and keep the sync launch under the ratchet * chore(wsl): move the sync Git runner's process-host import allowance with it * fix(wsl): read only per-worktree git paths from the link, and only from a git dir that still exists * fix(wsl): name which Git ran on stream and spawn spans too, so status shows its route * test(wsl): setup runner lookups declare host Git; cover the link read with the router on and off The hooks tests expected the setup runner's `rev-parse --git-path` call to carry `wslDistro: 'Ubuntu'` for a WSL-runtime project. On main that field never reached Git: main's gitExecFileSync options have no wslDistro and it calls resolveCommand('git', args, cwd) with no distro override, so the lookup ran host Git (WSL only when the cwd itself is a WSL path). The tests pinned call arguments, not what ran. The sync runner now states that with `hostGit: true` and resolves exactly as main did, so the tests assert that. New worktree-runner-script-link tests cover the router-gated link read: router on reads the link; router on with no usable link falls back to host Git and converts its Linux path; router off makes main's host Git call and never reads the link. * test(git): read src/main once in the host-Git routing guard, so it holds under load * test(wsl): pin the setup runner's sync Git spawn to main's exact launch on POSIX The setup runner's path lookup now passes hostGit: true where it passed a wslDistro that gitExecFileSync never read, so the binary, args, cwd and env it spawns are unchanged. Update the call-shape assertions and pin the POSIX execFileSync call itself. (cherry picked from commit 6aae6d4d090e13b75c1d9ad2734f2f717dde7570) * fix(wsl): await WSL Git admission in spawns only when there is one With the Git router off admitWslGit returns null; awaiting it anyway added a microtask to every spawn's admission path that main doesn't have. (cherry picked from commit 883aaeb90bc23e89cafb039708a565ab3d72620e) * fix(wsl): keep main's Git scheduling when the D1c Git router is off The remote-identity sweep moved every user's probes to the background admission tier, which only the router reads; it now keeps its tier unless the router is on. Admission wrapping and the spawn stderr tail also step aside without the router, so those calls run exactly as on main. (cherry picked from commit 151a3091307c03078d0c0b98487bbe3bf467ec27) * test(wsl): point the distro allowlist test at the sync runner's new home, as host Git Co-Authored-By: Claude (cherry picked from commit 255e85fc93deba83addc4370f889c1b721180188) * test(wsl): installing the Git router with the gate closed or off Windows leaves none installed Co-Authored-By: Claude * perf(wsl): skip the span-only Git resolution on every spawn while the Git router is off Co-Authored-By: Claude * fix(wsl): read a distro's automount root only from a running list probed after its stop settled, so it never boots a terminated distro --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Co-authored-by: Claude --- config/main-sync-spawn-baseline.txt | 2 +- .../process-host-direct-import-baseline.txt | 2 +- .../scripts/check-main-sync-spawn-ratchet.mjs | 6 +- .../command-runner/git-admission-span.test.ts | 23 +- .../command-runner/git-exec-file-sync.test.ts | 57 ++++ .../git/command-runner/git-exec-file-sync.ts | 40 +++ src/main/git/command-runner/git-exec-file.ts | 69 ++--- .../git/command-runner/git-exec-options.ts | 2 + src/main/git/command-runner/git-runs-in.ts | 6 + .../git-spawn-wsl-admission.test.ts | 64 ++++ src/main/git/command-runner/git-spawn.ts | 57 +++- .../git/command-runner/git-stream-stdout.ts | 17 +- .../local-git-runtime-route-guard.test.ts | 108 +++++++ .../local-git-runtime-route.test.ts | 102 +++++++ .../command-runner/local-git-runtime-route.ts | 138 +++++++++ .../wsl-command-resolution-allowlist.test.ts | 4 +- src/main/git/repo-detection-command.test.ts | 1 + src/main/git/repo-detection-command.ts | 5 +- src/main/git/runner.ts | 7 +- src/main/git/worktree-git-path.test.ts | 77 +++++ src/main/git/worktree-git-path.ts | 76 +++++ src/main/hooks-setup-runner-script.test.ts | 8 +- src/main/hooks.test.ts | 5 +- .../register-core-handlers.ts | 2 + src/main/ipc/repos-create.test.ts | 7 +- src/main/ipc/repos/repo-clone-lifecycle.ts | 2 + src/main/ipc/repos/repo-creation-handlers.ts | 7 +- src/main/ipc/wsl-drive-project-handlers.ts | 5 +- .../ipc/wsl-drive-unc-conversion-handlers.ts | 5 +- .../project-runtime-git-router-store.test.ts | 90 ++++++ src/main/project-runtime-git-router.test.ts | 183 ++++++++++++ src/main/project-runtime-git-router.ts | 277 ++++++++++++++++++ ...epo-git-remote-identity-enrichment.test.ts | 17 ++ .../repo-git-remote-identity-enrichment.ts | 3 + src/main/repo-git-remote-identity.ts | 8 +- src/main/repo-icon-autodetect.ts | 7 +- .../runtime-repository-clone-controller.ts | 2 + ...time-repository-registration-controller.ts | 7 +- src/main/worktree-runner-script-link.test.ts | 89 ++++++ src/main/worktree-runner-script.ts | 16 +- src/main/wsl/wsl-automount-root.test.ts | 100 +++++++ src/main/wsl/wsl-automount-root.ts | 137 +++++++++ 42 files changed, 1753 insertions(+), 87 deletions(-) create mode 100644 src/main/git/command-runner/git-exec-file-sync.test.ts create mode 100644 src/main/git/command-runner/git-exec-file-sync.ts create mode 100644 src/main/git/command-runner/git-runs-in.ts create mode 100644 src/main/git/command-runner/git-spawn-wsl-admission.test.ts create mode 100644 src/main/git/command-runner/local-git-runtime-route-guard.test.ts create mode 100644 src/main/git/command-runner/local-git-runtime-route.test.ts create mode 100644 src/main/git/command-runner/local-git-runtime-route.ts create mode 100644 src/main/git/worktree-git-path.test.ts create mode 100644 src/main/git/worktree-git-path.ts create mode 100644 src/main/project-runtime-git-router-store.test.ts create mode 100644 src/main/project-runtime-git-router.test.ts create mode 100644 src/main/project-runtime-git-router.ts create mode 100644 src/main/worktree-runner-script-link.test.ts create mode 100644 src/main/wsl/wsl-automount-root.test.ts create mode 100644 src/main/wsl/wsl-automount-root.ts 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() +}