fix: auto-discover and persist Git Bash path on Windows for scoop (#11921)

* feat: auto-discover and persist Git Bash path on Windows

- Add autoDiscoverGitBash function to find and cache Git Bash path when needed
- Modify System_CheckGitBash IPC handler to auto-discover and persist path
- Update Claude Code service with fallback auto-discovery mechanism
- Git Bash path is now cached after first discovery, improving UX for Windows users

* udpate

* fix: remove redundant validation of auto-discovered Git Bash path

The autoDiscoverGitBash function already returns a validated path, so calling validateGitBashPath again is unnecessary.

Co-Authored-By: Claude <noreply@anthropic.com>

* udpate

* test: add unit tests for autoDiscoverGitBash function

Add comprehensive test coverage for autoDiscoverGitBash including:
- Discovery with no existing config path
- Validation of existing config paths
- Handling of invalid existing paths
- Config persistence verification
- Real-world scenarios (standard Git, portable Git, user-configured paths)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove unnecessary async keyword from System_CheckGitBash handler

The handler doesn't use await since autoDiscoverGitBash is synchronous.
Removes async for consistency with other IPC handlers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: rename misleading test to match actual behavior

Renamed "should not call configManager.set multiple times on single discovery"
to "should persist on each discovery when config remains undefined" to
accurately describe that each call to autoDiscoverGitBash persists when
the config mock returns undefined.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: use generic type parameter instead of type assertion

Replace `as string | undefined` with `get<string | undefined>()` for
better type safety when retrieving GitBashPath from config.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: simplify Git Bash path resolution in Claude Code service

Remove redundant validateGitBashPath call since autoDiscoverGitBash
already handles validation of configured paths before attempting
discovery. Also remove unused ConfigKeys and configManager imports.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: attempt auto-discovery when configured Git Bash path is invalid

Previously, if a user had an invalid configured path (e.g., Git was
moved or uninstalled), autoDiscoverGitBash would return null without
attempting to find a valid installation. Now it logs a warning and
attempts auto-discovery, providing a better user experience by
automatically fixing invalid configurations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: ensure CLAUDE_CODE_GIT_BASH_PATH env var takes precedence over config

Previously, if a valid config path existed, the environment variable
CLAUDE_CODE_GIT_BASH_PATH was never checked. Now the precedence order is:

1. CLAUDE_CODE_GIT_BASH_PATH env var (highest - runtime override)
2. Configured path from settings
3. Auto-discovery via findGitBash

This allows users to temporarily override the configured path without
modifying their persistent settings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: improve code quality and test robustness

- Remove duplicate logging in Claude Code service (autoDiscoverGitBash logs internally)
- Simplify Git Bash path initialization with ternary expression
- Add afterEach cleanup to restore original env vars in tests
- Extract mockExistingPaths helper to reduce test code duplication

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: track Git Bash path source to distinguish manual vs auto-discovered

- Add GitBashPathSource type and GitBashPathInfo interface to shared constants
- Add GitBashPathSource config key to persist path origin ('manual' | 'auto')
- Update autoDiscoverGitBash to mark discovered paths as 'auto'
- Update setGitBashPath IPC to mark user-set paths as 'manual'
- Add getGitBashPathInfo API to retrieve path with source info
- Update AgentModal UI to show different text based on source:
  - Manual: "Using custom path" with clear button
  - Auto: "Auto-discovered" without clear button

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: simplify Git Bash config UI as form field

- Replace large Alert components with compact form field
- Use static isWin constant instead of async platform detection
- Show Git Bash field only on Windows with auto-fill support
- Disable save button when Git Bash path is missing on Windows
- Add "Auto-discovered" hint for auto-detected paths
- Remove hasGitBash state, simplify checkGitBash logic

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ui: add explicit select button for Git Bash path

Replace click-on-input interaction with a dedicated "Select" button
for clearer UX

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: simplify Git Bash UI by removing clear button

- Remove handleClearGitBash function (no longer needed)
- Remove clear button from UI (auto-discover fills value, user can re-select)
- Remove auto-discovered hint (SourceHint)
- Remove unused SourceHint styled component

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add reset button to restore auto-discovered Git Bash path

- Add handleResetGitBash to clear manual setting and re-run auto-discovery
- Show "Reset" button only when source is 'manual'
- Show "Auto-discovered" hint when path was found automatically
- User can re-select if auto-discovered path is not suitable

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: re-run auto-discovery when resetting Git Bash path

When setGitBashPath(null) is called (reset), now automatically
re-runs autoDiscoverGitBash() to restore the auto-discovered path.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(i18n): add Git Bash config translations

Add translations for:
- autoDiscoveredHint: hint text for auto-discovered paths
- placeholder: input placeholder for bash.exe selection
- tooltip: help tooltip text
- error.required: validation error message

Supported languages: en-US, zh-CN, zh-TW

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* update i18n

* fix: auto-discover Git Bash when getting path info

When getGitBashPathInfo() is called and no path is configured,
automatically trigger autoDiscoverGitBash() first. This handles
the upgrade scenario from old versions that don't have Git Bash
path configured.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
beyondkmp 2025-12-18 09:57:23 +08:00 committed by GitHub
parent 739096deca
commit 150bb3e3a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 539 additions and 117 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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
})

