fix(desktop): respect HERMES_GIT_BASH_PATH in findGitBash()

Port the HERMES_GIT_BASH_PATH env var check from main.cjs to main.ts
after the TS conversion. Also extract findGitBash to a dedicated module
for testability and add focused regression tests for override precedence
and invalid-override fallback.
This commit is contained in:
seamusmore 2026-07-14 10:36:13 +08:00 committed by Teknium
parent 4276fe8ded
commit 6b278eeccc
3 changed files with 116 additions and 41 deletions

View file

@ -0,0 +1,51 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { findGitBash } from './find-git-bash'
const yes = () => true
const no = () => false
test('HERMES_GIT_BASH_PATH override takes precedence', () => {
const result = findGitBash({
isWindows: true,
env: { HERMES_GIT_BASH_PATH: 'D:\\CustomGit\\bin\\bash.exe' },
fileExists: yes,
findOnPath: () => null
})
assert.equal(result, 'D:\\CustomGit\\bin\\bash.exe')
})
test('HERMES_GIT_BASH_PATH invalid path falls through to candidates', () => {
const env = {
HERMES_GIT_BASH_PATH: 'X:\\Missing\\bash.exe',
LOCALAPPDATA: 'C:\\Users\\test\\AppData\\Local',
ProgramFiles: 'C:\\Program Files',
'ProgramFiles(x86)': 'C:\\Program Files (x86)'
}
const fileExists = (p: string) => p !== 'X:\\Missing\\bash.exe' && p.includes('Program Files\\Git\\bin\\bash.exe')
const result = findGitBash({ isWindows: true, env, fileExists, findOnPath: () => null })
assert.equal(result, 'C:\\Program Files\\Git\\bin\\bash.exe')
})
test('HERMES_GIT_BASH_PATH empty string is ignored', () => {
const result = findGitBash({
isWindows: true,
env: { HERMES_GIT_BASH_PATH: '', LOCALAPPDATA: '' },
fileExists: no,
findOnPath: () => 'C:\\msys64\\usr\\bin\\bash.exe'
})
assert.equal(result, 'C:\\msys64\\usr\\bin\\bash.exe')
})
test('non-Windows uses findOnPath', () => {
const result = findGitBash({
isWindows: false,
env: {},
fileExists: no,
findOnPath: () => '/usr/bin/bash'
})
assert.equal(result, '/usr/bin/bash')
})

View file

@ -0,0 +1,55 @@
import path from 'node:path'
export interface GitBashOptions {
isWindows: boolean
env: Record<string, string | undefined>
fileExists: (filePath: string) => boolean
findOnPath?: (command: string) => string | null
}
/**
* Locate bash.exe on Windows.
* Resolution order (first match wins):
* 1. HERMES_GIT_BASH_PATH env var override
* 2. PortableGit under %LOCALAPPDATA%\hermes\git\ (install.ps1)
* 3. Standard Git for Windows install locations
* 4. %LOCALAPPDATA%\Programs\Git\ (user-scoped)
* 5. bash on PATH
*/
export function findGitBash(opts: GitBashOptions): string | null {
const { isWindows, env, fileExists, findOnPath } = opts
if (!isWindows) {
return findOnPath ? findOnPath('bash') : null
}
// Respect HERMES_GIT_BASH_PATH if set (mirrors tools/environments/local.py:_find_bash).
const gitBashPath = env.HERMES_GIT_BASH_PATH
if (gitBashPath && fileExists(gitBashPath)) return gitBashPath
const localAppData = env.LOCALAPPDATA || ''
const candidates: string[] = []
if (localAppData) {
candidates.push(path.join(localAppData, 'hermes', 'git', 'bin', 'bash.exe'))
candidates.push(path.join(localAppData, 'hermes', 'git', 'usr', 'bin', 'bash.exe'))
}
candidates.push(path.join(env['ProgramFiles'] || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'))
candidates.push(path.join(env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'))
if (localAppData) {
candidates.push(path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'))
}
for (const candidate of candidates) {
if (fileExists(candidate)) return candidate
}
if (findOnPath) {
const onPath = findOnPath('bash')
if (onPath) return onPath
}
return null
}

View file

@ -78,6 +78,7 @@ import {
} from './desktop-uninstall'
import { installEmbedReferer } from './embed-referer'
import { createEventDeduper } from './event-dedupe'
import { findGitBash as _findGitBash } from './find-git-bash'
import { readDirForIpc } from './fs-read-dir'
import { probeGatewayWebSocket } from './gateway-ws-probe'
import { scanGitRepos } from './git-repo-scan'
@ -1915,48 +1916,16 @@ function findSystemPython() {
return null
}
// findGitBash — locate bash.exe on Windows. Hermes' terminal tool requires
// bash (POSIX shell), and on Windows that's almost always Git for Windows'
// bundled Git Bash. We check the same set of locations tools/environments/
// local.py:_find_bash() checks at runtime, so a positive result here means
// the agent will be able to start a terminal too.
//
// On non-Windows hosts bash is part of the OS and this just returns the
// first bash on PATH.
// findGitBash — locate bash.exe on Windows. Resolves HERMES_GIT_BASH_PATH
// first (mirrors tools/environments/local.py:_find_bash), then PortableGit,
// standard install locations, and finally PATH.
function findGitBash() {
if (!IS_WINDOWS) {
return findOnPath('bash')
}
// install.ps1 drops PortableGit at %LOCALAPPDATA%\hermes\git\... — checked
// first so users who installed via install.ps1 are detected before we
// start probing system-wide locations.
const localAppData = process.env.LOCALAPPDATA || ''
const candidates = []
if (localAppData) {
candidates.push(path.join(localAppData, 'hermes', 'git', 'bin', 'bash.exe'))
candidates.push(path.join(localAppData, 'hermes', 'git', 'usr', 'bin', 'bash.exe'))
}
// Standard Git for Windows install locations.
candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'))
candidates.push(path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'))
if (localAppData) {
candidates.push(path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'))
}
for (const candidate of candidates) {
if (fileExists(candidate)) {
return candidate
}
}
// Last resort — bash on PATH (covers WSL bash, MSYS2, custom installs).
// On WSL hosts findOnPath itself filters out Windows-binary paths via
// isWindowsBinaryPathInWsl, so we won't hand back a wsl.exe shim either.
return findOnPath('bash')
return _findGitBash({
isWindows: IS_WINDOWS,
env: process.env,
fileExists,
findOnPath
})
}
function getVenvPython(venvRoot) {