From 6b278eeccc496fbf46866c362d79bc7ef9f379b8 Mon Sep 17 00:00:00 2001 From: seamusmore Date: Tue, 14 Jul 2026 10:36:13 +0800 Subject: [PATCH] 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. --- apps/desktop/electron/find-git-bash.test.ts | 51 +++++++++++++++++++ apps/desktop/electron/find-git-bash.ts | 55 +++++++++++++++++++++ apps/desktop/electron/main.ts | 51 ++++--------------- 3 files changed, 116 insertions(+), 41 deletions(-) create mode 100644 apps/desktop/electron/find-git-bash.test.ts create mode 100644 apps/desktop/electron/find-git-bash.ts diff --git a/apps/desktop/electron/find-git-bash.test.ts b/apps/desktop/electron/find-git-bash.test.ts new file mode 100644 index 000000000000..a8aa28ffb87e --- /dev/null +++ b/apps/desktop/electron/find-git-bash.test.ts @@ -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') +}) diff --git a/apps/desktop/electron/find-git-bash.ts b/apps/desktop/electron/find-git-bash.ts new file mode 100644 index 000000000000..1e525aebc563 --- /dev/null +++ b/apps/desktop/electron/find-git-bash.ts @@ -0,0 +1,55 @@ +import path from 'node:path' + +export interface GitBashOptions { + isWindows: boolean + env: Record + 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 +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 3856cd2abc22..c448e3ac0318 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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) {