Merge remote-tracking branch 'origin/fix/windows-git-bash-preflight' into windows-megafix

This commit is contained in:
Alex Newman
2026-08-19 12:27:01 -07:00
4 changed files with 213 additions and 2 deletions
+16 -2
View File
@@ -8,10 +8,11 @@
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { styleText } from 'node:util';
import { isPluginInstalled, marketplaceDirectory, readPluginVersion } from '../utils/paths.js';
import { IS_WINDOWS, isPluginInstalled, marketplaceDirectory, readPluginVersion } from '../utils/paths.js';
import { getBunVersion, getUvVersion, isInstallCurrent } from '../install/setup-runtime.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { resolveDataDir } from '../../shared/paths.js';
import { checkWindowsGitBash } from '../utils/windows-git-bash-preflight.js';
type CheckStatus = 'ok' | 'warn' | 'fail';
@@ -115,7 +116,20 @@ export async function runDoctorCommand(): Promise<void> {
required: false, // worker can be intentionally stopped; don't hard-fail
});
// 6. Last recorded install error (surface remediation if present).
// 6. Windows Git Bash reachability. All claude-mem hooks run via
// `"shell": "bash"`; on Windows, Claude Code resolves that through Git for
// Windows with no WSL fallback. No-op on macOS/Linux.
if (IS_WINDOWS) {
const gitBash = checkWindowsGitBash();
checks.push({
name: 'Git Bash (Windows)',
status: gitBash.ok ? 'ok' : 'fail',
detail: gitBash.detail,
required: true,
});
}
// 7. Last recorded install error (surface remediation if present).
const lastErrorPath = join(dataDir, 'last-install-error.json');
if (existsSync(lastErrorPath)) {
let detail = `present at ${lastErrorPath}`;
+13
View File
@@ -177,6 +177,7 @@ import { readJsonSafe } from '../../utils/json-utils.js';
import { readFlatSettings } from '../utils/settings.js';
import { shutdownWorkerAndWait } from '../../services/install/shutdown-helper.js';
import { detectInstalledIDEs } from './ide-detection.js';
import { checkWindowsGitBash } from '../utils/windows-git-bash-preflight.js';
function registerMarketplace(): void {
const knownMarketplaces = readJsonSafe<Record<string, any>>(knownMarketplacesPath(), {});
@@ -1967,6 +1968,18 @@ async function runInstallCommandInner(options: InstallOptions, summary: InstallS
}
log.info(segments.join(` ${dot} `));
// All claude-mem hooks run via `"shell": "bash"`; on Windows, Claude Code
// resolves that through Git for Windows with no WSL fallback. Surfacing it
// here — rather than letting the first hook throw an unbranded error — is
// a warning, not a hard stop: the operator may install Git for Windows
// after this run and hooks will start working without a reinstall.
if (IS_WINDOWS) {
const gitBash = checkWindowsGitBash();
if (!gitBash.ok) {
log.warn(gitBash.detail);
}
}
// An explicit --provider flag wins over the trial funnel: never pitch,
// email, poll, or override a provider the operator asked for by name.
const trialPairing = options.provider ? null : await promptProTrialOptIn(version);
@@ -0,0 +1,90 @@
/**
* Windows-only preflight for claude-mem's hooks.
*
* Every hook in plugin/hooks/hooks.json declares `"shell": "bash"`. On
* Windows, Claude Code resolves that through a closed chain — verified
* against the Claude Code CLI binary, no WSL fallback exists:
*
* 1. CLAUDE_CODE_GIT_BASH_PATH env var
* 2. C:\Program Files\Git\bin\bash.exe
* 3. C:\Program Files (x86)\Git\bin\bash.exe
* 4. `git` resolved on PATH, then ../../bin/bash.exe relative to it
* 5. null — Claude Code throws, and every claude-mem hook throws with it
*
* A Windows user who satisfied Claude Code's own requirements via PowerShell
* (no Git for Windows at all) hits step 5 with a raw, unbranded error. This
* module replicates the same chain so claude-mem can detect that case ahead
* of time and say so plainly. It does not change hook behavior — see #3605.
*/
import { existsSync } from 'fs';
import { win32 } from 'path';
import { lookupWindowsCommand } from '../../shared/spawn.js';
export const STANDARD_GIT_BASH_PATHS = [
'C:\\Program Files\\Git\\bin\\bash.exe',
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
];
export const GIT_BASH_REMEDIATION =
'Git Bash not found. claude-mem hooks require bash, and Claude Code resolves it via Git for ' +
'Windows on Windows. Install Git for Windows (https://git-scm.com/download/win), or if bash is ' +
'installed somewhere non-standard, set CLAUDE_CODE_GIT_BASH_PATH to the full path of bash.exe.';
/** Filesystem access this check needs, injectable so tests never touch the real FS. */
export interface GitBashProbe {
fileExists: (path: string) => boolean;
/** Resolve `git` on PATH the way Claude Code does — injectable for tests. */
lookupGitOnPath: () => string | null;
}
const defaultProbe: GitBashProbe = {
fileExists: existsSync,
lookupGitOnPath: () => lookupWindowsCommand('git'),
};
export interface GitBashPreflightResult {
ok: boolean;
/** bash.exe path claude-mem expects Claude Code to resolve, when found. */
resolvedPath?: string;
detail: string;
}
/**
* Replicates Claude Code's Git Bash resolution chain. A no-op on
* non-Windows platforms — no probe call is made, since bash is not
* Windows-specific there.
*/
export function checkWindowsGitBash(
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
probe: GitBashProbe = defaultProbe,
): GitBashPreflightResult {
if (platform !== 'win32') {
return { ok: true, detail: 'not applicable (non-Windows)' };
}
const override = env.CLAUDE_CODE_GIT_BASH_PATH;
if (override && probe.fileExists(override)) {
return { ok: true, resolvedPath: override, detail: `CLAUDE_CODE_GIT_BASH_PATH=${override}` };
}
for (const candidate of STANDARD_GIT_BASH_PATHS) {
if (probe.fileExists(candidate)) {
return { ok: true, resolvedPath: candidate, detail: candidate };
}
}
const gitOnPath = probe.lookupGitOnPath();
if (gitOnPath) {
// Windows paths regardless of the host platform running this check —
// win32.join, not the platform-dependent `path` import, so this resolves
// correctly under tests on macOS/Linux too.
const candidate = win32.join(gitOnPath, '..', '..', 'bin', 'bash.exe');
if (probe.fileExists(candidate)) {
return { ok: true, resolvedPath: candidate, detail: candidate };
}
}
return { ok: false, detail: GIT_BASH_REMEDIATION };
}
@@ -0,0 +1,94 @@
import { describe, it, expect } from 'bun:test';
import {
checkWindowsGitBash,
GIT_BASH_REMEDIATION,
STANDARD_GIT_BASH_PATHS,
type GitBashProbe,
} from '../../src/npx-cli/utils/windows-git-bash-preflight.js';
// Windows #3605 (fail-loudly slice) — claude-mem hooks require bash, and on
// Windows Claude Code resolves it via a closed chain (CLAUDE_CODE_GIT_BASH_PATH
// -> standard Git for Windows paths -> `git` on PATH -> null, no WSL fallback).
// This preflight replicates that chain to detect "no Git Bash reachable" ahead
// of the first opaque hook throw.
function probeThatMustNotBeCalled(): GitBashProbe {
return {
fileExists: () => {
throw new Error('fileExists should not be called off-Windows');
},
lookupGitOnPath: () => {
throw new Error('lookupGitOnPath should not be called off-Windows');
},
};
}
describe('checkWindowsGitBash', () => {
it('reports the actionable error when no bash is reachable anywhere in the chain', () => {
const probe: GitBashProbe = {
fileExists: () => false,
lookupGitOnPath: () => null,
};
const result = checkWindowsGitBash('win32', {}, probe);
expect(result.ok).toBe(false);
expect(result.detail).toBe(GIT_BASH_REMEDIATION);
expect(result.detail).toContain('Git for Windows');
expect(result.detail).toContain('CLAUDE_CODE_GIT_BASH_PATH');
});
it('passes when CLAUDE_CODE_GIT_BASH_PATH points at a real file', () => {
const probe: GitBashProbe = {
fileExists: (path) => path === 'D:\\custom\\bash.exe',
lookupGitOnPath: () => {
throw new Error('should not fall through to PATH lookup');
},
};
const result = checkWindowsGitBash(
'win32',
{ CLAUDE_CODE_GIT_BASH_PATH: 'D:\\custom\\bash.exe' },
probe,
);
expect(result.ok).toBe(true);
expect(result.resolvedPath).toBe('D:\\custom\\bash.exe');
});
it('passes when Git is present at the standard install path', () => {
const probe: GitBashProbe = {
fileExists: (path) => path === STANDARD_GIT_BASH_PATHS[0],
lookupGitOnPath: () => {
throw new Error('should not fall through to PATH lookup');
},
};
const result = checkWindowsGitBash('win32', {}, probe);
expect(result.ok).toBe(true);
expect(result.resolvedPath).toBe(STANDARD_GIT_BASH_PATHS[0]);
});
it('falls through to `git` on PATH and resolves ../../bin/bash.exe relative to it', () => {
const probe: GitBashProbe = {
fileExists: (path) => path === 'C:\\Program Files\\Git\\bin\\bash.exe',
lookupGitOnPath: () => 'C:\\Program Files\\Git\\cmd\\git.exe',
};
const result = checkWindowsGitBash('win32', {}, probe);
expect(result.ok).toBe(true);
expect(result.resolvedPath).toBe('C:\\Program Files\\Git\\bin\\bash.exe');
});
it('does not run at all on darwin', () => {
const result = checkWindowsGitBash('darwin', {}, probeThatMustNotBeCalled());
expect(result.ok).toBe(true);
});
it('does not run at all on linux', () => {
const result = checkWindowsGitBash('linux', {}, probeThatMustNotBeCalled());
expect(result.ok).toBe(true);
});
});