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
This commit is contained in:
7Sageer 2026-06-08 14:00:11 +08:00 committed by GitHub
parent 9aba465fd8
commit 3b62b123e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 292 additions and 45 deletions

View file

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

View file

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

View file

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

View file

@ -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<string, string | undefined>;
readonly isFile: (path: string) => Promise<boolean>;
readonly findExecutable: (name: string) => Promise<string | undefined>;
readonly execFileText: (
file: string,
args: readonly string[],
timeoutMs: number,
) => Promise<string | undefined>;
}
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<string> {
}
}
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<string> {
);
}
// Most Git for Windows installs put `git.exe` in `<root>\cmd\git.exe`,
// with bash at `<root>\bin\bash.exe` (a wrapper) or `<root>\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<string | undefined> {
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 `<root>\cmd\git.exe`,
// with bash at `<root>\bin\bash.exe`. Portable installs sometimes put
// both in `<root>\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<string>();
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<Environment> {
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<boolean>,
): Promise<string | undefined> {
if (pathEnv === undefined || pathEnv.length === 0) return undefined;
): Promise<readonly string[]> {
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<string | undefined> {
return new Promise((resolve) => {
nodeExecFile(
file,
[...args],
{ encoding: 'utf8', timeout: timeoutMs, windowsHide: true },
(error, stdout) => {
if (error !== null) {
resolve(undefined);
return;
}
resolve(stdout);
},
);
});
}

View file

@ -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<string, string | undefined>;
readonly existingPaths?: readonly string[];
readonly executables?: Readonly<Record<string, string>>;
readonly execFileResults?: Readonly<Record<string, string>>;
readonly execFileText?: Parameters<typeof detectEnvironment>[0]['execFileText'];
}
/** Build a stub deps bag mimicking Node's `os` + `process` surface. */
function stubDeps(opts: StubOpts): Parameters<typeof detectEnvironment>[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',