fix(git): distinguish binary absence from missing cwd on spawn ENOENT

Node reports ENOENT for both a missing git binary and a missing working directory
during spawn. The fix checks specifically for spawn syscall, then verifies the cwd
exists to disambiguate. This prevents reporting "no Git" when the error is actually
a missing working directory. Centralizes probe logic in a reusable function; other
failures cause rejection so callers preserve the unknown status instead of collapsing
to false.
This commit is contained in:
Jinjing
2026-09-15 00:06:35 -07:00
parent 4ced72197d
commit b210e4666d
6 changed files with 128 additions and 23 deletions
+10 -2
View File
@@ -40,9 +40,17 @@ export function extractExecError(err: unknown): { stderr: string; stdout: string
return { stderr: String(err), stdout: '' }
}
/** True only when the OS could not find the command binary to spawn. */
/** Recognizes spawn ENOENT; callers must separately rule out a missing cwd. */
export function isMissingCommandBinaryError(err: unknown): boolean {
return Boolean(err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT')
return Boolean(
err &&
typeof err === 'object' &&
'code' in err &&
err.code === 'ENOENT' &&
'syscall' in err &&
typeof err.syscall === 'string' &&
err.syscall.startsWith('spawn ')
)
}
/**
+31
View File
@@ -0,0 +1,31 @@
import { access } from 'node:fs/promises'
import { isMissingCommandBinaryError } from './exec-error'
type GitVersionExec = (
args: string[],
options: { cwd: string; timeout: number }
) => Promise<unknown>
/**
* Resolves `false` only when the spawn proved Git absent; every other failure rejects so callers
* keep an unknown answer instead of reporting a host with no Git.
*/
export async function probeGitAvailability(
exec: GitVersionExec,
options: { cwd: string; timeout: number }
): Promise<boolean> {
try {
await exec(['--version'], options)
return true
} catch (err) {
if (isMissingCommandBinaryError(err)) {
try {
await access(options.cwd)
return false
} catch {
// Node reports the same spawn ENOENT for a missing binary and a missing cwd.
}
}
throw err
}
}
@@ -2,7 +2,7 @@
* `repos:isGitAvailable` gates the create dialog's Git option. Only a spawn that never started may
* answer `false`; everything else rejects so the renderer's existing `unknown` branch is reachable.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() }))
@@ -30,6 +30,10 @@ describe('repos:isGitAvailable', () => {
gitExecFileAsyncMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('answers true when git reports its version', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git version 2.25.1\n', stderr: '' })
await expect(probeLocalGitAvailability()).resolves.toBe(true)
@@ -41,11 +45,29 @@ describe('repos:isGitAvailable', () => {
it('answers false only when the spawn itself found no binary', async () => {
gitExecFileAsyncMock.mockRejectedValue(
Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' })
Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT', syscall: 'spawn git' })
)
await expect(probeLocalGitAvailability()).resolves.toBe(false)
})
it('rejects an ENOENT when the working directory disappeared', async () => {
const missingCwd = `${process.cwd()}-missing`
vi.spyOn(process, 'cwd').mockReturnValue(missingCwd)
gitExecFileAsyncMock.mockRejectedValue(
Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT', syscall: 'spawn git' })
)
await expect(probeLocalGitAvailability()).rejects.toThrow('spawn git ENOENT')
})
it('rejects a non-spawn ENOENT rather than reporting no Git', async () => {
gitExecFileAsyncMock.mockRejectedValue(
Object.assign(new Error('open config ENOENT'), { code: 'ENOENT', syscall: 'open' })
)
await expect(probeLocalGitAvailability()).rejects.toThrow('open config ENOENT')
})
it('rejects on the timeout rather than reporting no git', async () => {
gitExecFileAsyncMock.mockRejectedValue(new Error('git --version timed out after 1500ms'))
await expect(probeLocalGitAvailability()).rejects.toThrow('timed out')
+5 -13
View File
@@ -10,7 +10,7 @@ import { DEFAULT_REPO_BADGE_COLOR, getDefaultWorkspaceDir } from '../../../share
import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { getEffectiveHostSetting } from '../../../shared/host-setting-overrides'
import { isMissingCommandBinaryError } from '../../git/exec-error'
import { probeGitAvailability } from '../../git/git-availability'
import { gitExecFileAsync } from '../../git/runner'
import { detectRepoIconAndUpstream } from '../../repo-icon-autodetect'
import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation'
@@ -25,18 +25,10 @@ const GIT_AVAILABILITY_TIMEOUT_MS = 1500
// Only ENOENT proves Git absent; rejecting other failures preserves the renderer's unknown state.
export async function probeLocalGitAvailability(): Promise<boolean> {
try {
await gitExecFileAsync(['--version'], {
cwd: process.cwd(),
timeout: GIT_AVAILABILITY_TIMEOUT_MS
})
return true
} catch (err) {
if (isMissingCommandBinaryError(err)) {
return false
}
throw err
}
return probeGitAvailability(gitExecFileAsync, {
cwd: process.cwd(),
timeout: GIT_AVAILABILITY_TIMEOUT_MS
})
}
/**
@@ -3,6 +3,7 @@ import { homedir } from 'node:os'
import { isAbsolute, resolve } from 'node:path'
import type { DirEntry, FilesystemPathFlavor } from '../../shared/filesystem-entry-types'
import { sortDirEntries } from '../../shared/file-name-sort'
import { probeGitAvailability } from '../git/git-availability'
import { gitExecFileAsync } from '../git/runner'
import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing'
@@ -54,11 +55,6 @@ export class RuntimeServerEnvironmentCommands {
}
async isGitAvailable(): Promise<boolean> {
try {
await gitExecFileAsync(['--version'], { cwd: process.cwd(), timeout: 3000 })
return true
} catch {
return false
}
return probeGitAvailability(gitExecFileAsync, { cwd: process.cwd(), timeout: 3000 })
}
}
@@ -0,0 +1,56 @@
/**
* `repo.gitAvailable` gates the create dialog's Git option on a runtime/remote host. Only a spawn
* that never started may answer `false`; everything else rejects so the renderer's existing
* `unknown` branch stays reachable instead of collapsing to a false "no Git here".
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() }))
vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
import { RuntimeServerEnvironmentCommands } from './runtime-server-environment-commands'
function spawnEnoent(): Error {
return Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT', syscall: 'spawn git' })
}
describe('RuntimeServerEnvironmentCommands.isGitAvailable', () => {
const commands = new RuntimeServerEnvironmentCommands()
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('answers true when git reports its version', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git version 2.25.1\n', stderr: '' })
await expect(commands.isGitAvailable()).resolves.toBe(true)
})
it('answers false only when the spawn itself found no binary', async () => {
gitExecFileAsyncMock.mockRejectedValue(spawnEnoent())
await expect(commands.isGitAvailable()).resolves.toBe(false)
})
it('rejects an ENOENT when the working directory disappeared', async () => {
vi.spyOn(process, 'cwd').mockReturnValue(`${process.cwd()}-missing`)
gitExecFileAsyncMock.mockRejectedValue(spawnEnoent())
await expect(commands.isGitAvailable()).rejects.toThrow('spawn git ENOENT')
})
it('rejects a slow host rather than reporting no Git', async () => {
gitExecFileAsyncMock.mockRejectedValue(new Error('git --version timed out after 3000ms'))
await expect(commands.isGitAvailable()).rejects.toThrow('timed out')
})
it('rejects a repository-level git failure rather than reporting no Git', async () => {
gitExecFileAsyncMock.mockRejectedValue(
Object.assign(new Error('detected dubious ownership'), { code: 128 })
)
await expect(commands.isGitAvailable()).rejects.toThrow('dubious ownership')
})
})