fix(windows): improve Git Bash detection for portable installations (#11671)

* fix(windows): improve Git Bash detection for portable installations

Enhance Git Bash detection on Windows to support portable Git installations
and custom installation paths. The previous implementation only checked fixed
paths and failed to detect Git when installed to custom locations or added
to PATH manually.

Key improvements:
- Use where.exe to find git.exe in PATH and derive bash.exe location
- Support CHERRY_STUDIO_GIT_BASH_PATH environment variable override
- Add security check to skip executables in current directory
- Implement three-tier fallback strategy (env var -> git derivation -> common paths)
- Add detailed logging for troubleshooting

This fixes the issue where users with portable Git installations could run
git.exe from command line but the app failed to detect Git Bash.

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

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

* fix(windows): improve Git Bash detection for portable installations

Enhance Git Bash detection on Windows to support portable Git installations
and custom installation paths. The previous implementation only checked fixed
paths and failed to detect Git when installed to custom locations or added
to PATH manually.

Key improvements:
- Move findExecutable and findGitBash to utils/process.ts for better code organization
- Use where.exe to find git.exe in PATH and derive bash.exe location
- Add security check to skip executables in current directory
- Implement two-tier fallback strategy (git derivation -> common paths)
- Add detailed logging for troubleshooting
- Remove environment variable override to simplify implementation

This fixes the issue where users with portable Git installations could run
git.exe from command line but the app failed to detect Git Bash.

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

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

* fix(windows): improve Git Bash detection for portable installations

Enhance Git Bash detection on Windows to support portable Git installations
and custom installation paths. The previous implementation only checked fixed
paths and failed to detect Git when installed to custom locations or added
to PATH manually.

Key improvements:
- Move findExecutable and findGitBash to utils/process.ts for better code organization
- Use where.exe to find git.exe in PATH and derive bash.exe location
- Add security check to skip executables in current directory
- Implement two-tier fallback strategy (git derivation -> common paths)
- Add detailed logging for troubleshooting
- Remove environment variable override to simplify implementation

This fixes the issue where users with portable Git installations could run
git.exe from command line but the app failed to detect Git Bash.

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

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

* update iswin

* test: add comprehensive test coverage for findExecutable and findGitBash

Add 33 test cases covering:
- Git found in common paths (Program Files, Program Files (x86))
- Git found via where.exe in PATH
- Windows/Unix line ending handling (CRLF/LF)
- Whitespace trimming from where.exe output
- Security checks to skip executables in current directory
- Multiple Git installation structures (Standard, Portable, MSYS2)
- Bash.exe path derivation from git.exe location
- Common paths fallback when git.exe not found
- LOCALAPPDATA environment variable handling
- Priority order (derivation over common paths)
- Error scenarios (Git not installed, bash.exe missing)
- Real-world scenarios (official installer, portable, Scoop)

All tests pass with proper mocking of fs, path, and child_process modules.

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

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

* refactor: clarify path navigation comments in findGitBash

Replace confusing arrow notation showing intermediate directories with
clearer descriptions of the navigation intent:
- "navigate up 2 levels" instead of showing "-> Git/cmd -> Git ->"
- "bash.exe in same directory" for portable installations
- Emphasizes the intent rather than the intermediate steps

Makes the code more maintainable by clearly stating what each path
pattern is checking for.

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

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

* test: skip process utility tests on non-Windows platforms

Use describe.skipIf to skip all tests when not on Windows since
findExecutable and findGitBash have platform guards that return null
on non-Windows systems. Remove redundant platform mocking in nested
describe blocks since the entire suite is already Windows-only.

This fixes test failures on macOS and Linux where all 33 tests were
failing because the functions correctly return null on those platforms.

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

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

* format

* fix: improve Git Bash detection error handling and logging

- Add try-catch wrapper in IPC handler to handle unexpected errors
- Fix inaccurate comment: usr/bin/bash.exe is for MSYS2, not Git 2.x
- Change log level from INFO to DEBUG for internal "not found" message
- Keep WARN level only in IPC handler for user-facing message

🤖 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-07 16:42:00 +08:00 committed by GitHub
parent 8f39ecf762
commit ebfc60b039
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 701 additions and 26 deletions

View File

@ -6,7 +6,7 @@ import { loggerService } from '@logger'
import { isLinux, isMac, isPortable, isWin } from '@main/constant' import { isLinux, isMac, isPortable, isWin } from '@main/constant'
import { generateSignature } from '@main/integration/cherryai' import { generateSignature } from '@main/integration/cherryai'
import anthropicService from '@main/services/AnthropicService' import anthropicService from '@main/services/AnthropicService'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' import { findGitBash, getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
import { handleZoomFactor } from '@main/utils/zoom' import { handleZoomFactor } from '@main/utils/zoom'
import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import type { UpgradeChannel } from '@shared/config/constant' import type { UpgradeChannel } from '@shared/config/constant'
@ -499,35 +499,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
} }
try { try {
// Check common Git Bash installation paths const bashPath = findGitBash()
const commonPaths = [
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'),
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'),
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'bin', 'bash.exe')
]
// Check if any of the common paths exist if (bashPath) {
for (const bashPath of commonPaths) { logger.info('Git Bash is available', { path: bashPath })
if (fs.existsSync(bashPath)) {
logger.debug('Git Bash found', { path: bashPath })
return true
}
}
// Check if git is in PATH
const { execSync } = require('child_process')
try {
execSync('git --version', { stdio: 'ignore' })
logger.debug('Git found in PATH')
return true return true
} catch {
// Git not in PATH
} }
logger.debug('Git Bash not found on Windows system') logger.warn('Git Bash not found. Please install Git for Windows from https://git-scm.com/downloads/win')
return false return false
} catch (error) { } catch (error) {
logger.error('Error checking Git Bash', error as Error) logger.error('Unexpected error checking Git Bash', error as Error)
return false return false
} }
}) })

