Fix WSL tool detection on Windows (#2218)

This commit is contained in:
Jinwoo Hong
2026-05-18 01:24:45 -04:00
committed by GitHub
parent 4b7c9b7213
commit e59afeacc4
8 changed files with 390 additions and 58 deletions
+60 -2
View File
@@ -1,9 +1,12 @@
/* eslint-disable max-lines -- Why: WSL fallback, retry safety, and glab parity share mocks. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as WslModule from '../wsl'
const { execFileMock, execFileSyncMock, spawnMock } = vi.hoisted(() => ({
const { execFileMock, execFileSyncMock, spawnMock, getDefaultWslDistroMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
execFileSyncMock: vi.fn(),
spawnMock: vi.fn()
spawnMock: vi.fn(),
getDefaultWslDistroMock: vi.fn()
}))
vi.mock('child_process', () => ({
@@ -12,6 +15,11 @@ vi.mock('child_process', () => ({
spawn: spawnMock
}))
vi.mock('../wsl', async (importOriginal) => ({
...(await importOriginal<typeof WslModule>()),
getDefaultWslDistro: getDefaultWslDistroMock
}))
import { ghExecFileAsync, glabExecFileAsync } from './runner'
describe('ghExecFileAsync WSL fallback', () => {
@@ -19,6 +27,8 @@ describe('ghExecFileAsync WSL fallback', () => {
beforeEach(() => {
execFileMock.mockReset()
getDefaultWslDistroMock.mockReset()
getDefaultWslDistroMock.mockReturnValue(null)
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
@@ -257,6 +267,30 @@ describe('ghExecFileAsync WSL fallback', () => {
expect(execFileMock).toHaveBeenCalledTimes(1)
})
it('retries cwd-less gh calls through the default WSL distro when host gh is missing', async () => {
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
execFileMock
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }))
})
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(null, { stdout: '{"resources":{}}', stderr: '' })
})
await expect(ghExecFileAsync(['api', 'rate_limit'])).resolves.toEqual({
stdout: '{"resources":{}}',
stderr: ''
})
expect(execFileMock).toHaveBeenNthCalledWith(
2,
'wsl.exe',
['-d', 'Ubuntu', '--', 'bash', '-c', "gh 'api' 'rate_limit'"],
expect.objectContaining({ cwd: undefined }),
expect.any(Function)
)
})
it('does not retry non-idempotent glab transient failures', async () => {
execFileMock.mockImplementation((_binary, _args, _options, callback) => {
callback(
@@ -295,6 +329,30 @@ describe('ghExecFileAsync WSL fallback', () => {
expect(execFileMock).toHaveBeenCalledTimes(1)
})
it('retries cwd-less glab calls through the default WSL distro when host glab is missing', async () => {
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
execFileMock
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' }))
})
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(null, { stdout: '[]', stderr: '' })
})
await expect(glabExecFileAsync(['api', 'projects'])).resolves.toEqual({
stdout: '[]',
stderr: ''
})
expect(execFileMock).toHaveBeenNthCalledWith(
2,
'wsl.exe',
['-d', 'Ubuntu', '--', 'bash', '-c', "glab 'api' 'projects'"],
expect.objectContaining({ cwd: undefined }),
expect.any(Function)
)
})
it('still retries idempotent glab transient failures', async () => {
execFileMock
.mockImplementationOnce((_binary, _args, _options, callback) => {
+59 -2
View File
@@ -11,7 +11,7 @@ consistent across every repo-scoped subprocess call. */
*/
import { execFile, execFileSync, spawn, type ChildProcess, type SpawnOptions } from 'child_process'
import { promisify } from 'util'
import { parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl'
import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl'
const execFileAsync = promisify(execFile)
@@ -121,6 +121,26 @@ function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand {
}
}
function resolveDefaultWslCli(command: 'gh' | 'glab', args: string[]): ResolvedCommand | null {
const distro = getDefaultWslDistro()
return distro ? resolveCommand(command, args, undefined, distro) : null
}
function isHostCommandMissing(err: unknown, command: 'gh' | 'glab'): boolean {
if (!err || typeof err !== 'object') {
return false
}
const e = err as { code?: unknown; message?: unknown; syscall?: unknown; path?: unknown }
if (e.code === 'ENOENT') {
return true
}
const message = typeof e.message === 'string' ? e.message.toLowerCase() : ''
return (
message.includes('enoent') &&
(message.includes(command) || e.path === command || e.syscall === 'spawn')
)
}
/**
* Given a command, its arguments, and a working directory, resolve whether
* the invocation should be routed through wsl.exe.
@@ -502,6 +522,7 @@ export async function ghExecFileAsync(
let resolved = resolveCommand('gh', args, options.cwd, options.wslDistro)
let lastError: unknown
let attemptedHostFallback = false
let attemptedDefaultWslFallback = false
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
try {
const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, {
@@ -515,6 +536,24 @@ export async function ghExecFileAsync(
} catch (err) {
lastError = err
const { stderr } = extractExecError(err)
if (
process.platform === 'win32' &&
!attemptedDefaultWslFallback &&
resolved.wsl === null &&
!options.cwd &&
!options.wslDistro &&
isHostCommandMissing(err, 'gh')
) {
const wslResolved = resolveDefaultWslCli('gh', args)
if (wslResolved) {
// Why: WSL-only Windows installs have no gh.exe on the host PATH, but
// global calls like rate_limit/auth do not carry a repo cwd to route by.
resolved = wslResolved
attemptedDefaultWslFallback = true
attempt = -1
continue
}
}
if (!attemptedHostFallback && canFallBackToHostGitHubCli('gh', args, resolved, stderr)) {
resolved = resolveHostGitHubCli('gh', args)
attemptedHostFallback = true
@@ -578,8 +617,9 @@ export async function glabExecFileAsync(
args: string[],
options: GlabExecOptions = {}
): Promise<{ stdout: string; stderr: string }> {
const resolved = resolveCommand('glab', args, options.cwd, options.wslDistro)
let resolved = resolveCommand('glab', args, options.cwd, options.wslDistro)
let lastError: unknown
let attemptedDefaultWslFallback = false
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
try {
const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, {
@@ -593,6 +633,23 @@ export async function glabExecFileAsync(
} catch (err) {
lastError = err
const { stderr } = extractExecError(err)
if (
process.platform === 'win32' &&
!attemptedDefaultWslFallback &&
resolved.wsl === null &&
!options.cwd &&
!options.wslDistro &&
isHostCommandMissing(err, 'glab')
) {
const wslResolved = resolveDefaultWslCli('glab', args)
if (wslResolved) {
// Why: mirror gh's WSL-only fallback for global GitLab project/auth calls.
resolved = wslResolved
attemptedDefaultWslFallback = true
attempt = -1
continue
}
}
const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length
// Why: mirror gh's write-safety gate. A transient error after GitLab
// applies a POST/PATCH/PUT/DELETE must not create duplicate comments,