mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 16:02:41 +00:00
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:
@@ -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 ')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user