Files
orca/src/main/git/runner-wsl-linked-gitdir-timeout.test.ts
T
6efd4061dc fix(git): run WSL git reads without a shell (#15257)
* fix(git): run WSL git reads without a shell

WSL-routed git ran through the distro user's interactive login shell for
one reason: to inherit their PATH. That shell also runs the distro's
rc/motd and writes it to the stdout callers parse, which is why #10917
reports a shell banner breaking GitHub source detection.

A shell-free route already existed (`--exec /usr/bin/env PATH=... git`,
added for status reads in #13207) but it was opt-in, and only
gitStatusReadOptionsForWorktree opted in. Every other read -- remote
get-url, config --get, log, show, rev-parse -- took the login shell on
every call, so the reported parse never benefited.

Classify reads at the resolver instead of at each caller. A read needs
nothing the login shell provides, so it takes the direct route without
the caller asking. Writes and network operations stay on the login
shell: they can depend on credential helpers and ssh-agent that the
user's profile sets up.

Subcommands that both read and write (config, remote, branch,
submodule) require an explicit read flag before they qualify, so
`config --get` goes direct while `config user.email x` does not.

`git show` blob reads stop forcing the login shell and go direct too.
The fence stays on that path: the login shell is still the fallback when
the environment probe is cold or rejected, and a banner there would
become file content.

Behavior note: the first WSL read per distro now also warms the
environment probe in the background, so a cold read spawns one extra
wsl.exe. Subsequent reads start no shell at all.

* fix(git): keep queried WSL remote reads on login shell

* style: format WSL runner test

* fix(git): match WSL read markers positionally

The conditional read markers were matched anywhere after the subcommand,
so a positional argument that happened to share a marker's name routed a
write shell-free: `worktree remove list` read as a listing, and
`submodule foreach status` as a status query -- the latter can run
arbitrary commands, including network ones.

Split the two kinds of marker apart. `config`/`branch` are flag-marked
and still match anywhere; `remote`/`worktree`/`submodule` are
action-marked and must match the first non-flag argument. The queried
`remote show` rule and the `symbolic-ref` arity rule are unchanged.

Neither case is reachable today -- Orca issues no `submodule foreach`,
and the worktree paths are its own CLI, not git argv -- but the loose
match was the shape of the defect, not those two instances.

* test(git): pin read routing behind global options

Writes hidden behind -c/-C/--git-dir must not reach the shell-free
route, and an unparsed global form must fall back to the login shell
rather than guess at the subcommand. Both directions fail safe.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-08-18 02:54:19 -07:00

104 lines
3.3 KiB
TypeScript

import { EventEmitter } from 'node:events'
import type * as FsPromises from 'node:fs/promises'
import { afterEach, describe, expect, it, vi } from 'vitest'
const { execFileMock, statMock, readFileMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
statMock: vi.fn(),
readFileMock: vi.fn()
}))
vi.mock('node:child_process', () => ({
execFile: execFileMock,
execFileSync: vi.fn(),
spawn: vi.fn()
}))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof FsPromises>()),
stat: statMock,
readFile: readFileMock
}))
import { gitExecFileAsync } from './runner'
import {
resetWslLinkedWorktreeGitRoutingForTests,
WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS
} from './wsl-linked-worktree-git-routing'
function createMockChild(): EventEmitter & { pid: number; kill: ReturnType<typeof vi.fn> } {
const child = new EventEmitter() as EventEmitter & {
pid: number
kill: ReturnType<typeof vi.fn>
}
child.pid = 1234
child.kill = vi.fn()
return child
}
async function withWindowsPlatform(run: () => Promise<void>): Promise<void> {
const original = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
await run()
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: original })
}
}
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
resetWslLinkedWorktreeGitRoutingForTests()
})
describe('WSL linked-worktree routing probe timeout', () => {
it('retries discovery on the next production Git call', async () => {
vi.useFakeTimers()
await withWindowsPlatform(async () => {
statMock
.mockImplementationOnce(() => new Promise(() => {}))
.mockResolvedValue({ isDirectory: () => false, isFile: () => true })
readFileMock.mockResolvedValue('gitdir: C:/main/.git/worktrees/linked\n')
execFileMock.mockImplementation((command, _args, _options, callback) => {
const child = createMockChild()
queueMicrotask(() => {
if (command === 'wsl.exe') {
callback?.(
Object.assign(new Error('not a git repository'), { code: 128 }),
'',
'fatal: not a git repository'
)
} else {
callback?.(null, 'host git recovered\n', '')
}
})
return child
})
const first = gitExecFileAsync(['status', '--short'], {
cwd: String.raw`C:\repo`,
wslDistro: 'Ubuntu'
})
const firstFailure = expect(first).rejects.toThrow('not a git repository')
await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS)
await firstFailure
await expect(
gitExecFileAsync(['status', '--short'], {
cwd: String.raw`C:\repo`,
wslDistro: 'Ubuntu'
})
).resolves.toEqual({ stdout: 'host git recovered\n', stderr: '' })
expect(statMock).toHaveBeenCalledTimes(2)
// A WSL read also warms the direct-git environment probe in the background;
// it is not part of the routing sequence under test.
const routedCommands = execFileMock.mock.calls
.filter(([, args]) => !String((args as string[])?.at(-1)).includes('^GIT_'))
.map(([command]) => command)
expect(routedCommands).toEqual(['wsl.exe', 'git'])
})
})
})