diff --git a/src/main/git/exec-error.ts b/src/main/git/exec-error.ts index 6312f60479e..6e0385091d5 100644 --- a/src/main/git/exec-error.ts +++ b/src/main/git/exec-error.ts @@ -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 ') + ) } /** diff --git a/src/main/git/git-availability.ts b/src/main/git/git-availability.ts new file mode 100644 index 00000000000..0fda3933f7a --- /dev/null +++ b/src/main/git/git-availability.ts @@ -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 + +/** + * 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 { + 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 + } +} diff --git a/src/main/ipc/repos/repo-creation-git-availability.test.ts b/src/main/ipc/repos/repo-creation-git-availability.test.ts index 0d2a37ca32f..8ffbbdc033e 100644 --- a/src/main/ipc/repos/repo-creation-git-availability.test.ts +++ b/src/main/ipc/repos/repo-creation-git-availability.test.ts @@ -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') diff --git a/src/main/ipc/repos/repo-creation-handlers.ts b/src/main/ipc/repos/repo-creation-handlers.ts index 08d3c201093..57bfcf66c79 100644 --- a/src/main/ipc/repos/repo-creation-handlers.ts +++ b/src/main/ipc/repos/repo-creation-handlers.ts @@ -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 { - 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 + }) } /** diff --git a/src/main/runtime/runtime-server-environment-commands.ts b/src/main/runtime/runtime-server-environment-commands.ts index 54f08d29f0c..7671481b1e5 100644 --- a/src/main/runtime/runtime-server-environment-commands.ts +++ b/src/main/runtime/runtime-server-environment-commands.ts @@ -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 { - try { - await gitExecFileAsync(['--version'], { cwd: process.cwd(), timeout: 3000 }) - return true - } catch { - return false - } + return probeGitAvailability(gitExecFileAsync, { cwd: process.cwd(), timeout: 3000 }) } } diff --git a/src/main/runtime/runtime-server-git-availability.test.ts b/src/main/runtime/runtime-server-git-availability.test.ts new file mode 100644 index 00000000000..2259e1a563a --- /dev/null +++ b/src/main/runtime/runtime-server-git-availability.test.ts @@ -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') + }) +})