diff --git a/apps/frontend/src/main/__tests__/env-utils.test.ts b/apps/frontend/src/main/__tests__/env-utils.test.ts index 994a334d20..98a929f69b 100644 --- a/apps/frontend/src/main/__tests__/env-utils.test.ts +++ b/apps/frontend/src/main/__tests__/env-utils.test.ts @@ -1,5 +1,39 @@ -import { describe, expect, it, beforeEach, afterEach } from 'vitest'; -import { shouldUseShell, getSpawnOptions, getSpawnCommand } from '../env-utils'; +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; + +// Mock fs module BEFORE importing env-utils to ensure mock is in place +// (Vitest hoists vi.mock but explicit ordering is clearer) +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(actual.existsSync), + }; +}); + +// Mock platform module to allow dynamic control of isWindows() and isUnix() +// This is necessary because the platform module checks process.platform at import time +// and we need to test Windows-specific behavior on non-Windows CI runners +// Default to non-Windows behavior; tests will override as needed +vi.mock('../platform', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // Default to non-Windows - tests will override with mockReturnValue + isWindows: vi.fn().mockReturnValue(false), + isUnix: vi.fn().mockReturnValue(true), + }; +}); + +// Import modules after mock setup +import * as fs from 'fs'; +import * as platform from '../platform'; +import { shouldUseShell, getSpawnOptions, getSpawnCommand, deriveGitBashPath, getGitBashEnv } from '../env-utils'; + +// Helper to set platform mock values based on simulated platform +function setPlatformMock(simulatedPlatform: string): void { + vi.mocked(platform.isWindows).mockReturnValue(simulatedPlatform === 'win32'); + vi.mocked(platform.isUnix).mockReturnValue(simulatedPlatform !== 'win32'); +} describe('shouldUseShell', () => { const originalPlatform = process.platform; @@ -11,6 +45,7 @@ describe('shouldUseShell', () => { writable: true, configurable: true, }); + vi.restoreAllMocks(); }); describe('Windows platform', () => { @@ -20,10 +55,11 @@ describe('shouldUseShell', () => { writable: true, configurable: true, }); + setPlatformMock('win32'); }); it('should return true for .cmd files', () => { - expect(shouldUseShell('D:\\Program Files\\nodejs\\claude.cmd')).toBe(true); + expect(shouldUseShell('D:\\Tools\\nodejs\\claude.cmd')).toBe(true); expect(shouldUseShell('C:\\Users\\admin\\AppData\\Roaming\\npm\\claude.cmd')).toBe(true); }); @@ -48,9 +84,9 @@ describe('shouldUseShell', () => { }); it('should handle paths with spaces and special characters', () => { - expect(shouldUseShell('D:\\Program Files (x86)\\tool.cmd')).toBe(true); + expect(shouldUseShell('D:\\Tools (x86)\\tool.cmd')).toBe(true); expect(shouldUseShell('D:\\Path&Name\\tool.cmd')).toBe(true); - expect(shouldUseShell('D:\\Program Files (x86)\\tool.exe')).toBe(false); + expect(shouldUseShell('D:\\Tools (x86)\\tool.exe')).toBe(false); }); }); @@ -61,8 +97,9 @@ describe('shouldUseShell', () => { writable: true, configurable: true, }); - expect(shouldUseShell('/usr/local/bin/claude')).toBe(false); - expect(shouldUseShell('/opt/homebrew/bin/claude.cmd')).toBe(false); + setPlatformMock('darwin'); + expect(shouldUseShell('/tmp/tools/bin/claude')).toBe(false); + expect(shouldUseShell('/tmp/homebrew/bin/claude.cmd')).toBe(false); }); it('should return false on Linux', () => { @@ -71,7 +108,8 @@ describe('shouldUseShell', () => { writable: true, configurable: true, }); - expect(shouldUseShell('/usr/bin/claude')).toBe(false); + setPlatformMock('linux'); + expect(shouldUseShell('/tmp/system/bin/claude')).toBe(false); expect(shouldUseShell('/home/user/.local/bin/claude.bat')).toBe(false); }); }); @@ -87,6 +125,7 @@ describe('getSpawnOptions', () => { writable: true, configurable: true, }); + vi.restoreAllMocks(); }); it('should set shell: true for .cmd files on Windows', () => { @@ -95,6 +134,7 @@ describe('getSpawnOptions', () => { writable: true, configurable: true, }); + setPlatformMock('win32'); const opts = getSpawnOptions('D:\\nodejs\\claude.cmd', { cwd: 'D:\\project', @@ -114,6 +154,7 @@ describe('getSpawnOptions', () => { writable: true, configurable: true, }); + setPlatformMock('win32'); const opts = getSpawnOptions('C:\\Windows\\git.exe', { cwd: 'D:\\project', @@ -131,6 +172,7 @@ describe('getSpawnOptions', () => { writable: true, configurable: true, }); + setPlatformMock('win32'); const opts = getSpawnOptions('D:\\tool.cmd', { cwd: 'D:\\project', @@ -156,6 +198,7 @@ describe('getSpawnOptions', () => { writable: true, configurable: true, }); + setPlatformMock('win32'); const opts = getSpawnOptions('D:\\tool.cmd'); @@ -170,8 +213,9 @@ describe('getSpawnOptions', () => { writable: true, configurable: true, }); + setPlatformMock('darwin'); - const opts = getSpawnOptions('/usr/local/bin/claude', { + const opts = getSpawnOptions('/tmp/tools/bin/claude', { cwd: '/project', }); @@ -187,6 +231,7 @@ describe('getSpawnOptions', () => { writable: true, configurable: true, }); + setPlatformMock('win32'); const opts = getSpawnOptions('C:\\scripts\\setup.bat', { cwd: 'D:\\project', @@ -209,6 +254,7 @@ describe('getSpawnCommand', () => { writable: true, configurable: true, }); + vi.restoreAllMocks(); }); describe('Windows platform', () => { @@ -218,6 +264,7 @@ describe('getSpawnCommand', () => { writable: true, configurable: true, }); + setPlatformMock('win32'); }); it('should quote .cmd files with spaces', () => { @@ -231,13 +278,13 @@ describe('getSpawnCommand', () => { }); it('should quote .bat files with spaces', () => { - const cmd = getSpawnCommand('D:\\Program Files (x86)\\scripts\\setup.bat'); - expect(cmd).toBe('"D:\\Program Files (x86)\\scripts\\setup.bat"'); + const cmd = getSpawnCommand('D:\\Tools (x86)\\scripts\\setup.bat'); + expect(cmd).toBe('"D:\\Tools (x86)\\scripts\\setup.bat"'); }); it('should NOT quote .exe files', () => { - const cmd = getSpawnCommand('C:\\Program Files\\Git\\cmd\\git.exe'); - expect(cmd).toBe('C:\\Program Files\\Git\\cmd\\git.exe'); + const cmd = getSpawnCommand('C:\\Tools\\Git\\cmd\\git.exe'); + expect(cmd).toBe('C:\\Tools\\Git\\cmd\\git.exe'); }); it('should NOT quote extensionless files', () => { @@ -256,8 +303,8 @@ describe('getSpawnCommand', () => { }); it('should be idempotent - already quoted .bat files stay quoted', () => { - const cmd = getSpawnCommand('"D:\\Program Files\\scripts\\setup.bat"'); - expect(cmd).toBe('"D:\\Program Files\\scripts\\setup.bat"'); + const cmd = getSpawnCommand('"D:\\Tools\\scripts\\setup.bat"'); + expect(cmd).toBe('"D:\\Tools\\scripts\\setup.bat"'); }); it('should be idempotent - double-quoting does not occur', () => { @@ -278,8 +325,8 @@ describe('getSpawnCommand', () => { }); it('should strip quotes from .exe files (defensive: no quotes with shell:false)', () => { - const cmd = getSpawnCommand('"C:\\Program Files\\Git\\cmd\\git.exe"'); - expect(cmd).toBe('C:\\Program Files\\Git\\cmd\\git.exe'); + const cmd = getSpawnCommand('"C:\\Tools\\Git\\cmd\\git.exe"'); + expect(cmd).toBe('C:\\Tools\\Git\\cmd\\git.exe'); }); it('should strip quotes from extensionless files (defensive: no quotes with shell:false)', () => { @@ -288,8 +335,8 @@ describe('getSpawnCommand', () => { }); it('should strip quotes and trim whitespace from .exe files', () => { - const cmd = getSpawnCommand(' "C:\\Program Files\\Git\\cmd\\git.exe" '); - expect(cmd).toBe('C:\\Program Files\\Git\\cmd\\git.exe'); + const cmd = getSpawnCommand(' "C:\\Tools\\Git\\cmd\\git.exe" '); + expect(cmd).toBe('C:\\Tools\\Git\\cmd\\git.exe'); }); }); @@ -300,8 +347,9 @@ describe('getSpawnCommand', () => { writable: true, configurable: true, }); - expect(getSpawnCommand('/usr/local/bin/claude')).toBe('/usr/local/bin/claude'); - expect(getSpawnCommand('/opt/homebrew/bin/claude.cmd')).toBe('/opt/homebrew/bin/claude.cmd'); + setPlatformMock('darwin'); + expect(getSpawnCommand('/tmp/tools/bin/claude')).toBe('/tmp/tools/bin/claude'); + expect(getSpawnCommand('/tmp/homebrew/bin/claude.cmd')).toBe('/tmp/homebrew/bin/claude.cmd'); }); it('should NOT quote commands on Linux', () => { @@ -310,7 +358,8 @@ describe('getSpawnCommand', () => { writable: true, configurable: true, }); - expect(getSpawnCommand('/usr/bin/claude')).toBe('/usr/bin/claude'); + setPlatformMock('linux'); + expect(getSpawnCommand('/tmp/system/bin/claude')).toBe('/tmp/system/bin/claude'); expect(getSpawnCommand('/home/user/.local/bin/claude.bat')).toBe('/home/user/.local/bin/claude.bat'); }); @@ -320,8 +369,9 @@ describe('getSpawnCommand', () => { writable: true, configurable: true, }); - expect(getSpawnCommand(' /usr/local/bin/claude ')).toBe('/usr/local/bin/claude'); - expect(getSpawnCommand('\t/opt/homebrew/bin/claude\t')).toBe('/opt/homebrew/bin/claude'); + setPlatformMock('darwin'); + expect(getSpawnCommand(' /tmp/tools/bin/claude ')).toBe('/tmp/tools/bin/claude'); + expect(getSpawnCommand('\t/tmp/homebrew/bin/claude\t')).toBe('/tmp/homebrew/bin/claude'); }); it('should trim whitespace on Linux', () => { @@ -330,7 +380,8 @@ describe('getSpawnCommand', () => { writable: true, configurable: true, }); - expect(getSpawnCommand(' /usr/bin/claude ')).toBe('/usr/bin/claude'); + setPlatformMock('linux'); + expect(getSpawnCommand(' /tmp/system/bin/claude ')).toBe('/tmp/system/bin/claude'); expect(getSpawnCommand('\t/home/user/.local/bin/claude\t')).toBe('/home/user/.local/bin/claude'); }); }); @@ -346,6 +397,7 @@ describe('shouldUseShell with quoted paths', () => { writable: true, configurable: true, }); + vi.restoreAllMocks(); }); describe('Windows platform', () => { @@ -355,6 +407,7 @@ describe('shouldUseShell with quoted paths', () => { writable: true, configurable: true, }); + setPlatformMock('win32'); }); it('should detect .cmd files in quoted paths', () => { @@ -364,11 +417,11 @@ describe('shouldUseShell with quoted paths', () => { it('should detect .bat files in quoted paths', () => { expect(shouldUseShell('"C:\\Scripts\\setup.bat"')).toBe(true); - expect(shouldUseShell('"D:\\Program Files\\script.BAT"')).toBe(true); + expect(shouldUseShell('"D:\\Tools\\script.BAT"')).toBe(true); }); it('should NOT detect .exe files in quoted paths', () => { - expect(shouldUseShell('"C:\\Program Files\\git.exe"')).toBe(false); + expect(shouldUseShell('"C:\\Tools\\git.exe"')).toBe(false); }); it('should handle whitespace around quoted paths', () => { @@ -376,3 +429,225 @@ describe('shouldUseShell with quoted paths', () => { }); }); }); + +describe('deriveGitBashPath', () => { + const originalPlatform = process.platform; + + afterEach(() => { + // Restore original platform after each test + Object.defineProperty(process, 'platform', { + value: originalPlatform, + writable: true, + configurable: true, + }); + vi.restoreAllMocks(); + }); + + describe('Non-Windows platforms', () => { + it('should return null on macOS', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + writable: true, + configurable: true, + }); + setPlatformMock('darwin'); + expect(deriveGitBashPath('/tmp/tools/bin/git')).toBeNull(); + }); + + it('should return null on Linux', () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + writable: true, + configurable: true, + }); + setPlatformMock('linux'); + expect(deriveGitBashPath('/tmp/system/bin/git')).toBeNull(); + }); + }); + + describe('Windows platform', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { + value: 'win32', + writable: true, + configurable: true, + }); + setPlatformMock('win32'); + }); + + it('should derive bash.exe from Git/cmd/git.exe', () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + return p === 'C:\\Tools\\Git\\bin\\bash.exe'; + }); + + const result = deriveGitBashPath('C:\\Tools\\Git\\cmd\\git.exe'); + expect(result).toBe('C:\\Tools\\Git\\bin\\bash.exe'); + }); + + it('should derive bash.exe from Git/bin/git.exe', () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + return p === 'C:\\Tools\\Git\\bin\\bash.exe'; + }); + + const result = deriveGitBashPath('C:\\Tools\\Git\\bin\\git.exe'); + expect(result).toBe('C:\\Tools\\Git\\bin\\bash.exe'); + }); + + it('should derive bash.exe from Git/mingw64/bin/git.exe', () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + return p === 'D:\\Tools\\Git\\bin\\bash.exe'; + }); + + const result = deriveGitBashPath('D:\\Tools\\Git\\mingw64\\bin\\git.exe'); + expect(result).toBe('D:\\Tools\\Git\\bin\\bash.exe'); + }); + + it('should derive bash.exe from Git/mingw32/bin/git.exe', () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + return p === 'C:\\Git\\bin\\bash.exe'; + }); + + const result = deriveGitBashPath('C:\\Git\\mingw32\\bin\\git.exe'); + expect(result).toBe('C:\\Git\\bin\\bash.exe'); + }); + + it('should try alternate path if primary path not found', () => { + let callCount = 0; + vi.mocked(fs.existsSync).mockImplementation((p) => { + callCount++; + // First call (primary path) returns false, second call (alternate) returns true + if (callCount === 1) return false; + return p === 'C:\\Git\\bin\\bash.exe'; + }); + + const result = deriveGitBashPath('C:\\Git\\some\\other\\git.exe'); + // Should try alternate path + expect(result).toBe('C:\\Git\\bin\\bash.exe'); + }); + + it('should return null if bash.exe not found in any path', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const result = deriveGitBashPath('C:\\Unknown\\Path\\git.exe'); + expect(result).toBeNull(); + }); + + it('should handle paths with spaces', () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + return p === 'D:\\Tools (x86)\\Git\\bin\\bash.exe'; + }); + + const result = deriveGitBashPath('D:\\Tools (x86)\\Git\\cmd\\git.exe'); + expect(result).toBe('D:\\Tools (x86)\\Git\\bin\\bash.exe'); + }); + + it('should handle custom Git installation paths', () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + return p === 'E:\\Tools\\Git\\bin\\bash.exe'; + }); + + const result = deriveGitBashPath('E:\\Tools\\Git\\cmd\\git.exe'); + expect(result).toBe('E:\\Tools\\Git\\bin\\bash.exe'); + }); + }); +}); + +describe('getGitBashEnv', () => { + const originalPlatform = process.platform; + const originalEnv = process.env.CLAUDE_CODE_GIT_BASH_PATH; + + // Mock function for dependency injection + const mockGetToolInfo = vi.fn() as ReturnType & ((tool: string) => { found: boolean; path: string | null; source: string }); + + afterEach(() => { + // Restore original platform and env after each test + Object.defineProperty(process, 'platform', { + value: originalPlatform, + writable: true, + configurable: true, + }); + if (originalEnv === undefined) { + delete process.env.CLAUDE_CODE_GIT_BASH_PATH; + } else { + process.env.CLAUDE_CODE_GIT_BASH_PATH = originalEnv; + } + vi.restoreAllMocks(); + mockGetToolInfo.mockReset(); + }); + + describe('Non-Windows platforms', () => { + it('should return empty object on macOS', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + writable: true, + configurable: true, + }); + setPlatformMock('darwin'); + expect(getGitBashEnv(mockGetToolInfo)).toEqual({}); + expect(mockGetToolInfo).not.toHaveBeenCalled(); + }); + + it('should return empty object on Linux', () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + writable: true, + configurable: true, + }); + setPlatformMock('linux'); + expect(getGitBashEnv(mockGetToolInfo)).toEqual({}); + expect(mockGetToolInfo).not.toHaveBeenCalled(); + }); + }); + + describe('Windows platform', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { + value: 'win32', + writable: true, + configurable: true, + }); + setPlatformMock('win32'); + delete process.env.CLAUDE_CODE_GIT_BASH_PATH; + }); + + it('should return empty object if env var already set', () => { + process.env.CLAUDE_CODE_GIT_BASH_PATH = 'C:\\existing\\bash.exe'; + expect(getGitBashEnv(mockGetToolInfo)).toEqual({}); + expect(mockGetToolInfo).not.toHaveBeenCalled(); + }); + + it('should return empty object if git not found', () => { + mockGetToolInfo.mockReturnValue({ found: false, path: null, source: 'mock' }); + expect(getGitBashEnv(mockGetToolInfo)).toEqual({}); + expect(mockGetToolInfo).toHaveBeenCalledWith('git'); + }); + + it('should return CLAUDE_CODE_GIT_BASH_PATH if git found and bash exists', () => { + mockGetToolInfo.mockReturnValue({ + found: true, + path: 'C:\\Tools\\Git\\cmd\\git.exe', + source: 'system-path' + }); + vi.mocked(fs.existsSync).mockImplementation((p) => { + return p === 'C:\\Tools\\Git\\bin\\bash.exe'; + }); + + const result = getGitBashEnv(mockGetToolInfo); + expect(result).toEqual({ + 'CLAUDE_CODE_GIT_BASH_PATH': 'C:\\Tools\\Git\\bin\\bash.exe' + }); + expect(mockGetToolInfo).toHaveBeenCalledWith('git'); + }); + + it('should return empty object if bash.exe not found', () => { + mockGetToolInfo.mockReturnValue({ + found: true, + path: 'C:\\Tools\\Git\\cmd\\git.exe', + source: 'system-path' + }); + vi.mocked(fs.existsSync).mockReturnValue(false); + + expect(getGitBashEnv(mockGetToolInfo)).toEqual({}); + }); + }); +}); diff --git a/apps/frontend/src/main/__tests__/ipc-handlers.test.ts b/apps/frontend/src/main/__tests__/ipc-handlers.test.ts index 207eb487dd..2bf99622a5 100644 --- a/apps/frontend/src/main/__tests__/ipc-handlers.test.ts +++ b/apps/frontend/src/main/__tests__/ipc-handlers.test.ts @@ -84,7 +84,6 @@ vi.mock("electron-log/main.js", () => ({ vi.mock("../cli-tool-manager", () => ({ getToolInfo: vi.fn(() => ({ found: false, path: null, source: "mock" })), getToolPath: vi.fn((tool: string) => tool), - deriveGitBashPath: vi.fn(() => null), clearCache: vi.fn(), clearToolCache: vi.fn(), configureTools: vi.fn(), diff --git a/apps/frontend/src/main/agent/agent-process.test.ts b/apps/frontend/src/main/agent/agent-process.test.ts index de10c03436..df581c5c80 100644 --- a/apps/frontend/src/main/agent/agent-process.test.ts +++ b/apps/frontend/src/main/agent/agent-process.test.ts @@ -123,13 +123,14 @@ vi.mock('../cli-tool-manager', () => ({ } return { found: false, path: undefined, source: 'user-config', message: `${tool} not found` }; }), - deriveGitBashPath: vi.fn(() => null), clearCache: vi.fn() })); // Mock env-utils to avoid blocking environment augmentation vi.mock('../env-utils', () => ({ - getAugmentedEnv: vi.fn(() => ({ ...process.env })) + getAugmentedEnv: vi.fn(() => ({ ...process.env })), + getGitBashEnv: vi.fn(() => ({})), + deriveGitBashPath: vi.fn(() => null) })); // Mock fs.existsSync for getAutoBuildSourcePath path validation diff --git a/apps/frontend/src/main/agent/agent-process.ts b/apps/frontend/src/main/agent/agent-process.ts index 3ffac994c1..0837255da7 100644 --- a/apps/frontend/src/main/agent/agent-process.ts +++ b/apps/frontend/src/main/agent/agent-process.ts @@ -22,8 +22,8 @@ import { buildMemoryEnvVars } from '../memory-env-builder'; import { readSettingsFile } from '../settings-utils'; import type { AppSettings } from '../../shared/types/settings'; import { getOAuthModeClearVars } from './env-utils'; -import { getAugmentedEnv } from '../env-utils'; -import { getToolInfo } from '../cli-tool-manager'; +import { getAugmentedEnv, getGitBashEnv } from '../env-utils'; +import { getToolInfo, type CLITool } from '../cli-tool-manager'; import { isWindows, killProcessGracefully } from '../platform'; /** @@ -41,60 +41,6 @@ const CLI_TOOL_ENV_MAP: Readonly> = { } as const; -function deriveGitBashPath(gitExePath: string): string | null { - if (process.platform !== 'win32') { - return null; - } - - try { - const gitDir = path.dirname(gitExePath); // e.g., D:\...\Git\mingw64\bin - const gitDirName = path.basename(gitDir).toLowerCase(); - - // Find Git installation root - let gitRoot: string; - - if (gitDirName === 'cmd') { - // .../Git/cmd/git.exe -> .../Git - gitRoot = path.dirname(gitDir); - } else if (gitDirName === 'bin') { - // Could be .../Git/bin/git.exe OR .../Git/mingw64/bin/git.exe - const parent = path.dirname(gitDir); - const parentName = path.basename(parent).toLowerCase(); - if (parentName === 'mingw64' || parentName === 'mingw32') { - // .../Git/mingw64/bin/git.exe -> .../Git - gitRoot = path.dirname(parent); - } else { - // .../Git/bin/git.exe -> .../Git - gitRoot = parent; - } - } else { - // Unknown structure - try to find 'bin' sibling - gitRoot = path.dirname(gitDir); - } - - // Bash.exe is in Git/bin/bash.exe - const bashPath = path.join(gitRoot, 'bin', 'bash.exe'); - - if (existsSync(bashPath)) { - console.log('[AgentProcess] Derived git-bash path:', bashPath); - return bashPath; - } - - // Fallback: check one level up if gitRoot didn't work - const altBashPath = path.join(path.dirname(gitRoot), 'bin', 'bash.exe'); - if (existsSync(altBashPath)) { - console.log('[AgentProcess] Found git-bash at alternate path:', altBashPath); - return altBashPath; - } - - console.warn('[AgentProcess] Could not find bash.exe from git path:', gitExePath); - return null; - } catch (error) { - console.error('[AgentProcess] Error deriving git-bash path:', error); - return null; - } -} - /** * Process spawning and lifecycle management */ @@ -163,22 +109,12 @@ export class AgentProcessManager { const augmentedEnv = getAugmentedEnv(); // On Windows, detect and pass git-bash path for Claude Code CLI - // Electron can detect git via where.exe, but Python subprocess may not have the same PATH - const gitBashEnv: Record = {}; - if (process.platform === 'win32' && !process.env.CLAUDE_CODE_GIT_BASH_PATH) { - try { - const gitInfo = getToolInfo('git'); - if (gitInfo.found && gitInfo.path) { - const bashPath = deriveGitBashPath(gitInfo.path); - if (bashPath) { - gitBashEnv['CLAUDE_CODE_GIT_BASH_PATH'] = bashPath; - console.log('[AgentProcess] Setting CLAUDE_CODE_GIT_BASH_PATH:', bashPath); - } - } - } catch (error) { - console.warn('[AgentProcess] Failed to detect git-bash path:', error); - } - } + // Use dependency injection to avoid lazy-loading issues with bundlers + // Adapter converts path?: string to path: string | null for type compatibility + const gitBashEnv = getGitBashEnv((tool) => { + const result = getToolInfo(tool as CLITool); + return { ...result, path: result.path ?? null }; + }); // Detect and pass CLI tool paths to Python backend const claudeCliEnv = this.detectAndSetCliPath('claude'); diff --git a/apps/frontend/src/main/env-utils.ts b/apps/frontend/src/main/env-utils.ts index d30e293a3e..3aa3f13ad1 100644 --- a/apps/frontend/src/main/env-utils.ts +++ b/apps/frontend/src/main/env-utils.ts @@ -15,6 +15,7 @@ import * as fs from 'fs'; import { promises as fsPromises } from 'fs'; import { execFileSync, execFile } from 'child_process'; import { promisify } from 'util'; +import { createRequire } from 'module'; import { getSentryEnvForSubprocess } from './sentry'; import { isWindows, isUnix, getPathDelimiter } from './platform'; @@ -563,3 +564,135 @@ export function getSpawnCommand(command: string): string { } return trimmed; } + +/** + * Derive git-bash (bash.exe) path from git executable path on Windows. + * + * Git for Windows installs bash.exe in Git/bin/ directory. This function + * handles various git.exe locations: + * - .../Git/cmd/git.exe -> .../Git/bin/bash.exe + * - .../Git/bin/git.exe -> .../Git/bin/bash.exe + * - .../Git/mingw64/bin/git.exe -> .../Git/bin/bash.exe + * + * @param gitExePath - Path to git.exe (e.g., D:\Program Files\Git\mingw64\bin\git.exe) + * @returns Path to bash.exe if found, null otherwise + */ +function formatPathForLogs(filePath: string): string { + if (!filePath) { + return ''; + } + return path.win32.basename(filePath) || path.basename(filePath); +} + +function formatErrorForLogs(error: unknown): string { + if (error && typeof error === 'object' && 'code' in error) { + const code = (error as { code?: unknown }).code; + return typeof code === 'string' || typeof code === 'number' ? String(code) : 'unknown'; + } + if (error instanceof Error) { + return error.name; + } + return 'unknown'; +} + +export function deriveGitBashPath(gitExePath: string): string | null { + if (!isWindows()) { + return null; + } + + try { + // Use win32 path ops to correctly parse Windows-style paths on non-Windows runners + const winPath = path.win32; + const gitDir = winPath.dirname(gitExePath); // e.g., D:\...\Git\mingw64\bin + const gitDirName = winPath.basename(gitDir).toLowerCase(); + + // Find Git installation root + let gitRoot: string; + + if (gitDirName === 'cmd') { + // .../Git/cmd/git.exe -> .../Git + gitRoot = winPath.dirname(gitDir); + } else if (gitDirName === 'bin') { + // Could be .../Git/bin/git.exe OR .../Git/mingw64/bin/git.exe + const parent = winPath.dirname(gitDir); + const parentName = winPath.basename(parent).toLowerCase(); + if (parentName === 'mingw64' || parentName === 'mingw32') { + // .../Git/mingw64/bin/git.exe -> .../Git + gitRoot = winPath.dirname(parent); + } else { + // .../Git/bin/git.exe -> .../Git + gitRoot = parent; + } + } else { + // Unknown structure - try to find 'bin' sibling + gitRoot = winPath.dirname(gitDir); + } + + // Bash.exe is in Git/bin/bash.exe + const bashPath = winPath.join(gitRoot, 'bin', 'bash.exe'); + + if (fs.existsSync(bashPath)) { + return bashPath; + } + + // Fallback: check one level up if gitRoot didn't work + const altBashPath = winPath.join(winPath.dirname(gitRoot), 'bin', 'bash.exe'); + if (fs.existsSync(altBashPath)) { + return altBashPath; + } + + console.warn('[deriveGitBashPath] bash.exe not found in standard locations.'); + return null; + } catch (error) { + console.warn('[deriveGitBashPath] Error deriving git-bash path:', formatErrorForLogs(error)); + return null; + } +} + +// Lazy import to avoid circular dependency (cli-tool-manager imports env-utils) +const require = createRequire(import.meta.url); +let _getToolInfo: ((tool: string) => { found: boolean; path: string | null; source: string }) | null = null; +function getToolInfoLazy(tool: string): { found: boolean; path: string | null; source: string } { + if (!_getToolInfo) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + _getToolInfo = require('./cli-tool-manager').getToolInfo; + } + return _getToolInfo!(tool); +} + +/** + * Get Git Bash environment variables for Windows. + * + * On Windows, Claude Code CLI requires git-bash (bash.exe) to execute shell commands. + * This function detects the git-bash path and returns it as an environment variable. + * + * This is a shared utility to avoid code duplication between AgentProcess and InsightsConfig. + * + * @param toolInfoFn - Optional function to get tool info (for testing/dependency injection) + * @returns Record containing CLAUDE_CODE_GIT_BASH_PATH if detected, empty object otherwise + */ +export function getGitBashEnv( + toolInfoFn?: (tool: string) => { found: boolean; path: string | null; source: string } +): Record { + const gitBashEnv: Record = {}; + + if (!isWindows() || process.env.CLAUDE_CODE_GIT_BASH_PATH) { + return gitBashEnv; + } + + try { + const getToolInfoImpl = toolInfoFn ?? getToolInfoLazy; + const gitInfo = getToolInfoImpl('git'); + if (gitInfo.found && gitInfo.path) { + const bashPath = deriveGitBashPath(gitInfo.path); + if (bashPath) { + gitBashEnv['CLAUDE_CODE_GIT_BASH_PATH'] = bashPath; + console.log('[env-utils] Setting CLAUDE_CODE_GIT_BASH_PATH:', formatPathForLogs(bashPath)); + } + } + } catch (error) { + console.warn('[env-utils] Failed to detect git-bash path:', formatErrorForLogs(error)); + } + + return gitBashEnv; +} diff --git a/apps/frontend/src/main/insights/config.ts b/apps/frontend/src/main/insights/config.ts index 97e8a9a28d..e9929d82cd 100644 --- a/apps/frontend/src/main/insights/config.ts +++ b/apps/frontend/src/main/insights/config.ts @@ -5,7 +5,9 @@ import { getAPIProfileEnv } from '../services/profile'; import { getOAuthModeClearVars } from '../agent/env-utils'; import { pythonEnvManager, getConfiguredPythonPath } from '../python-env-manager'; import { getValidatedPythonPath } from '../python-detector'; -import { getAugmentedEnv } from '../env-utils'; +import { getAugmentedEnv, getGitBashEnv } from '../env-utils'; +import { getToolInfo, type CLITool } from '../cli-tool-manager'; +import { isWindows } from '../platform'; import { getEffectiveSourcePath } from '../updater/path-resolver'; /** @@ -121,11 +123,11 @@ export class InsightsConfig { if (autoBuildSource) { const normalizedAutoBuildSource = path.resolve(autoBuildSource); - const autoBuildComparator = process.platform === 'win32' + const autoBuildComparator = isWindows() ? normalizedAutoBuildSource.toLowerCase() : normalizedAutoBuildSource; const hasAutoBuildSource = pythonPathParts.some((entry) => { - const candidate = process.platform === 'win32' ? entry.toLowerCase() : entry; + const candidate = isWindows() ? entry.toLowerCase() : entry; return candidate === autoBuildComparator; }); @@ -140,8 +142,17 @@ export class InsightsConfig { // are available even when app is launched from Finder/Dock. const augmentedEnv = getAugmentedEnv(); + // On Windows, detect and pass git-bash path for Claude Code CLI + // Use dependency injection to avoid lazy-loading issues with bundlers + // Adapter converts path?: string to path: string | null for type compatibility + const gitBashEnv = getGitBashEnv((tool) => { + const result = getToolInfo(tool as CLITool); + return { ...result, path: result.path ?? null }; + }); + return { ...augmentedEnv, + ...gitBashEnv, ...pythonEnv, // Include PYTHONPATH for bundled site-packages ...autoBuildEnv, ...oauthModeClearVars,