From 3b62b123e68cc4543bfa8fa376c7e8a24fee0afb Mon Sep 17 00:00:00 2001 From: 7Sageer <12210216@mail.sustech.edu.cn> Date: Mon, 8 Jun 2026 14:00:11 +0800 Subject: [PATCH] fix: detect Scoop Git Bash on Windows (#529) * fix: detect Scoop Git Bash on Windows * fix: harden Git Bash shim detection * fix(kaos): preserve Git Bash PATH priority --- .changeset/detect-scoop-git-bash.md | 6 + docs/en/configuration/env-vars.md | 4 +- docs/zh/configuration/env-vars.md | 4 +- packages/kaos/src/environment.ts | 165 ++++++++++++++++++++----- packages/kaos/test/environment.test.ts | 158 +++++++++++++++++++++-- 5 files changed, 292 insertions(+), 45 deletions(-) create mode 100644 .changeset/detect-scoop-git-bash.md diff --git a/.changeset/detect-scoop-git-bash.md b/.changeset/detect-scoop-git-bash.md new file mode 100644 index 000000000..4b9234e76 --- /dev/null +++ b/.changeset/detect-scoop-git-bash.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kaos": patch +"@moonshot-ai/kimi-code": patch +--- + +Detect Git Bash installed through Scoop and other Git shims on Windows. diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 21917cc14..48472b2cb 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -155,13 +155,13 @@ The CLI also reads several standard system variables to detect the runtime envir - `HOME`: used to resolve the default data path - `VISUAL`, `EDITOR`: external editor command (`VISUAL` takes precedence) -- `PATH`: used to locate dependencies such as `rg` and `git` +- `PATH`: used to locate dependencies such as `rg` and `git`; on Windows, Git Bash detection checks each `git.exe` found on `PATH`, including package-manager shims such as Scoop - `NO_COLOR`, `FORCE_COLOR`: control color output (following the [no-color.org](https://no-color.org) convention) - `CI`: when non-empty and not `"0"`, disables theme detection and falls back to the dark theme - `TERM_PROGRAM`, `TERM`, `TMUX`: detect terminal features and notification support - `DISPLAY`, `WAYLAND_DISPLAY`, `XDG_SESSION_TYPE`: detect Linux graphical sessions (for clipboard and image features) - `WSL_DISTRO_NAME`, `WSLENV`: detect WSL for the clipboard PowerShell bridge -- `LOCALAPPDATA`: used on Windows when probing for the Git Bash installation path +- `LOCALAPPDATA`: used on Windows as a fallback when probing for the Git Bash installation path ## HTTP proxy diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 659ba3b7b..44bab884d 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -155,13 +155,13 @@ CLI 还会读取一些标准系统变量来检测运行环境,不会修改它 - `HOME`:解析默认数据路径 - `VISUAL`、`EDITOR`:外部编辑器命令(`VISUAL` 优先) -- `PATH`:定位 `rg`、`git` 等依赖 +- `PATH`:定位 `rg`、`git` 等依赖;在 Windows 上,Git Bash 探测会检查 `PATH` 中找到的每个 `git.exe`,包括 Scoop 等包管理器提供的 shim - `NO_COLOR`、`FORCE_COLOR`:控制颜色输出(遵循 [no-color.org](https://no-color.org) 约定) - `CI`:非空且非 `"0"` 时关闭主题检测,回退深色主题 - `TERM_PROGRAM`、`TERM`、`TMUX`:检测终端特性和通知支持 - `DISPLAY`、`WAYLAND_DISPLAY`、`XDG_SESSION_TYPE`:检测 Linux 图形会话(用于剪贴板和图片功能) - `WSL_DISTRO_NAME`、`WSLENV`:检测 WSL,用于剪贴板 PowerShell 桥接 -- `LOCALAPPDATA`:Windows 上探测 Git Bash 安装路径 +- `LOCALAPPDATA`:Windows 上探测 Git Bash 安装路径时作为 fallback 使用 ## HTTP 代理 diff --git a/packages/kaos/src/environment.ts b/packages/kaos/src/environment.ts index 84cb39980..9b08baff2 100644 --- a/packages/kaos/src/environment.ts +++ b/packages/kaos/src/environment.ts @@ -2,9 +2,9 @@ * Environment — cross-platform probe of OS / shell. * * Detection is a pure function of injected probes (`platform` / `arch` / - * `release` / `env` / `isFile` / `findExecutable`) so the same suite runs - * identically on any host OS. `detectEnvironmentFromNode()` bundles the - * Node defaults for production callers. + * `release` / `env` / `isFile` / `execFileText`) so the same suite runs + * identically on any host OS. `detectEnvironmentFromNode()` bundles the Node + * defaults for production callers. * * On Windows the probe expects Git Bash (the canonical POSIX shell that * ships with Git for Windows). If it cannot be located the function @@ -12,9 +12,11 @@ * user-facing install hint. Set `KIMI_SHELL_PATH` to override. */ +import { execFile as nodeExecFile } from 'node:child_process'; import { constants as fsConstants } from 'node:fs'; import { access } from 'node:fs/promises'; import * as nodeOs from 'node:os'; +import * as nodePath from 'node:path'; import { KaosShellNotFoundError } from './errors'; @@ -40,9 +42,15 @@ export interface EnvironmentDeps { readonly release: string; readonly env: Record; readonly isFile: (path: string) => Promise; - readonly findExecutable: (name: string) => Promise; + readonly execFileText: ( + file: string, + args: readonly string[], + timeoutMs: number, + ) => Promise; } +const GIT_EXEC_PATH_TIMEOUT_MS = 5_000; + function resolveOsKind(platform: string): OsKind { switch (platform) { case 'darwin': @@ -91,17 +99,34 @@ async function locateWindowsGitBash(deps: EnvironmentDeps): Promise { } } - const gitExe = await deps.findExecutable('git.exe'); - if (gitExe !== undefined) { - const inferred = inferGitBashFromGitExe(gitExe); + const gitExecutables = await findExecutablesOnPath( + 'git.exe', + deps.env['PATH'], + deps.platform, + deps.isFile, + ); + + for (const gitExe of gitExecutables) { + const inferred = gitBashCandidatesFromGitExe(gitExe); if (inferred !== undefined) { - for (const path of inferred) { - checked.push(path); - if (await deps.isFile(path)) { - return path; + for (const candidate of inferred) { + checked.push(candidate); + if (await deps.isFile(candidate)) { + return candidate; } } } + + const gitExecPath = await readGitExecPath(deps, gitExe); + if (gitExecPath === undefined) { + continue; + } + for (const candidate of gitBashCandidatesFromGitExecPath(gitExecPath)) { + checked.push(candidate); + if (await deps.isFile(candidate)) { + return candidate; + } + } } const candidates: string[] = [ @@ -127,24 +152,81 @@ async function locateWindowsGitBash(deps: EnvironmentDeps): Promise { ); } -// Most Git for Windows installs put `git.exe` in `\cmd\git.exe`, -// with bash at `\bin\bash.exe` (a wrapper) or `\usr\bin\bash.exe` -// (the real MSYS2 shell). Walk back to the parent of `cmd` / `bin` and -// return both candidates so the caller can try them in preference order. -function inferGitBashFromGitExe(gitExe: string): string[] | undefined { - const sep = gitExe.includes('\\') ? '\\' : '/'; - const parts = gitExe.split(sep); - for (let i = parts.length - 2; i >= 0; i -= 1) { - const segment = parts[i]; - if (segment === 'cmd' || segment === 'bin') { - const root = parts.slice(0, i).join(sep); - const prefix = root.length === 0 ? '' : `${root}${sep}`; - return [`${prefix}bin${sep}bash.exe`, `${prefix}usr${sep}bin${sep}bash.exe`]; +async function readGitExecPath( + deps: EnvironmentDeps, + gitExe: string, +): Promise { + if (deps.platform === 'win32' && !isAbsoluteWindowsPath(gitExe)) return undefined; + + const stdout = await deps.execFileText(gitExe, ['--exec-path'], GIT_EXEC_PATH_TIMEOUT_MS); + if (stdout === undefined) return undefined; + + for (const line of stdout.split(/\r?\n/)) { + const execPath = line.trim(); + if (execPath.length > 0) { + return execPath; } } return undefined; } +// Most Git for Windows installs put `git.exe` in `\cmd\git.exe`, +// with bash at `\bin\bash.exe`. Portable installs sometimes put +// both in `\bin\`. Only infer from those anchored layouts; package +// manager shims live elsewhere and must resolve through `git --exec-path`. +function gitBashCandidatesFromGitExe(gitExe: string): readonly string[] | undefined { + const normalizedGitExe = nodePath.win32.normalize(normalizeWindowsPath(gitExe)); + const gitDir = nodePath.win32.dirname(normalizedGitExe); + const gitDirName = nodePath.win32.basename(gitDir).toLowerCase(); + if (gitDirName !== 'cmd' && gitDirName !== 'bin') { + return undefined; + } + return gitBashCandidatesFromGitRoot(nodePath.win32.dirname(gitDir)); +} + +function gitBashCandidatesFromGitExecPath(execPath: string): readonly string[] { + const normalized = nodePath.win32.normalize(normalizeWindowsPath(execPath)); + const parts = normalized.split('\\'); + for (let i = parts.length - 1; i >= 0; i -= 1) { + const segment = parts[i]?.toLowerCase(); + if (segment === 'mingw32' || segment === 'mingw64') { + const root = parts.slice(0, i).join('\\'); + if (root.length > 0) { + return gitBashCandidatesFromGitRoot(root); + } + } + } + + return gitBashCandidatesFromGitRoot(nodePath.win32.join(normalized, '..', '..')); +} + +function gitBashCandidatesFromGitRoot(root: string): readonly string[] { + return [ + nodePath.win32.normalize(nodePath.win32.join(root, 'bin', 'bash.exe')), + nodePath.win32.normalize(nodePath.win32.join(root, 'usr', 'bin', 'bash.exe')), + ]; +} + +function normalizeWindowsPath(path: string): string { + return path.replaceAll('/', '\\'); +} + +function isAbsoluteWindowsPath(path: string): boolean { + return nodePath.win32.isAbsolute(normalizeWindowsPath(path)); +} + +function dedupeWindowsPaths(paths: readonly string[]): readonly string[] { + const deduped: string[] = []; + const seen = new Set(); + for (const path of paths) { + const key = normalizeWindowsPath(path).toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + deduped.push(path); + } + return deduped; +} + /** * Production convenience — derive the deps bag from Node's ambient surface. * @@ -175,27 +257,50 @@ export function detectEnvironmentFromNode(): Promise { release: nodeOs.release(), env, isFile, - findExecutable: (name: string) => findExecutableOnPath(name, env['PATH'], platform, isFile), + execFileText, }); return detectedEnvironment; } -async function findExecutableOnPath( +async function findExecutablesOnPath( name: string, pathEnv: string | undefined, platform: string, isFile: (p: string) => Promise, -): Promise { - if (pathEnv === undefined || pathEnv.length === 0) return undefined; +): Promise { + if (pathEnv === undefined || pathEnv.length === 0) return []; const listSep = platform === 'win32' ? ';' : ':'; const dirSep = platform === 'win32' ? '\\' : '/'; + const paths: string[] = []; for (const rawDir of pathEnv.split(listSep)) { const dir = rawDir.trim(); if (dir.length === 0) continue; + if (platform === 'win32' && !isAbsoluteWindowsPath(dir)) continue; const candidate = dir.endsWith(dirSep) ? `${dir}${name}` : `${dir}${dirSep}${name}`; if (await isFile(candidate)) { - return candidate; + paths.push(candidate); } } - return undefined; + return platform === 'win32' ? dedupeWindowsPaths(paths) : paths; +} + +async function execFileText( + file: string, + args: readonly string[], + timeoutMs: number, +): Promise { + return new Promise((resolve) => { + nodeExecFile( + file, + [...args], + { encoding: 'utf8', timeout: timeoutMs, windowsHide: true }, + (error, stdout) => { + if (error !== null) { + resolve(undefined); + return; + } + resolve(stdout); + }, + ); + }); } diff --git a/packages/kaos/test/environment.test.ts b/packages/kaos/test/environment.test.ts index 2f020ee55..91922ae1a 100644 --- a/packages/kaos/test/environment.test.ts +++ b/packages/kaos/test/environment.test.ts @@ -6,8 +6,9 @@ * - macOS / Linux / Windows / unknown → `osKind` * - POSIX path probing prefers /bin/bash, falls back to /usr/bin/bash, * /usr/local/bin/bash, then /bin/sh (with shellName 'sh'). - * - Windows resolves Git Bash via `KIMI_SHELL_PATH`, `git.exe` on PATH, - * or well-known install locations; throws `KaosShellNotFoundError` + * - Windows resolves Git Bash via `KIMI_SHELL_PATH`, `git.exe` on PATH + * (including `git --exec-path` for shims), or well-known install + * locations; throws `KaosShellNotFoundError` * if none are present. * - `osArch` / `osVersion` are populated from the Node OS APIs. * @@ -32,23 +33,29 @@ interface StubOpts { readonly release?: string; readonly env?: Record; readonly existingPaths?: readonly string[]; - readonly executables?: Readonly>; + readonly execFileResults?: Readonly>; + readonly execFileText?: Parameters[0]['execFileText']; } /** Build a stub deps bag mimicking Node's `os` + `process` surface. */ function stubDeps(opts: StubOpts): Parameters[0] { const existing = new Set(opts.existingPaths ?? []); - const executables = opts.executables ?? {}; return { platform: opts.platform, arch: opts.arch ?? 'x86_64', release: opts.release ?? '1.2.3', env: opts.env ?? {}, isFile: async (path: string) => existing.has(path), - findExecutable: async (name: string) => executables[name], + execFileText: + opts.execFileText ?? + (async (file: string, args: readonly string[]) => opts.execFileResults?.[execFileKey(file, args)]), }; } +function execFileKey(file: string, args: readonly string[]): string { + return [file, ...args].join('\0'); +} + describe('detectEnvironment', () => { it('reports osKind "macOS" on darwin', async () => { const env: Environment = await detectEnvironment( @@ -146,23 +153,152 @@ describe('detectEnvironment', () => { }); it('infers Git Bash from git.exe on PATH when override is absent', async () => { + const gitExe = 'C:\\Program Files\\Git\\cmd\\git.exe'; const env = await detectEnvironment( stubDeps({ platform: 'win32', - executables: { 'git.exe': 'C:\\Program Files\\Git\\cmd\\git.exe' }, - existingPaths: ['C:\\Program Files\\Git\\bin\\bash.exe'], + env: { PATH: 'C:\\Program Files\\Git\\cmd' }, + existingPaths: [gitExe, 'C:\\Program Files\\Git\\bin\\bash.exe'], + execFileText: async (file: string) => { + throw new Error(`unexpected execFileText call for ${file}`); + }, }), ); expect(env.shellName).toBe('bash'); expect(env.shellPath).toBe('C:\\Program Files\\Git\\bin\\bash.exe'); }); - it('infers Git Bash from usr/bin when bin/bash.exe is missing', async () => { + it('resolves a Scoop git shim through git --exec-path', async () => { + const gitExe = 'C:\\Users\\me\\scoop\\shims\\git.exe'; const env = await detectEnvironment( stubDeps({ platform: 'win32', - executables: { 'git.exe': 'D:\\Program Files\\Git\\cmd\\git.exe' }, - existingPaths: ['D:\\Program Files\\Git\\usr\\bin\\bash.exe'], + env: { PATH: 'C:\\Users\\me\\scoop\\shims' }, + execFileResults: { + [execFileKey(gitExe, ['--exec-path'])]: + 'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n', + }, + existingPaths: [gitExe, 'C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'); + }); + + it('resolves a Scoop git shim through usr/bin when bin/bash.exe is missing', async () => { + const gitExe = 'C:\\Users\\me\\scoop\\shims\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'C:\\Users\\me\\scoop\\shims' }, + execFileResults: { + [execFileKey(gitExe, ['--exec-path'])]: + 'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n', + }, + existingPaths: [gitExe, 'C:\\Users\\me\\scoop\\apps\\git\\current\\usr\\bin\\bash.exe'], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\usr\\bin\\bash.exe'); + }); + + it('does not treat shim-adjacent bash.exe as the Git installation shell', async () => { + const gitExe = 'C:\\Users\\me\\scoop\\shims\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'C:\\Users\\me\\scoop\\shims' }, + execFileResults: { + [execFileKey(gitExe, ['--exec-path'])]: + 'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n', + }, + existingPaths: [ + gitExe, + 'C:\\Users\\me\\scoop\\bin\\bash.exe', + 'C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe', + ], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'); + }); + + it('checks later git.exe matches when the first one cannot resolve Git Bash', async () => { + const scoopGit = 'C:\\Users\\me\\scoop\\shims\\git.exe'; + const portableGit = 'D:\\PortableGit\\cmd\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'C:\\Users\\me\\scoop\\shims;D:\\PortableGit\\cmd' }, + existingPaths: [scoopGit, portableGit, 'D:\\PortableGit\\bin\\bash.exe'], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('D:\\PortableGit\\bin\\bash.exe'); + }); + + it('keeps PATH order when an earlier shim resolves through git --exec-path', async () => { + const scoopGit = 'C:\\Users\\me\\scoop\\shims\\git.exe'; + const portableGit = 'D:\\PortableGit\\cmd\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'C:\\Users\\me\\scoop\\shims;D:\\PortableGit\\cmd' }, + execFileResults: { + [execFileKey(scoopGit, ['--exec-path'])]: + 'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n', + }, + existingPaths: [ + scoopGit, + portableGit, + 'C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe', + 'D:\\PortableGit\\bin\\bash.exe', + ], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'); + }); + + it('skips relative Windows PATH entries before git --exec-path probing', async () => { + const relativeGit = 'tools\\git.exe'; + const error = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'tools' }, + existingPaths: [relativeGit], + execFileText: async (file: string) => { + throw new Error(`unexpected execFileText call for ${file}`); + }, + }), + ).then( + () => { + throw new Error('expected throw'); + }, + (error: unknown) => error, + ); + expect(error).toBeInstanceOf(KaosShellNotFoundError); + }); + + it('scans PATH directly for git.exe candidates', async () => { + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'D:\\PortableGit\\cmd' }, + existingPaths: ['D:\\PortableGit\\cmd\\git.exe', 'D:\\PortableGit\\bin\\bash.exe'], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('D:\\PortableGit\\bin\\bash.exe'); + }); + + it('infers Git Bash from usr/bin when bin/bash.exe is missing', async () => { + const gitExe = 'D:\\Program Files\\Git\\cmd\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'D:\\Program Files\\Git\\cmd' }, + existingPaths: [gitExe, 'D:\\Program Files\\Git\\usr\\bin\\bash.exe'], }), ); expect(env.shellName).toBe('bash'); @@ -200,7 +336,7 @@ describe('detectEnvironment', () => { expect(env.shellPath).toBe('C:\\Users\\me\\AppData\\Local\\Programs\\Git\\bin\\bash.exe'); }); -it('falls back to usr/bin under LOCALAPPDATA when bin/bash.exe is missing', async () => { + it('falls back to usr/bin under LOCALAPPDATA when bin/bash.exe is missing', async () => { const env = await detectEnvironment( stubDeps({ platform: 'win32',