View File

@ -0,0 +1,572 @@
import { execFileSync } from 'child_process'
import fs from 'fs'
import path from 'path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { findExecutable, findGitBash } from '../process'
// Mock dependencies
vi.mock('child_process')
vi.mock('fs')
vi.mock('path')
// These tests only run on Windows since the functions have platform guards
describe.skipIf(process.platform !== 'win32')('process utilities', () => {
beforeEach(() => {
vi.clearAllMocks()
// Mock path.join to concatenate paths with backslashes (Windows-style)
vi.mocked(path.join).mockImplementation((...args) => args.join('\\'))
// Mock path.resolve to handle path resolution with .. support
vi.mocked(path.resolve).mockImplementation((...args) => {
let result = args.join('\\')
// Handle .. navigation
while (result.includes('\\..')) {
result = result.replace(/\\[^\\]+\\\.\./g, '')
}
// Ensure absolute path
if (!result.match(/^[A-Z]:/)) {
result = `C:\\cwd\\${result}`
}
return result
})
// Mock path.dirname
vi.mocked(path.dirname).mockImplementation((p) => {
const parts = p.split('\\')
parts.pop()
return parts.join('\\')
})
// Mock path.sep
Object.defineProperty(path, 'sep', { value: '\\', writable: true })
// Mock process.cwd()
vi.spyOn(process, 'cwd').mockReturnValue('C:\\cwd')
})
describe('findExecutable', () => {
describe('git common paths', () => {
it('should find git at Program Files path', () => {
const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe'
process.env.ProgramFiles = 'C:\\Program Files'
vi.mocked(fs.existsSync).mockImplementation((p) => p === gitPath)
const result = findExecutable('git')
expect(result).toBe(gitPath)
expect(fs.existsSync).toHaveBeenCalledWith(gitPath)
})
it('should find git at Program Files (x86) path', () => {
const gitPath = 'C:\\Program Files (x86)\\Git\\cmd\\git.exe'
process.env['ProgramFiles(x86)'] = 'C:\\Program Files (x86)'
vi.mocked(fs.existsSync).mockImplementation((p) => p === gitPath)
const result = findExecutable('git')
expect(result).toBe(gitPath)
expect(fs.existsSync).toHaveBeenCalledWith(gitPath)
})
it('should use fallback paths when environment variables are not set', () => {
delete process.env.ProgramFiles
delete process.env['ProgramFiles(x86)']
const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe'
vi.mocked(fs.existsSync).mockImplementation((p) => p === gitPath)
const result = findExecutable('git')
expect(result).toBe(gitPath)
})
})
describe('where.exe PATH lookup', () => {
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'win32', writable: true })
// Common paths don't exist
vi.mocked(fs.existsSync).mockReturnValue(false)
})
it('should find executable via where.exe', () => {
const gitPath = 'C:\\Git\\bin\\git.exe'
vi.mocked(execFileSync).mockReturnValue(gitPath)
const result = findExecutable('git')
expect(result).toBe(gitPath)
expect(execFileSync).toHaveBeenCalledWith('where.exe', ['git.exe'], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
})
})
it('should add .exe extension when calling where.exe', () => {
vi.mocked(execFileSync).mockImplementation(() => {
throw new Error('Not found')
})
findExecutable('node')
expect(execFileSync).toHaveBeenCalledWith('where.exe', ['node.exe'], expect.any(Object))
})
it('should handle Windows line endings (CRLF)', () => {
const gitPath1 = 'C:\\Git\\bin\\git.exe'
const gitPath2 = 'C:\\Program Files\\Git\\cmd\\git.exe'
vi.mocked(execFileSync).mockReturnValue(`${gitPath1}\r\n${gitPath2}\r\n`)
const result = findExecutable('git')
// Should return the first valid path
expect(result).toBe(gitPath1)
})
it('should handle Unix line endings (LF)', () => {
const gitPath1 = 'C:\\Git\\bin\\git.exe'
const gitPath2 = 'C:\\Program Files\\Git\\cmd\\git.exe'
vi.mocked(execFileSync).mockReturnValue(`${gitPath1}\n${gitPath2}\n`)
const result = findExecutable('git')
expect(result).toBe(gitPath1)
})
it('should handle mixed line endings', () => {
const gitPath1 = 'C:\\Git\\bin\\git.exe'
const gitPath2 = 'C:\\Program Files\\Git\\cmd\\git.exe'
vi.mocked(execFileSync).mockReturnValue(`${gitPath1}\r\n${gitPath2}\n`)
const result = findExecutable('git')
expect(result).toBe(gitPath1)
})
it('should trim whitespace from paths', () => {
const gitPath = 'C:\\Git\\bin\\git.exe'
vi.mocked(execFileSync).mockReturnValue(` ${gitPath} \n`)
const result = findExecutable('git')
expect(result).toBe(gitPath)
})
it('should filter empty lines', () => {
const gitPath = 'C:\\Git\\bin\\git.exe'
vi.mocked(execFileSync).mockReturnValue(`\n\n${gitPath}\n\n`)
const result = findExecutable('git')
expect(result).toBe(gitPath)
})
})
describe('security checks', () => {
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'win32', writable: true })
vi.mocked(fs.existsSync).mockReturnValue(false)
})
it('should skip executables in current directory', () => {
const maliciousPath = 'C:\\cwd\\git.exe'
const safePath = 'C:\\Git\\bin\\git.exe'
vi.mocked(execFileSync).mockReturnValue(`${maliciousPath}\n${safePath}`)
vi.mocked(path.resolve).mockImplementation((p) => {
if (p.includes('cwd\\git.exe')) return 'c:\\cwd\\git.exe'
return 'c:\\git\\bin\\git.exe'
})
vi.mocked(path.dirname).mockImplementation((p) => {
if (p.includes('cwd\\git.exe')) return 'c:\\cwd'
return 'c:\\git\\bin'
})
const result = findExecutable('git')
// Should skip malicious path and return safe path
expect(result).toBe(safePath)
})
it('should skip executables in current directory subdirectories', () => {
const maliciousPath = 'C:\\cwd\\subdir\\git.exe'
const safePath = 'C:\\Git\\bin\\git.exe'
vi.mocked(execFileSync).mockReturnValue(`${maliciousPath}\n${safePath}`)
vi.mocked(path.resolve).mockImplementation((p) => {
if (p.includes('cwd\\subdir')) return 'c:\\cwd\\subdir\\git.exe'
return 'c:\\git\\bin\\git.exe'
})
vi.mocked(path.dirname).mockImplementation((p) => {
if (p.includes('cwd\\subdir')) return 'c:\\cwd\\subdir'
return 'c:\\git\\bin'
})
const result = findExecutable('git')
expect(result).toBe(safePath)
})
it('should return null when only malicious executables are found', () => {
const maliciousPath = 'C:\\cwd\\git.exe'
vi.mocked(execFileSync).mockReturnValue(maliciousPath)
vi.mocked(path.resolve).mockReturnValue('c:\\cwd\\git.exe')
vi.mocked(path.dirname).mockReturnValue('c:\\cwd')
const result = findExecutable('git')
expect(result).toBeNull()
})
})
describe('error handling', () => {
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'win32', writable: true })
vi.mocked(fs.existsSync).mockReturnValue(false)
})
it('should return null when where.exe fails', () => {
vi.mocked(execFileSync).mockImplementation(() => {
throw new Error('Command failed')
})
const result = findExecutable('nonexistent')
expect(result).toBeNull()
})
it('should return null when where.exe returns empty output', () => {
vi.mocked(execFileSync).mockReturnValue('')
const result = findExecutable('git')
expect(result).toBeNull()
})
it('should return null when where.exe returns only whitespace', () => {
vi.mocked(execFileSync).mockReturnValue(' \n\n ')
const result = findExecutable('git')
expect(result).toBeNull()
})
})
describe('non-git executables', () => {
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'win32', writable: true })
})
it('should skip common paths check for non-git executables', () => {
const nodePath = 'C:\\Program Files\\nodejs\\node.exe'
vi.mocked(execFileSync).mockReturnValue(nodePath)
const result = findExecutable('node')
expect(result).toBe(nodePath)
// Should not check common Git paths
expect(fs.existsSync).not.toHaveBeenCalledWith(expect.stringContaining('Git\\cmd\\node.exe'))
})
})
})
describe('findGitBash', () => {
describe('git.exe path derivation', () => {
it('should derive bash.exe from standard Git installation (Git/cmd/git.exe)', () => {
const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe'
const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe'
// findExecutable will find git at common path
process.env.ProgramFiles = 'C:\\Program Files'
vi.mocked(fs.existsSync).mockImplementation((p) => {
return p === gitPath || p === bashPath
})
const result = findGitBash()
expect(result).toBe(bashPath)
})
it('should derive bash.exe from portable Git installation (Git/bin/git.exe)', () => {
const gitPath = 'C:\\PortableGit\\bin\\git.exe'
const bashPath = 'C:\\PortableGit\\bin\\bash.exe'
// Mock: common git paths don't exist, but where.exe finds portable git
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.exe exists at Git/bin/bash.exe (second path in possibleBashPaths)
if (pathStr === bashPath) return true
return false
})
// where.exe returns portable git path
vi.mocked(execFileSync).mockReturnValue(gitPath)
const result = findGitBash()
expect(result).toBe(bashPath)
})
it('should derive bash.exe from MSYS2 Git installation (Git/usr/bin/bash.exe)', () => {
const gitPath = 'C:\\msys64\\usr\\bin\\git.exe'
const bashPath = 'C:\\msys64\\usr\\bin\\bash.exe'
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
// MSYS2 bash.exe exists at usr/bin/bash.exe (third path in possibleBashPaths)
if (pathStr === bashPath) return true
return false
})
vi.mocked(execFileSync).mockReturnValue(gitPath)
const result = findGitBash()
expect(result).toBe(bashPath)
})
it('should try multiple bash.exe locations in order', () => {
const gitPath = 'C:\\Git\\cmd\\git.exe'
const bashPath = 'C:\\Git\\bin\\bash.exe'
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
// Standard path exists (first in possibleBashPaths)
if (pathStr === bashPath) return true
return false
})
vi.mocked(execFileSync).mockReturnValue(gitPath)
const result = findGitBash()
expect(result).toBe(bashPath)
})
it('should handle when git.exe is found but bash.exe is not at any derived location', () => {
const gitPath = 'C:\\Git\\cmd\\git.exe'
// git.exe exists via where.exe, but bash.exe doesn't exist at any derived location
vi.mocked(fs.existsSync).mockImplementation(() => {
// Only return false for all bash.exe checks
return false
})
vi.mocked(execFileSync).mockReturnValue(gitPath)
const result = findGitBash()
// Should fall back to common paths check
expect(result).toBeNull()
})
})
describe('common paths fallback', () => {
beforeEach(() => {
// git.exe not found
vi.mocked(execFileSync).mockImplementation(() => {
throw new Error('Not found')
})
})
it('should check Program Files path', () => {
const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe'
process.env.ProgramFiles = 'C:\\Program Files'
vi.mocked(fs.existsSync).mockImplementation((p) => p === bashPath)
const result = findGitBash()
expect(result).toBe(bashPath)
})
it('should check Program Files (x86) path', () => {
const bashPath = 'C:\\Program Files (x86)\\Git\\bin\\bash.exe'
process.env['ProgramFiles(x86)'] = 'C:\\Program Files (x86)'
vi.mocked(fs.existsSync).mockImplementation((p) => p === bashPath)
const result = findGitBash()
expect(result).toBe(bashPath)
})
it('should check LOCALAPPDATA path', () => {
const bashPath = 'C:\\Users\\User\\AppData\\Local\\Programs\\Git\\bin\\bash.exe'
process.env.LOCALAPPDATA = 'C:\\Users\\User\\AppData\\Local'
vi.mocked(fs.existsSync).mockImplementation((p) => p === bashPath)
const result = findGitBash()
expect(result).toBe(bashPath)
})
it('should skip LOCALAPPDATA check when environment variable is not set', () => {
delete process.env.LOCALAPPDATA
vi.mocked(fs.existsSync).mockReturnValue(false)
const result = findGitBash()
expect(result).toBeNull()
// Should not check invalid path with empty LOCALAPPDATA
expect(fs.existsSync).not.toHaveBeenCalledWith(expect.stringContaining('undefined'))
})
it('should use fallback values when environment variables are not set', () => {
delete process.env.ProgramFiles
delete process.env['ProgramFiles(x86)']
const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe'
vi.mocked(fs.existsSync).mockImplementation((p) => p === bashPath)
const result = findGitBash()
expect(result).toBe(bashPath)
})
})
describe('priority order', () => {
it('should prioritize git.exe derivation over common paths', () => {
const gitPath = 'C:\\CustomPath\\Git\\cmd\\git.exe'
const derivedBashPath = 'C:\\CustomPath\\Git\\bin\\bash.exe'
const commonBashPath = 'C:\\Program Files\\Git\\bin\\bash.exe'
// Both exist
vi.mocked(fs.existsSync).mockImplementation((p) => {
const pathStr = p?.toString() || ''
// Common git paths don't exist (so findExecutable uses where.exe)
if (pathStr.includes('Program Files\\Git\\cmd\\git.exe')) return false
if (pathStr.includes('Program Files (x86)\\Git\\cmd\\git.exe')) return false
// Both bash paths exist, but derived should be checked first
if (pathStr === derivedBashPath) return true
if (pathStr === commonBashPath) return true
return false
})
vi.mocked(execFileSync).mockReturnValue(gitPath)
const result = findGitBash()
// Should return derived path, not common path
expect(result).toBe(derivedBashPath)
})
})
describe('error scenarios', () => {
it('should return null when Git is not installed anywhere', () => {
vi.mocked(fs.existsSync).mockReturnValue(false)
vi.mocked(execFileSync).mockImplementation(() => {
throw new Error('Not found')
})
const result = findGitBash()
expect(result).toBeNull()
})
it('should return null when git.exe exists but bash.exe does not', () => {
const gitPath = 'C:\\Git\\cmd\\git.exe'
vi.mocked(fs.existsSync).mockImplementation((p) => {
// git.exe exists, but no bash.exe anywhere
return p === gitPath
})
vi.mocked(execFileSync).mockReturnValue(gitPath)
const result = findGitBash()
expect(result).toBeNull()
})
})
describe('real-world scenarios', () => {
it('should handle official Git for Windows installer', () => {
const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe'
const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe'
process.env.ProgramFiles = 'C:\\Program Files'
vi.mocked(fs.existsSync).mockImplementation((p) => {
return p === gitPath || p === bashPath
})
const result = findGitBash()
expect(result).toBe(bashPath)
})
it('should handle portable Git installation in custom directory', () => {
const gitPath = 'D:\\DevTools\\PortableGit\\bin\\git.exe'
const bashPath = 'D:\\DevTools\\PortableGit\\bin\\bash.exe'
vi.mocked(fs.existsSync).mockImplementation((p) => {
const pathStr = p?.toString() || ''
// Common 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 Git paths exist (portable uses second path: Git/bin/bash.exe)
if (pathStr === bashPath) return true
return false
})
vi.mocked(execFileSync).mockReturnValue(gitPath)
const result = findGitBash()
expect(result).toBe(bashPath)
})
it('should handle Git installed via Scoop', () => {
// Scoop typically installs to %USERPROFILE%\scoop\apps\git\current
const gitPath = 'C:\\Users\\User\\scoop\\apps\\git\\current\\cmd\\git.exe'
const bashPath = 'C:\\Users\\User\\scoop\\apps\\git\\current\\bin\\bash.exe'
vi.mocked(fs.existsSync).mockImplementation((p) => {
const pathStr = p?.toString() || ''
// Common 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
// Scoop bash path exists (standard structure: cmd -> bin)
if (pathStr === bashPath) return true
return false
})
vi.mocked(execFileSync).mockReturnValue(gitPath)
const result = findGitBash()
expect(result).toBe(bashPath)
})
})
})
})

