diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 0ebe48266d..aec1d57b43 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -244,6 +244,7 @@ export enum IpcChannel { System_GetCpuName = 'system:getCpuName', System_CheckGitBash = 'system:checkGitBash', System_GetGitBashPath = 'system:getGitBashPath', + System_GetGitBashPathInfo = 'system:getGitBashPathInfo', System_SetGitBashPath = 'system:setGitBashPath', // DevTools diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 1e02ce7706..af0191f4fa 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -488,3 +488,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [ // resources/scripts should be maintained manually export const HOME_CHERRY_DIR = '.cherrystudio' + +// Git Bash path configuration types +export type GitBashPathSource = 'manual' | 'auto' + +export interface GitBashPathInfo { + path: string | null + source: GitBashPathSource | null +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index d7e82ff875..4cb3402414 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -6,7 +6,14 @@ import { loggerService } from '@logger' import { isLinux, isMac, isPortable, isWin } from '@main/constant' import { generateSignature } from '@main/integration/cherryai' import anthropicService from '@main/services/AnthropicService' -import { findGitBash, getBinaryPath, isBinaryExists, runInstallScript, validateGitBashPath } from '@main/utils/process' +import { + autoDiscoverGitBash, + getBinaryPath, + getGitBashPathInfo, + isBinaryExists, + runInstallScript, + validateGitBashPath +} from '@main/utils/process' import { handleZoomFactor } from '@main/utils/zoom' import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import type { UpgradeChannel } from '@shared/config/constant' @@ -499,9 +506,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { } try { - const customPath = configManager.get(ConfigKeys.GitBashPath) as string | undefined - const bashPath = findGitBash(customPath) - + // Use autoDiscoverGitBash to handle auto-discovery and persistence + const bashPath = autoDiscoverGitBash() if (bashPath) { logger.info('Git Bash is available', { path: bashPath }) return true @@ -524,13 +530,22 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { return customPath ?? null }) + // Returns { path, source } where source is 'manual' | 'auto' | null + ipcMain.handle(IpcChannel.System_GetGitBashPathInfo, () => { + return getGitBashPathInfo() + }) + ipcMain.handle(IpcChannel.System_SetGitBashPath, (_, newPath: string | null) => { if (!isWin) { return false } if (!newPath) { + // Clear manual setting and re-run auto-discovery configManager.set(ConfigKeys.GitBashPath, null) + configManager.set(ConfigKeys.GitBashPathSource, null) + // Re-run auto-discovery to restore auto-discovered path if available + autoDiscoverGitBash() return true } @@ -539,7 +554,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { return false } + // Set path with 'manual' source configManager.set(ConfigKeys.GitBashPath, validated) + configManager.set(ConfigKeys.GitBashPathSource, 'manual') return true }) diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index c693d4b05a..6f2bbd44a4 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -32,7 +32,8 @@ export enum ConfigKeys { Proxy = 'proxy', EnableDeveloperMode = 'enableDeveloperMode', ClientId = 'clientId', - GitBashPath = 'gitBashPath' + GitBashPath = 'gitBashPath', + GitBashPathSource = 'gitBashPathSource' // 'manual' | 'auto' | null } export class ConfigManager { diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index ba863f7c50..45cecb049f 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -15,8 +15,8 @@ import { query } from '@anthropic-ai/claude-agent-sdk' import { loggerService } from '@logger' import { config as apiConfigService } from '@main/apiServer/config' import { validateModelId } from '@main/apiServer/utils' -import { ConfigKeys, configManager } from '@main/services/ConfigManager' -import { validateGitBashPath } from '@main/utils/process' +import { isWin } from '@main/constant' +import { autoDiscoverGitBash } from '@main/utils/process' import getLoginShellEnvironment from '@main/utils/shell-env' import { app } from 'electron' @@ -109,7 +109,8 @@ class ClaudeCodeService implements AgentServiceInterface { Object.entries(loginShellEnv).filter(([key]) => !key.toLowerCase().endsWith('_proxy')) ) as Record - const customGitBashPath = validateGitBashPath(configManager.get(ConfigKeys.GitBashPath) as string | undefined) + // Auto-discover Git Bash path on Windows (already logs internally) + const customGitBashPath = isWin ? autoDiscoverGitBash() : null const env = { ...loginShellEnvWithoutProxies, diff --git a/src/main/utils/__tests__/process.test.ts b/src/main/utils/__tests__/process.test.ts index 0485ec5fad..a1ac2fd9a5 100644 --- a/src/main/utils/__tests__/process.test.ts +++ b/src/main/utils/__tests__/process.test.ts @@ -1,9 +1,21 @@ +import { configManager } from '@main/services/ConfigManager' import { execFileSync } from 'child_process' import fs from 'fs' import path from 'path' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { findExecutable, findGitBash, validateGitBashPath } from '../process' +import { autoDiscoverGitBash, findExecutable, findGitBash, validateGitBashPath } from '../process' + +// Mock configManager +vi.mock('@main/services/ConfigManager', () => ({ + ConfigKeys: { + GitBashPath: 'gitBashPath' + }, + configManager: { + get: vi.fn(), + set: vi.fn() + } +})) // Mock dependencies vi.mock('child_process') @@ -695,4 +707,284 @@ describe.skipIf(process.platform !== 'win32')('process utilities', () => { }) }) }) + + describe('autoDiscoverGitBash', () => { + const originalEnvVar = process.env.CLAUDE_CODE_GIT_BASH_PATH + + beforeEach(() => { + vi.mocked(configManager.get).mockReset() + vi.mocked(configManager.set).mockReset() + delete process.env.CLAUDE_CODE_GIT_BASH_PATH + }) + + afterEach(() => { + // Restore original environment variable + if (originalEnvVar !== undefined) { + process.env.CLAUDE_CODE_GIT_BASH_PATH = originalEnvVar + } else { + delete process.env.CLAUDE_CODE_GIT_BASH_PATH + } + }) + + /** + * Helper to mock fs.existsSync with a set of valid paths + */ + const mockExistingPaths = (...validPaths: string[]) => { + vi.mocked(fs.existsSync).mockImplementation((p) => validPaths.includes(p as string)) + } + + describe('with no existing config path', () => { + it('should discover and persist Git Bash path when not configured', () => { + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + + vi.mocked(configManager.get).mockReturnValue(undefined) + process.env.ProgramFiles = 'C:\\Program Files' + mockExistingPaths(gitPath, bashPath) + + const result = autoDiscoverGitBash() + + expect(result).toBe(bashPath) + expect(configManager.set).toHaveBeenCalledWith('gitBashPath', bashPath) + }) + + it('should return null and not persist when Git Bash is not found', () => { + vi.mocked(configManager.get).mockReturnValue(undefined) + vi.mocked(fs.existsSync).mockReturnValue(false) + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('Not found') + }) + + const result = autoDiscoverGitBash() + + expect(result).toBeNull() + expect(configManager.set).not.toHaveBeenCalled() + }) + }) + + describe('environment variable precedence', () => { + it('should use env var over valid config path', () => { + const envPath = 'C:\\EnvGit\\bin\\bash.exe' + const configPath = 'C:\\ConfigGit\\bin\\bash.exe' + + process.env.CLAUDE_CODE_GIT_BASH_PATH = envPath + vi.mocked(configManager.get).mockReturnValue(configPath) + mockExistingPaths(envPath, configPath) + + const result = autoDiscoverGitBash() + + // Env var should take precedence + expect(result).toBe(envPath) + // Should not persist env var path (it's a runtime override) + expect(configManager.set).not.toHaveBeenCalled() + }) + + it('should fall back to config path when env var is invalid', () => { + const envPath = 'C:\\Invalid\\bash.exe' + const configPath = 'C:\\ConfigGit\\bin\\bash.exe' + + process.env.CLAUDE_CODE_GIT_BASH_PATH = envPath + vi.mocked(configManager.get).mockReturnValue(configPath) + // Env path is invalid (doesn't exist), only config path exists + mockExistingPaths(configPath) + + const result = autoDiscoverGitBash() + + // Should fall back to config path + expect(result).toBe(configPath) + expect(configManager.set).not.toHaveBeenCalled() + }) + + it('should fall back to auto-discovery when both env var and config are invalid', () => { + const envPath = 'C:\\InvalidEnv\\bash.exe' + const configPath = 'C:\\InvalidConfig\\bash.exe' + const discoveredPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + + process.env.CLAUDE_CODE_GIT_BASH_PATH = envPath + process.env.ProgramFiles = 'C:\\Program Files' + vi.mocked(configManager.get).mockReturnValue(configPath) + // Both env and config paths are invalid, only standard Git exists + mockExistingPaths(gitPath, discoveredPath) + + const result = autoDiscoverGitBash() + + expect(result).toBe(discoveredPath) + expect(configManager.set).toHaveBeenCalledWith('gitBashPath', discoveredPath) + }) + }) + + describe('with valid existing config path', () => { + it('should validate and return existing path without re-discovering', () => { + const existingPath = 'C:\\CustomGit\\bin\\bash.exe' + + vi.mocked(configManager.get).mockReturnValue(existingPath) + mockExistingPaths(existingPath) + + const result = autoDiscoverGitBash() + + expect(result).toBe(existingPath) + // Should not call findGitBash or persist again + expect(configManager.set).not.toHaveBeenCalled() + // Should not call execFileSync (which findGitBash would use for discovery) + expect(execFileSync).not.toHaveBeenCalled() + }) + + it('should not override existing valid config with auto-discovery', () => { + const existingPath = 'C:\\CustomGit\\bin\\bash.exe' + const discoveredPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + + vi.mocked(configManager.get).mockReturnValue(existingPath) + mockExistingPaths(existingPath, discoveredPath) + + const result = autoDiscoverGitBash() + + expect(result).toBe(existingPath) + expect(configManager.set).not.toHaveBeenCalled() + }) + }) + + describe('with invalid existing config path', () => { + it('should attempt auto-discovery when existing path does not exist', () => { + const existingPath = 'C:\\NonExistent\\bin\\bash.exe' + const discoveredPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + + vi.mocked(configManager.get).mockReturnValue(existingPath) + process.env.ProgramFiles = 'C:\\Program Files' + // Invalid path doesn't exist, but Git is installed at standard location + mockExistingPaths(gitPath, discoveredPath) + + const result = autoDiscoverGitBash() + + // Should discover and return the new path + expect(result).toBe(discoveredPath) + // Should persist the discovered path (overwrites invalid) + expect(configManager.set).toHaveBeenCalledWith('gitBashPath', discoveredPath) + }) + + it('should attempt auto-discovery when existing path is not bash.exe', () => { + const existingPath = 'C:\\CustomGit\\bin\\git.exe' + const discoveredPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + + vi.mocked(configManager.get).mockReturnValue(existingPath) + process.env.ProgramFiles = 'C:\\Program Files' + // Invalid path exists but is not bash.exe (validation will fail) + // Git is installed at standard location + mockExistingPaths(existingPath, gitPath, discoveredPath) + + const result = autoDiscoverGitBash() + + // Should discover and return the new path + expect(result).toBe(discoveredPath) + // Should persist the discovered path (overwrites invalid) + expect(configManager.set).toHaveBeenCalledWith('gitBashPath', discoveredPath) + }) + + it('should return null when existing path is invalid and discovery fails', () => { + const existingPath = 'C:\\NonExistent\\bin\\bash.exe' + + vi.mocked(configManager.get).mockReturnValue(existingPath) + vi.mocked(fs.existsSync).mockReturnValue(false) + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('Not found') + }) + + const result = autoDiscoverGitBash() + + // Both validation and discovery failed + expect(result).toBeNull() + // Should not persist when discovery fails + expect(configManager.set).not.toHaveBeenCalled() + }) + }) + + describe('config persistence verification', () => { + it('should persist discovered path with correct config key', () => { + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + + vi.mocked(configManager.get).mockReturnValue(undefined) + process.env.ProgramFiles = 'C:\\Program Files' + mockExistingPaths(gitPath, bashPath) + + autoDiscoverGitBash() + + // Verify the exact call to configManager.set + expect(configManager.set).toHaveBeenCalledTimes(1) + expect(configManager.set).toHaveBeenCalledWith('gitBashPath', bashPath) + }) + + it('should persist on each discovery when config remains undefined', () => { + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + + vi.mocked(configManager.get).mockReturnValue(undefined) + process.env.ProgramFiles = 'C:\\Program Files' + mockExistingPaths(gitPath, bashPath) + + autoDiscoverGitBash() + autoDiscoverGitBash() + + // Each call discovers and persists since config remains undefined (mocked) + expect(configManager.set).toHaveBeenCalledTimes(2) + }) + }) + + describe('real-world scenarios', () => { + it('should discover and persist standard Git for Windows installation', () => { + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + + vi.mocked(configManager.get).mockReturnValue(undefined) + process.env.ProgramFiles = 'C:\\Program Files' + mockExistingPaths(gitPath, bashPath) + + const result = autoDiscoverGitBash() + + expect(result).toBe(bashPath) + expect(configManager.set).toHaveBeenCalledWith('gitBashPath', bashPath) + }) + + it('should discover portable Git via where.exe and persist', () => { + const gitPath = 'D:\\PortableApps\\Git\\bin\\git.exe' + const bashPath = 'D:\\PortableApps\\Git\\bin\\bash.exe' + + vi.mocked(configManager.get).mockReturnValue(undefined) + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = p?.toString() || '' + // Common git paths don't exist + if (pathStr.includes('Program Files\\Git\\cmd\\git.exe')) return false + if (pathStr.includes('Program Files (x86)\\Git\\cmd\\git.exe')) return false + // Portable bash path exists + if (pathStr === bashPath) return true + return false + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = autoDiscoverGitBash() + + expect(result).toBe(bashPath) + expect(configManager.set).toHaveBeenCalledWith('gitBashPath', bashPath) + }) + + it('should respect user-configured path over auto-discovery', () => { + const userConfiguredPath = 'D:\\MyGit\\bin\\bash.exe' + const systemPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + + vi.mocked(configManager.get).mockReturnValue(userConfiguredPath) + mockExistingPaths(userConfiguredPath, systemPath) + + const result = autoDiscoverGitBash() + + expect(result).toBe(userConfiguredPath) + expect(configManager.set).not.toHaveBeenCalled() + // Verify findGitBash was not called for discovery + expect(execFileSync).not.toHaveBeenCalled() + }) + }) + }) }) diff --git a/src/main/utils/process.ts b/src/main/utils/process.ts index 7175af7e75..ccc0f66535 100644 --- a/src/main/utils/process.ts +++ b/src/main/utils/process.ts @@ -1,4 +1,5 @@ import { loggerService } from '@logger' +import type { GitBashPathInfo, GitBashPathSource } from '@shared/config/constant' import { HOME_CHERRY_DIR } from '@shared/config/constant' import { execFileSync, spawn } from 'child_process' import fs from 'fs' @@ -6,6 +7,7 @@ import os from 'os' import path from 'path' import { isWin } from '../constant' +import { ConfigKeys, configManager } from '../services/ConfigManager' import { getResourcePath } from '.' const logger = loggerService.withContext('Utils:Process') @@ -59,7 +61,7 @@ export async function getBinaryPath(name?: string): Promise { export async function isBinaryExists(name: string): Promise { const cmd = await getBinaryPath(name) - return await fs.existsSync(cmd) + return fs.existsSync(cmd) } /** @@ -225,3 +227,77 @@ export function validateGitBashPath(customPath?: string | null): string | null { logger.debug('Validated custom Git Bash path', { path: resolved }) return resolved } + +/** + * Auto-discover and persist Git Bash path if not already configured + * Only called when Git Bash is actually needed + * + * Precedence order: + * 1. CLAUDE_CODE_GIT_BASH_PATH environment variable (highest - runtime override) + * 2. Configured path from settings (manual or auto) + * 3. Auto-discovery via findGitBash (only if no valid config exists) + */ +export function autoDiscoverGitBash(): string | null { + if (!isWin) { + return null + } + + // 1. Check environment variable override first (highest priority) + const envOverride = process.env.CLAUDE_CODE_GIT_BASH_PATH + if (envOverride) { + const validated = validateGitBashPath(envOverride) + if (validated) { + logger.debug('Using CLAUDE_CODE_GIT_BASH_PATH override', { path: validated }) + return validated + } + logger.warn('CLAUDE_CODE_GIT_BASH_PATH provided but path is invalid', { path: envOverride }) + } + + // 2. Check if a path is already configured + const existingPath = configManager.get(ConfigKeys.GitBashPath) + const existingSource = configManager.get(ConfigKeys.GitBashPathSource) + + if (existingPath) { + const validated = validateGitBashPath(existingPath) + if (validated) { + return validated + } + // Existing path is invalid, try to auto-discover + logger.warn('Existing Git Bash path is invalid, attempting auto-discovery', { + path: existingPath, + source: existingSource + }) + } + + // 3. Try to find Git Bash via auto-discovery + const discoveredPath = findGitBash() + if (discoveredPath) { + // Persist the discovered path with 'auto' source + configManager.set(ConfigKeys.GitBashPath, discoveredPath) + configManager.set(ConfigKeys.GitBashPathSource, 'auto') + logger.info('Auto-discovered Git Bash path', { path: discoveredPath }) + } + + return discoveredPath +} + +/** + * Get Git Bash path info including source + * If no path is configured, triggers auto-discovery first + */ +export function getGitBashPathInfo(): GitBashPathInfo { + if (!isWin) { + return { path: null, source: null } + } + + let path = configManager.get(ConfigKeys.GitBashPath) ?? null + let source = configManager.get(ConfigKeys.GitBashPathSource) ?? null + + // If no path configured, trigger auto-discovery (handles upgrade from old versions) + if (!path) { + path = autoDiscoverGitBash() + source = path ? 'auto' : null + } + + return { path, source } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 117bec3b91..dc08e9a2df 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,7 +2,7 @@ import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' import { electronAPI } from '@electron-toolkit/preload' import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import type { SpanContext } from '@opentelemetry/api' -import type { TerminalConfig, UpgradeChannel } from '@shared/config/constant' +import type { GitBashPathInfo, TerminalConfig, UpgradeChannel } from '@shared/config/constant' import type { LogLevel, LogSourceWithContext } from '@shared/config/logger' import type { FileChangeEvent, WebviewKeyEvent } from '@shared/config/types' import type { MCPServerLogEntry } from '@shared/config/types' @@ -126,6 +126,7 @@ const api = { getCpuName: () => ipcRenderer.invoke(IpcChannel.System_GetCpuName), checkGitBash: (): Promise => ipcRenderer.invoke(IpcChannel.System_CheckGitBash), getGitBashPath: (): Promise => ipcRenderer.invoke(IpcChannel.System_GetGitBashPath), + getGitBashPathInfo: (): Promise => ipcRenderer.invoke(IpcChannel.System_GetGitBashPathInfo), setGitBashPath: (newPath: string | null): Promise => ipcRenderer.invoke(IpcChannel.System_SetGitBashPath, newPath) }, diff --git a/src/renderer/src/components/Popups/agent/AgentModal.tsx b/src/renderer/src/components/Popups/agent/AgentModal.tsx index 8a8b4fe61b..25e4b81f18 100644 --- a/src/renderer/src/components/Popups/agent/AgentModal.tsx +++ b/src/renderer/src/components/Popups/agent/AgentModal.tsx @@ -3,6 +3,7 @@ import { ErrorBoundary } from '@renderer/components/ErrorBoundary' import { HelpTooltip } from '@renderer/components/TooltipIcons' import { TopView } from '@renderer/components/TopView' import { permissionModeCards } from '@renderer/config/agent' +import { isWin } from '@renderer/config/constant' import { useAgents } from '@renderer/hooks/agents/useAgents' import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' import SelectAgentBaseModelButton from '@renderer/pages/home/components/SelectAgentBaseModelButton' @@ -16,7 +17,8 @@ import type { UpdateAgentForm } from '@renderer/types' import { AgentConfigurationSchema, isAgentType } from '@renderer/types' -import { Alert, Button, Input, Modal, Select } from 'antd' +import type { GitBashPathInfo } from '@shared/config/constant' +import { Button, Input, Modal, Select } from 'antd' import { AlertTriangleIcon } from 'lucide-react' import type { ChangeEvent, FormEvent } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -59,8 +61,7 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { const isEditing = (agent?: AgentWithTools) => agent !== undefined const [form, setForm] = useState(() => buildAgentForm(agent)) - const [hasGitBash, setHasGitBash] = useState(true) - const [customGitBashPath, setCustomGitBashPath] = useState('') + const [gitBashPathInfo, setGitBashPathInfo] = useState({ path: null, source: null }) useEffect(() => { if (open) { @@ -68,29 +69,15 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { } }, [agent, open]) - const checkGitBash = useCallback( - async (showToast = false) => { - try { - const [gitBashInstalled, savedPath] = await Promise.all([ - window.api.system.checkGitBash(), - window.api.system.getGitBashPath().catch(() => null) - ]) - setCustomGitBashPath(savedPath ?? '') - setHasGitBash(gitBashInstalled) - if (showToast) { - if (gitBashInstalled) { - window.toast.success(t('agent.gitBash.success', 'Git Bash detected successfully!')) - } else { - window.toast.error(t('agent.gitBash.notFound', 'Git Bash not found. Please install it first.')) - } - } - } catch (error) { - logger.error('Failed to check Git Bash:', error as Error) - setHasGitBash(true) // Default to true on error to avoid false warnings - } - }, - [t] - ) + const checkGitBash = useCallback(async () => { + if (!isWin) return + try { + const pathInfo = await window.api.system.getGitBashPathInfo() + setGitBashPathInfo(pathInfo) + } catch (error) { + logger.error('Failed to check Git Bash:', error as Error) + } + }, []) useEffect(() => { checkGitBash() @@ -119,24 +106,22 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { return } - setCustomGitBashPath(pickedPath) - await checkGitBash(true) + await checkGitBash() } catch (error) { logger.error('Failed to pick Git Bash path', error as Error) window.toast.error(t('agent.gitBash.pick.failed', 'Failed to set Git Bash path')) } }, [checkGitBash, t]) - const handleClearGitBash = useCallback(async () => { + const handleResetGitBash = useCallback(async () => { try { + // Clear manual setting and re-run auto-discovery await window.api.system.setGitBashPath(null) - setCustomGitBashPath('') - await checkGitBash(true) + await checkGitBash() } catch (error) { - logger.error('Failed to clear Git Bash path', error as Error) - window.toast.error(t('agent.gitBash.pick.failed', 'Failed to set Git Bash path')) + logger.error('Failed to reset Git Bash path', error as Error) } - }, [checkGitBash, t]) + }, [checkGitBash]) const onPermissionModeChange = useCallback((value: PermissionMode) => { setForm((prev) => { @@ -268,6 +253,12 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { return } + if (isWin && !gitBashPathInfo.path) { + window.toast.error(t('agent.gitBash.error.required', 'Git Bash path is required on Windows')) + loadingRef.current = false + return + } + if (isEditing(agent)) { if (!agent) { loadingRef.current = false @@ -327,7 +318,8 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { t, updateAgent, afterSubmit, - addAgent + addAgent, + gitBashPathInfo.path ] ) @@ -346,66 +338,6 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { footer={null}> - {!hasGitBash && ( - -
- {t( - 'agent.gitBash.error.description', - 'Git Bash is required to run agents on Windows. The agent cannot function without it. Please install Git for Windows from' - )}{' '} - { - e.preventDefault() - window.api.openWebsite('https://git-scm.com/download/win') - }} - style={{ textDecoration: 'underline' }}> - git-scm.com - -
- - - - } - type="error" - showIcon - style={{ marginBottom: 16 }} - /> - )} - - {hasGitBash && customGitBashPath && ( - -
- {t('agent.gitBash.customPath', { - defaultValue: 'Using custom path: {{path}}', - path: customGitBashPath - })} -
-
- - -
- - } - type="success" - showIcon - style={{ marginBottom: 16 }} - /> - )} + {isWin && ( + +
+ + +
+ + + + {gitBashPathInfo.source === 'manual' && ( + + )} + + {gitBashPathInfo.path && gitBashPathInfo.source === 'auto' && ( + {t('agent.gitBash.autoDiscoveredHint', 'Auto-discovered')} + )} +
+ )} +