diff --git a/src/main/git/gitService.ts b/src/main/git/gitService.ts index 51f4027..3d16105 100644 --- a/src/main/git/gitService.ts +++ b/src/main/git/gitService.ts @@ -1,10 +1,32 @@ -import childProcess, { type ExecFileException } from 'node:child_process'; +import * as childProcessModule from 'node:child_process'; import { promisify } from 'node:util'; import type { ProjectGitChangeSummary, ProjectGitCommitSummary, ProjectGitContext } from '@shared/domain/project'; import { nowIso } from '@shared/utils/ids'; -const execFileAsync = promisify(childProcess.execFile); +type ExecFileException = import('node:child_process').ExecFileException; + +type ChildProcessModuleLike = { + readonly execFile?: typeof childProcessModule.execFile; + readonly default?: { + readonly execFile?: typeof childProcessModule.execFile; + }; +}; + +export function resolveExecFileForInterop( + module: ChildProcessModuleLike, +): typeof childProcessModule.execFile { + const execFile = module.execFile ?? module.default?.execFile; + if (typeof execFile !== 'function') { + throw new Error('node:child_process execFile is unavailable.'); + } + + return execFile; +} + +const execFileAsync = promisify( + resolveExecFileForInterop(childProcessModule as ChildProcessModuleLike), +); const GIT_TIMEOUT_MS = 5_000; type GitCommandRunner = (projectPath: string, args: string[]) => Promise; diff --git a/tests/main/gitService.test.ts b/tests/main/gitService.test.ts index 320cedf..b27dae0 100644 --- a/tests/main/gitService.test.ts +++ b/tests/main/gitService.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { GitService } from '@main/git/gitService'; +import { GitService, resolveExecFileForInterop } from '@main/git/gitService'; function createGitError(message: string, options?: { code?: number | string; stderr?: string }) { return Object.assign(new Error(message), options); @@ -24,6 +24,24 @@ function createService(responses: Record) { } describe('GitService', () => { + test('resolves execFile from the named child_process export when available', () => { + const namedExecFile = (() => undefined) as unknown as typeof import('node:child_process').execFile; + + expect(resolveExecFileForInterop({ execFile: namedExecFile })).toBe(namedExecFile); + }); + + test('falls back to default.execFile for Bun child_process interop', () => { + const defaultExecFile = (() => undefined) as unknown as typeof import('node:child_process').execFile; + + expect(resolveExecFileForInterop({ default: { execFile: defaultExecFile } })).toBe(defaultExecFile); + }); + + test('throws when execFile is unavailable in both child_process export shapes', () => { + expect(() => resolveExecFileForInterop({})).toThrow( + 'node:child_process execFile is unavailable.', + ); + }); + test('describes a git repository with branch, dirty state, change counts, and head commit summary', async () => { const service = createService({ 'rev-parse --show-toplevel': 'C:\\workspace\\repo\n',