View File

@ -1,10 +1,11 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { HOME_CHERRY_DIR } from '@shared/config/constant' import { HOME_CHERRY_DIR } from '@shared/config/constant'
import { spawn } from 'child_process' import { execFileSync, spawn } from 'child_process'
import fs from 'fs' import fs from 'fs'
import os from 'os' import os from 'os'
import path from 'path' import path from 'path'
import { isWin } from '../constant'
import { getResourcePath } from '.' import { getResourcePath } from '.'
const logger = loggerService.withContext('Utils:Process') const logger = loggerService.withContext('Utils:Process')
@ -39,7 +40,7 @@ export function runInstallScript(scriptPath: string): Promise<void> {
} }
export async function getBinaryName(name: string): Promise<string> { export async function getBinaryName(name: string): Promise<string> {
if (process.platform === 'win32') { if (isWin) {
return `${name}.exe` return `${name}.exe`
} }
return name return name
@ -60,3 +61,123 @@ export async function isBinaryExists(name: string): Promise<boolean> {
const cmd = await getBinaryPath(name) const cmd = await getBinaryPath(name)
return await fs.existsSync(cmd) return await fs.existsSync(cmd)
} }
/**
* Find executable in common paths or PATH environment variable
* Based on Claude Code's implementation with security checks
* @param name - Name of the executable to find (without .exe extension)
* @returns Full path to the executable or null if not found
*/
export function findExecutable(name: string): string | null {
// This implementation uses where.exe which is Windows-only
if (!isWin) {
return null
}
// Special handling for git - check common installation paths first
if (name === 'git') {
const commonGitPaths = [
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'cmd', 'git.exe'),
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'cmd', 'git.exe')
]
for (const gitPath of commonGitPaths) {
if (fs.existsSync(gitPath)) {
logger.debug(`Found ${name} at common path`, { path: gitPath })
return gitPath
}
}
}
// Use where.exe to find executable in PATH
// Use execFileSync to prevent command injection
try {
// Add .exe extension for more precise matching on Windows
const executableName = `${name}.exe`
const result = execFileSync('where.exe', [executableName], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
})
// Handle both Windows (\r\n) and Unix (\n) line endings
const paths = result.trim().split(/\r?\n/).filter(Boolean)
const currentDir = process.cwd().toLowerCase()
// Security check: skip executables in current directory
for (const exePath of paths) {
// Trim whitespace from where.exe output
const cleanPath = exePath.trim()
const resolvedPath = path.resolve(cleanPath).toLowerCase()
const execDir = path.dirname(resolvedPath).toLowerCase()
// Skip if in current directory or subdirectory (potential malware)
if (execDir === currentDir || execDir.startsWith(currentDir + path.sep)) {
logger.warn('Skipping potentially malicious executable in current directory', {
path: cleanPath
})
continue
}
logger.debug(`Found ${name} via where.exe`, { path: cleanPath })
return cleanPath
}
return null
} catch (error) {
logger.debug(`where.exe ${name} failed`, { error })
return null
}
}
/**
* Find Git Bash executable on Windows
* @returns Full path to bash.exe or null if not found
*/
export function findGitBash(): string | null {
// Git Bash is Windows-only
if (!isWin) {
return null
}
// 1. Find git.exe and derive bash.exe path
const gitPath = findExecutable('git')
if (gitPath) {
// Try multiple possible locations for bash.exe relative to git.exe
// Different Git installations have different directory structures
const possibleBashPaths = [
path.join(gitPath, '..', '..', 'bin', 'bash.exe'), // Standard Git: git.exe at Git/cmd/ -> navigate up 2 levels -> then bin/bash.exe
path.join(gitPath, '..', 'bash.exe'), // Portable Git: git.exe at Git/bin/ -> bash.exe in same directory
path.join(gitPath, '..', '..', 'usr', 'bin', 'bash.exe') // MSYS2 Git: git.exe at msys64/usr/bin/ -> navigate up 2 levels -> then usr/bin/bash.exe
]
for (const bashPath of possibleBashPaths) {
const resolvedBashPath = path.resolve(bashPath)
if (fs.existsSync(resolvedBashPath)) {
logger.debug('Found bash.exe via git.exe path derivation', { path: resolvedBashPath })
return resolvedBashPath
}
}
logger.debug('bash.exe not found at expected locations relative to git.exe', {
gitPath,
checkedPaths: possibleBashPaths.map((p) => path.resolve(p))
})
}
// 2. Fallback: check common Git Bash paths directly
const commonBashPaths = [
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'),
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'),
...(process.env.LOCALAPPDATA ? [path.join(process.env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe')] : [])
]
for (const bashPath of commonBashPaths) {
if (fs.existsSync(bashPath)) {
logger.debug('Found bash.exe at common path', { path: bashPath })
return bashPath
}
}
logger.debug('Git Bash not found - checked git derivation and common paths')
return null
}