View File

@ -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 {

View File

@ -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<string, string>
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,

View File

@ -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()
})
})
})
})

View File

@ -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<string> {
export async function isBinaryExists(name: string): Promise<boolean> {
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<string | undefined>(ConfigKeys.GitBashPath)
const existingSource = configManager.get<GitBashPathSource | undefined>(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<string | null>(ConfigKeys.GitBashPath) ?? null
let source = configManager.get<GitBashPathSource | null>(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 }
}

View File

@ -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<boolean> => ipcRenderer.invoke(IpcChannel.System_CheckGitBash),
getGitBashPath: (): Promise<string | null> => ipcRenderer.invoke(IpcChannel.System_GetGitBashPath),
getGitBashPathInfo: (): Promise<GitBashPathInfo> => ipcRenderer.invoke(IpcChannel.System_GetGitBashPathInfo),
setGitBashPath: (newPath: string | null): Promise<boolean> =>
ipcRenderer.invoke(IpcChannel.System_SetGitBashPath, newPath)
},

View File

@ -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<Props> = ({ agent, afterSubmit, resolve }) => {
const isEditing = (agent?: AgentWithTools) => agent !== undefined
const [form, setForm] = useState<BaseAgentForm>(() => buildAgentForm(agent))
const [hasGitBash, setHasGitBash] = useState<boolean>(true)
const [customGitBashPath, setCustomGitBashPath] = useState<string>('')
const [gitBashPathInfo, setGitBashPathInfo] = useState<GitBashPathInfo>({ path: null, source: null })
useEffect(() => {
if (open) {
@ -68,29 +69,15 @@ const PopupContainer: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ agent, afterSubmit, resolve }) => {
t,
updateAgent,
afterSubmit,
addAgent
addAgent,
gitBashPathInfo.path
]
)
@ -346,66 +338,6 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
footer={null}>
<StyledForm onSubmit={onSubmit}>
<FormContent>
{!hasGitBash && (
<Alert
message={t('agent.gitBash.error.title', 'Git Bash Required')}
description={
<div>
<div style={{ marginBottom: 8 }}>
{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'
)}{' '}
<a
href="https://git-scm.com/download/win"
onClick={(e) => {
e.preventDefault()
window.api.openWebsite('https://git-scm.com/download/win')
}}
style={{ textDecoration: 'underline' }}>
git-scm.com
</a>
</div>
<Button size="small" onClick={() => checkGitBash(true)}>
{t('agent.gitBash.error.recheck', 'Recheck Git Bash Installation')}
</Button>
<Button size="small" style={{ marginLeft: 8 }} onClick={handlePickGitBash}>
{t('agent.gitBash.pick.button', 'Select Git Bash Path')}
</Button>
</div>
}
type="error"
showIcon
style={{ marginBottom: 16 }}
/>
)}
{hasGitBash && customGitBashPath && (
<Alert
message={t('agent.gitBash.found.title', 'Git Bash configured')}
description={
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div>
{t('agent.gitBash.customPath', {
defaultValue: 'Using custom path: {{path}}',
path: customGitBashPath
})}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button size="small" onClick={handlePickGitBash}>
{t('agent.gitBash.pick.button', 'Select Git Bash Path')}
</Button>
<Button size="small" onClick={handleClearGitBash}>
{t('agent.gitBash.clear.button', 'Clear custom path')}
</Button>
</div>
</div>
}
type="success"
showIcon
style={{ marginBottom: 16 }}
/>
)}
<FormRow>
<FormItem style={{ flex: 1 }}>
<Label>
@ -439,6 +371,40 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
/>
</FormItem>
{isWin && (
<FormItem>
<div className="flex items-center gap-2">
<Label>
Git Bash <RequiredMark>*</RequiredMark>
</Label>
<HelpTooltip
title={t(
'agent.gitBash.tooltip',
'Git Bash is required to run agents on Windows. Install from git-scm.com if not available.'
)}
/>
</div>
<GitBashInputWrapper>
<Input
value={gitBashPathInfo.path ?? ''}
readOnly
placeholder={t('agent.gitBash.placeholder', 'Select bash.exe path')}
/>
<Button size="small" onClick={handlePickGitBash}>
{t('common.select', 'Select')}
</Button>
{gitBashPathInfo.source === 'manual' && (
<Button size="small" onClick={handleResetGitBash}>
{t('common.reset', 'Reset')}
</Button>
)}
</GitBashInputWrapper>
{gitBashPathInfo.path && gitBashPathInfo.source === 'auto' && (
<SourceHint>{t('agent.gitBash.autoDiscoveredHint', 'Auto-discovered')}</SourceHint>
)}
</FormItem>
)}
<FormItem>
<Label>
{t('agent.settings.tooling.permissionMode.title', 'Permission mode')} <RequiredMark>*</RequiredMark>
@ -511,7 +477,11 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
<FormFooter>
<Button onClick={onCancel}>{t('common.close')}</Button>
<Button type="primary" htmlType="submit" loading={loadingRef.current} disabled={!hasGitBash}>
<Button
type="primary"
htmlType="submit"
loading={loadingRef.current}
disabled={isWin && !gitBashPathInfo.path}>
{isEditing(agent) ? t('common.confirm') : t('common.add')}
</Button>
</FormFooter>
@ -582,6 +552,21 @@ const FormItem = styled.div`
gap: 8px;
`
const GitBashInputWrapper = styled.div`
display: flex;
gap: 8px;
align-items: center;
input {
flex: 1;
}
`
const SourceHint = styled.span`
font-size: 12px;
color: var(--color-text-3);
`
const Label = styled.label`
font-size: 14px;
color: var(--color-text-1);

View File

@ -32,6 +32,7 @@
},
"gitBash": {
"autoDetected": "Using auto-detected Git Bash",
"autoDiscoveredHint": "Auto-discovered",
"clear": {
"button": "Clear custom path"
},
@ -39,6 +40,7 @@
"error": {
"description": "Git Bash is required to run agents on Windows. The agent cannot function without it. Please install Git for Windows from",
"recheck": "Recheck Git Bash Installation",
"required": "Git Bash path is required on Windows",
"title": "Git Bash Required"
},
"found": {
@ -51,7 +53,9 @@
"invalidPath": "Selected file is not a valid Git Bash executable (bash.exe).",
"title": "Select Git Bash executable"
},
"success": "Git Bash detected successfully!"
"placeholder": "Select bash.exe path",
"success": "Git Bash detected successfully!",
"tooltip": "Git Bash is required to run agents on Windows. Install from git-scm.com if not available."
},
"input": {
"placeholder": "Enter your message here, send with {{key}} - @ select path, / select command"

View File

@ -32,6 +32,7 @@
},
"gitBash": {
"autoDetected": "使用自动检测的 Git Bash",
"autoDiscoveredHint": "自动发现",
"clear": {
"button": "清除自定义路径"
},
@ -39,6 +40,7 @@
"error": {
"description": "在 Windows 上运行智能体需要 Git Bash。没有它智能体无法运行。请从以下地址安装 Git for Windows",
"recheck": "重新检测 Git Bash 安装",
"required": "在 Windows 上需要配置 Git Bash 路径",
"title": "需要 Git Bash"
},
"found": {
@ -51,7 +53,9 @@
"invalidPath": "选择的文件不是有效的 Git Bash 可执行文件bash.exe。",
"title": "选择 Git Bash 可执行文件"
},
"success": "成功检测到 Git Bash"
"placeholder": "选择 bash.exe 路径",
"success": "成功检测到 Git Bash",
"tooltip": "在 Windows 上运行智能体需要 Git Bash。如果未安装请从 git-scm.com 下载安装。"
},
"input": {
"placeholder": "在这里输入消息,按 {{key}} 发送 - @ 选择路径, / 选择命令"

View File

@ -32,6 +32,7 @@
},
"gitBash": {
"autoDetected": "使用自動偵測的 Git Bash",
"autoDiscoveredHint": "自動發現",
"clear": {
"button": "清除自訂路徑"
},
@ -39,6 +40,7 @@
"error": {
"description": "在 Windows 上執行 Agent 需要 Git Bash。沒有它 Agent 無法運作。請從以下網址安裝 Git for Windows",
"recheck": "重新偵測 Git Bash 安裝",
"required": "在 Windows 上需要設定 Git Bash 路徑",
"title": "需要 Git Bash"
},
"found": {
@ -51,7 +53,9 @@
"invalidPath": "選擇的檔案不是有效的 Git Bash 可執行檔bash.exe。",
"title": "選擇 Git Bash 可執行檔"
},
"success": "成功偵測到 Git Bash"
"placeholder": "選擇 bash.exe 路徑",
"success": "成功偵測到 Git Bash",
"tooltip": "在 Windows 上執行 Agent 需要 Git Bash。如未安裝請從 git-scm.com 下載安裝。"
},
"input": {
"placeholder": "在這裡輸入您的訊息,使用 {{key}} 傳送 - @ 選擇路徑,/ 選擇命令"

View File

@ -32,6 +32,7 @@
},
"gitBash": {
"autoDetected": "Automatisch ermitteltes Git Bash wird verwendet",
"autoDiscoveredHint": "[to be translated]:Auto-discovered",
"clear": {
"button": "Benutzerdefinierten Pfad löschen"
},
@ -39,6 +40,7 @@
"error": {
"description": "Git Bash ist erforderlich, um Agents unter Windows auszuführen. Der Agent kann ohne es nicht funktionieren. Bitte installieren Sie Git für Windows von",
"recheck": "Überprüfe die Git Bash-Installation erneut",
"required": "[to be translated]:Git Bash path is required on Windows",
"title": "Git Bash erforderlich"
},
"found": {
@ -51,7 +53,9 @@
"invalidPath": "Die ausgewählte Datei ist keine gültige Git Bash ausführbare Datei (bash.exe).",
"title": "Git Bash ausführbare Datei auswählen"
},
"success": "Git Bash erfolgreich erkannt!"
"placeholder": "[to be translated]:Select bash.exe path",
"success": "Git Bash erfolgreich erkannt!",
"tooltip": "[to be translated]:Git Bash is required to run agents on Windows. Install from git-scm.com if not available."
},
"input": {
"placeholder": "Gib hier deine Nachricht ein, senden mit {{key}} @ Pfad auswählen, / Befehl auswählen"

View File

@ -32,6 +32,7 @@
},
"gitBash": {
"autoDetected": "Χρησιμοποιείται αυτόματα εντοπισμένο Git Bash",
"autoDiscoveredHint": "[to be translated]:Auto-discovered",
"clear": {
"button": "Διαγραφή προσαρμοσμένης διαδρομής"
},
@ -39,6 +40,7 @@
"error": {
"description": "Το Git Bash απαιτείται για την εκτέλεση πρακτόρων στα Windows. Ο πράκτορας δεν μπορεί να λειτουργήσει χωρίς αυτό. Παρακαλούμε εγκαταστήστε το Git για Windows από",
"recheck": "Επανέλεγχος Εγκατάστασης του Git Bash",
"required": "[to be translated]:Git Bash path is required on Windows",
"title": "Απαιτείται Git Bash"
},
"found": {
@ -51,7 +53,9 @@
"invalidPath": "Το επιλεγμένο αρχείο δεν είναι έγκυρο εκτελέσιμο Git Bash (bash.exe).",
"title": "Επιλογή εκτελέσιμου Git Bash"
},
"success": "Το Git Bash εντοπίστηκε με επιτυχία!"
"placeholder": "[to be translated]:Select bash.exe path",
"success": "Το Git Bash εντοπίστηκε με επιτυχία!",
"tooltip": "[to be translated]:Git Bash is required to run agents on Windows. Install from git-scm.com if not available."
},
"input": {
"placeholder": "Εισάγετε το μήνυμά σας εδώ, στείλτε με {{key}} - @ επιλέξτε διαδρομή, / επιλέξτε εντολή"

View File

@ -32,6 +32,7 @@
},
"gitBash": {
"autoDetected": "Usando Git Bash detectado automáticamente",
"autoDiscoveredHint": "[to be translated]:Auto-discovered",
"clear": {
"button": "Borrar ruta personalizada"
},
@ -39,6 +40,7 @@
"error": {
"description": "Se requiere Git Bash para ejecutar agentes en Windows. El agente no puede funcionar sin él. Instale Git para Windows desde",
"recheck": "Volver a verificar la instalación de Git Bash",
"required": "[to be translated]:Git Bash path is required on Windows",
"title": "Git Bash Requerido"
},
"found": {
@ -51,7 +53,9 @@
"invalidPath": "El archivo seleccionado no es un ejecutable válido de Git Bash (bash.exe).",
"title": "Seleccionar ejecutable de Git Bash"
},
"success": "¡Git Bash detectado con éxito!"
"placeholder": "[to be translated]:Select bash.exe path",
"success": "¡Git Bash detectado con éxito!",
"tooltip": "[to be translated]:Git Bash is required to run agents on Windows. Install from git-scm.com if not available."
},
"input": {
"placeholder": "Introduce tu mensaje aquí, envía con {{key}} - @ seleccionar ruta, / seleccionar comando"

View File

@ -32,6 +32,7 @@
},
"gitBash": {
"autoDetected": "Utilisation de Git Bash détecté automatiquement",
"autoDiscoveredHint": "[to be translated]:Auto-discovered",
"clear": {
"button": "Effacer le chemin personnalisé"
},
@ -39,6 +40,7 @@
"error": {
"description": "Git Bash est requis pour exécuter des agents sur Windows. L'agent ne peut pas fonctionner sans. Veuillez installer Git pour Windows depuis",
"recheck": "Revérifier l'installation de Git Bash",
"required": "[to be translated]:Git Bash path is required on Windows",
"title": "Git Bash requis"
},
"found": {
@ -51,7 +53,9 @@
"invalidPath": "Le fichier sélectionné n'est pas un exécutable Git Bash valide (bash.exe).",
"title": "Sélectionner l'exécutable Git Bash"
},
"success": "Git Bash détecté avec succès !"
"placeholder": "[to be translated]:Select bash.exe path",
"success": "Git Bash détecté avec succès !",
"tooltip": "[to be translated]:Git Bash is required to run agents on Windows. Install from git-scm.com if not available."
},
"input": {
"placeholder": "Entrez votre message ici, envoyez avec {{key}} - @ sélectionner le chemin, / sélectionner la commande"

View File

@ -32,6 +32,7 @@
},
"gitBash": {
"autoDetected": "自動検出されたGit Bashを使用中",
"autoDiscoveredHint": "[to be translated]:Auto-discovered",
"clear": {
"button": "カスタムパスをクリア"
},
@ -39,6 +40,7 @@
"error": {
"description": "Windowsでエージェントを実行するにはGit Bashが必要です。これがないとエージェントは動作しません。以下からGit for Windowsをインストールしてください。",
"recheck": "Git Bashのインストールを再確認してください",
"required": "[to be translated]:Git Bash path is required on Windows",
"title": "Git Bashが必要です"
},
"found": {
@ -51,7 +53,9 @@
"invalidPath": "選択されたファイルは有効なGit Bash実行ファイルbash.exeではありません。",
"title": "Git Bash実行ファイルを選択"
},
"success": "Git Bashが正常に検出されました"
"placeholder": "[to be translated]:Select bash.exe path",
"success": "Git Bashが正常に検出されました",
"tooltip": "[to be translated]:Git Bash is required to run agents on Windows. Install from git-scm.com if not available."
},
"input": {
"placeholder": "メッセージをここに入力し、{{key}}で送信 - @でパスを選択、/でコマンドを選択"

View File

@ -32,6 +32,7 @@
},
"gitBash": {
"autoDetected": "Usando Git Bash detectado automaticamente",
"autoDiscoveredHint": "[to be translated]:Auto-discovered",
"clear": {
"button": "Limpar caminho personalizado"
},
@ -39,6 +40,7 @@
"error": {
"description": "O Git Bash é necessário para executar agentes no Windows. O agente não pode funcionar sem ele. Por favor, instale o Git para Windows a partir de",
"recheck": "Reverificar a Instalação do Git Bash",
"required": "[to be translated]:Git Bash path is required on Windows",
"title": "Git Bash Necessário"
},
"found": {
@ -51,7 +53,9 @@
"invalidPath": "O arquivo selecionado não é um executável válido do Git Bash (bash.exe).",
"title": "Selecionar executável do Git Bash"
},
"success": "Git Bash detectado com sucesso!"
"placeholder": "[to be translated]:Select bash.exe path",
"success": "Git Bash detectado com sucesso!",
"tooltip": "[to be translated]:Git Bash is required to run agents on Windows. Install from git-scm.com if not available."
},
"input": {
"placeholder": "Digite sua mensagem aqui, envie com {{key}} - @ selecionar caminho, / selecionar comando"

View File

@ -32,6 +32,7 @@
},
"gitBash": {
"autoDetected": "Используется автоматически обнаруженный Git Bash",
"autoDiscoveredHint": "[to be translated]:Auto-discovered",
"clear": {
"button": "Очистить пользовательский путь"
},
@ -39,6 +40,7 @@
"error": {
"description": "Для запуска агентов в Windows требуется Git Bash. Без него агент не может работать. Пожалуйста, установите Git для Windows с",
"recheck": "Повторная проверка установки Git Bash",
"required": "[to be translated]:Git Bash path is required on Windows",
"title": "Требуется Git Bash"
},
"found": {
@ -51,7 +53,9 @@
"invalidPath": "Выбранный файл не является допустимым исполняемым файлом Git Bash (bash.exe).",
"title": "Выберите исполняемый файл Git Bash"
},
"success": "Git Bash успешно обнаружен!"
"placeholder": "[to be translated]:Select bash.exe path",
"success": "Git Bash успешно обнаружен!",
"tooltip": "[to be translated]:Git Bash is required to run agents on Windows. Install from git-scm.com if not available."
},
"input": {
"placeholder": "Введите ваше сообщение здесь, отправьте с помощью {{key}} — @ выбрать путь, / выбрать команду"