mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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 <noreply@anthropic.com>
(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 <noreply@anthropic.com>
* perf(wsl): skip the span-only Git resolution on every spawn while the Git router is off
Co-Authored-By: Claude <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof ChildProcess>()),
|
||||
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
|
||||
}
|
||||
]
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<void> | 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<void> | 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<typeof gitExecFileAsyncBufferInSpan>[1]
|
||||
callerOptions: Parameters<typeof gitExecFileAsyncBufferInSpan>[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))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:<distro>`. */
|
||||
export function gitRunsIn(resolved: Pick<ResolvedCommand, 'wsl'>): string {
|
||||
return resolved.wsl ? `wsl:${resolved.wsl.distro}` : 'host'
|
||||
}
|
||||
@@ -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<void> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<ChildProcess> {
|
||||
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<ChildProcess> {
|
||||
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<ReturnType<typeof acquireGitAdmission>> | 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 } : {}),
|
||||
|
||||
@@ -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<GitStreamResult> {
|
||||
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<GitStreamResult> {
|
||||
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<void> | null } = { current: null }
|
||||
const stream = (command: ResolvedCommand): Promise<GitStreamResult> =>
|
||||
new Promise<GitStreamResult>((resolve, reject) => {
|
||||
|
||||
@@ -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<string>()
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<string>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<boolean>) | 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<boolean>) | 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<T extends RoutableGitOptions>(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<string, number>()
|
||||
|
||||
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<void> | 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<RoutableGitOptions, 'wslDistro'>,
|
||||
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<T>(
|
||||
options: RoutableGitOptions & { admissionTier?: GitAdmissionTier },
|
||||
run: () => Promise<T>
|
||||
): Promise<T> {
|
||||
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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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: '' })
|
||||
|
||||
@@ -15,6 +15,8 @@ export async function runGitRepoCommands<T>(commands: GitRepoCommands<T>): 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<T>(commands: GitRepoCommands<T>): 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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* `git rev-parse --git-path <relative>` 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<string> = 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
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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, unknown>([[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<string, string | null>()
|
||||
const realpaths = new Map<string, string>()
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -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<Pick<GlobalSettings, 'localWindowsRuntimeDefault'>>
|
||||
getAllWorktreeMeta: () => Record<string, unknown>
|
||||
}
|
||||
|
||||
/** 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<string, string | undefined>()
|
||||
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, `<automount root><drive>/…`, 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<string> = 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)
|
||||
}
|
||||
@@ -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' }))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -223,10 +223,13 @@ export class RuntimeRepositoryRegistrationController {
|
||||
): Promise<RepoCreateFailure | null> {
|
||||
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) {
|
||||
|
||||
@@ -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<typeof GitRunner>('./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`)
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> | 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)
|
||||
|
||||
@@ -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<string | null>): void {
|
||||
unfollows.push(followWslAutomountRoots(() => ['Ubuntu'], read))
|
||||
}
|
||||
|
||||
/** One confirmed running-distro list, probed at the current test clock. */
|
||||
async function listRunning(distros: string[]): Promise<void> {
|
||||
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/'])
|
||||
})
|
||||
})
|
||||
@@ -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 `<root>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<string | null>
|
||||
|
||||
const roots = new Map<string, string>()
|
||||
const pending = new Set<string>()
|
||||
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<string | null> {
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user