doctor: name the Windows bash gstack resolves and what chose it

On Windows (OSTYPE msys/cygwin) gstack-doctor adds a 'windows bash' row from
lib/script-invocation.ts resolveWindowsBash, run through the new thin
bin/gstack-windows-bash.ts with the bun on PATH: the resolved bash and the
override or discovery step that chose it, a warn when GSTACK_GIT_BASH and
GSTACK_BASH differ, and a fail carrying the BASH_UNAVAILABLE line (every
candidate with its rejection reason) when none qualifies. Tests inject the
platform (GSTACK_DOCTOR_PLATFORM) and the discovery inputs
(GSTACK_DOCTOR_BASH_PROBE) so the row runs on every OS.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-17 00:03:03 +00:00
1 parent 3f8bd20734
commit 75e425b1d5
3 files changed
+116 -3

No files matched your search

+46 -3
View File
@@ -13,9 +13,9 @@
# default is every host installed from this checkout.
# Rows and ids: bin/gstack-doctor-components.sh.
#
# Rows: install root, rescue refs, state root, bun, hooks, codex, codex probe,
# artifacts sync, browse bundle, binaries, cso, claude code, session journal,
# guard log, CLAUDE.md. Each row is
# Rows: install root, rescue refs, state root, bun, hooks, windows bash (on
# Windows only), codex, codex probe, artifacts sync, browse bundle, binaries,
# cso, claude code, session journal, guard log, CLAUDE.md. Each row is
# `ok`, `warn`, `not configured` or `fail`; a row that is not ok carries a fix
# line, and every gstack helper in a fix line is an absolute path under this
# install root. Exit 1 when any row is `fail`, 2 on a usage error, else 0.
@@ -29,6 +29,11 @@
# compiled binary's side-effect-free --version) and the CSO build-result record. The sync row prints only a clamped status code and counts, never
# the status message or file paths: the message can quote a matched secret.
# Bash 3.2 plus POSIX tools; it still reports when bun or python3 is missing.
#
# Windows is detected from OSTYPE (msys, cygwin). Tests inject it instead:
# GSTACK_DOCTOR_PLATFORM=windows|posix overrides the detection, and
# GSTACK_DOCTOR_BASH_PROBE=<json file> hands bin/gstack-windows-bash.ts the
# bash discovery inputs (its --probe).
# Heredoc delivery guard, as in bin/gstack-brain-sync: compat level 50 keeps
# heredoc bodies on the tempfile path, so a small pipe buffer cannot hang them.
@@ -489,6 +494,37 @@ $refs
REFS
}
# check_windows_bash — the Git Bash gstack's TypeScript hands its bash scripts
# to on Windows, and what chose it (lib/script-invocation.ts, through
# bin/gstack-windows-bash.ts run by the bun on PATH).
check_windows_bash() {
local out rc bash source warning refused
if [ ! -f "$BIN/gstack-windows-bash.ts" ]; then
row warn "windows bash" "not checked: $BIN/gstack-windows-bash.ts is missing" "cd $SRC && ./setup"
return
fi
if ! command -v bun >/dev/null 2>&1; then
row warn "windows bash" "not checked: bun not found" "install Bun (see the bun row), then rerun $BIN/gstack-doctor"
return
fi
out=$(bun "$BIN/gstack-windows-bash.ts" ${GSTACK_DOCTOR_BASH_PROBE:+--probe "$GSTACK_DOCTOR_BASH_PROBE"} </dev/null 2>&1)
rc=$?
out=$(printf '%s\n' "$out" | tr -d '\000-\010\013-\037')
bash=$(printf '%s\n' "$out" | sed -n 's/^WINDOWS_BASH: //p' | head -1)
source=$(printf '%s\n' "$out" | sed -n 's/^WINDOWS_BASH_SOURCE: //p' | head -1)
warning=$(printf '%s\n' "$out" | sed -n 's/^WINDOWS_BASH_WARNING: //p' | head -1)
refused=$(printf '%s\n' "$out" | sed -n 's/^WINDOWS_BASH_REFUSED: //p' | head -1)
if [ -n "$refused" ]; then
row fail "windows bash" "$refused" "set GSTACK_GIT_BASH to Git Bash's bash.exe for good (PowerShell user variable or the env block of ~/.claude/settings.json), then restart Claude Code ($DOCS#bash-unavailable)"
elif [ -z "$bash" ]; then
row warn "windows bash" "not checked: gstack-windows-bash exited $rc ($(printf '%s\n' "$out" | grep -v '^[[:space:]]*$' | head -1 | cut -c1-200))" "cd $SRC && ./setup"
elif [ -n "$warning" ]; then
row warn "windows bash" "$bash (chosen by $source); $warning" "unset GSTACK_BASH, or set it to the same path"
else
row ok "windows bash" "$bash (chosen by $source)"
fi
}
# check_claude_md — a gstack section in ~/.claude/CLAUDE.md or this project's
# CLAUDE.md that enumerates skills (#3169): Claude Code lists the installed
# skills itself, and a pasted list goes stale with each release. A section is
@@ -548,6 +584,12 @@ GSTACK_STATE_ROOT="$STATE_ROOT"
. "$BIN/gstack-launch-probe.sh" 2>/dev/null || { echo "gstack-doctor: $BIN/gstack-launch-probe.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; }
EXE=""
case "${OSTYPE:-}" in msys*|cygwin*|win32*) EXE=".exe" ;; esac
WINDOWS=0
case "${GSTACK_DOCTOR_PLATFORM:-}" in
windows) WINDOWS=1 ;;
posix) ;;
*) case "${OSTYPE:-}" in msys*|cygwin*|win32*) WINDOWS=1 ;; esac ;;
esac
if [ "$CHECK" -eq 1 ]; then
. "$BIN/gstack-doctor-check.sh" || { echo "gstack-doctor: $BIN/gstack-doctor-check.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; }
@@ -561,6 +603,7 @@ check_rescue_refs
check_state_root
check_bun
check_hooks
[ "$WINDOWS" -eq 1 ] && check_windows_bash
check_codex
[ "$CODEX_READY" -eq 1 ] && check_codex_probe
check_sync
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bun
/**
* gstack-windows-bash — the doctor's `windows bash` row: the Git Bash gstack's
* TypeScript hands its bash scripts to on Windows, and what chose it
* (lib/script-invocation.ts resolveWindowsBash, the one resolver).
*
* gstack-windows-bash [--probe <file>]
*
* Prints `WINDOWS_BASH: <path>` and `WINDOWS_BASH_SOURCE: <source>` (exit 0),
* or `WINDOWS_BASH_REFUSED: <BASH_UNAVAILABLE line listing each candidate>`
* (exit 1); `WINDOWS_BASH_WARNING: <text>` when GSTACK_GIT_BASH and
* GSTACK_BASH differ. --probe reads the discovery inputs from a JSON file
* ({exists, gitExecPath, which, uname}, the shape of
* test/fixtures/windows-bash-resolution.json) so tests drive it on any OS;
* without it the resolver probes this machine. Exit 2 on usage.
*/
import * as fs from 'node:fs';
import { resolveWindowsBash, type BashProbe } from '../lib/script-invocation';
const usage = 'usage: gstack-windows-bash [--probe <file>]';
const args = process.argv.slice(2);
let probe: BashProbe = {};
if (args[0] === '-h' || args[0] === '--help') { console.log(usage); process.exit(0); }
if (args.length > 0) {
if (args[0] !== '--probe' || args.length !== 2) { console.error(usage); process.exit(2); }
const p = JSON.parse(fs.readFileSync(args[1], 'utf8')) as { exists?: string[]; gitExecPath?: string | null; which?: Record<string, string>; uname?: Record<string, string> };
const exists = new Set(p.exists ?? []);
probe = {
exists: f => exists.has(f),
gitExecPath: () => p.gitExecPath ?? null,
which: cmd => p.which?.[cmd] ?? null,
uname: bash => p.uname?.[bash] ?? null,
};
}
const res = resolveWindowsBash(probe);
if (res.warning) console.log(`WINDOWS_BASH_WARNING: ${res.warning}`);
if (!res.ok) {
console.log(`WINDOWS_BASH_REFUSED: ${res.message}`);
process.exit(1);
}
console.log(`WINDOWS_BASH: ${res.bash}`);
console.log(`WINDOWS_BASH_SOURCE: ${res.source}`);
+27
View File
@@ -27,6 +27,7 @@ const COPIED = [
'autoplan/SKILL.md', // its frontmatter registers the autoplan hook the hooks-row test breaks
'bin/gstack-launch-probe.sh',
'bin/gstack-doctor-check.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts',
'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/result-codes.ts', // the windows bash row
];
const bases: string[] = [];
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
@@ -1006,4 +1007,30 @@ describe('gstack-doctor: rescue refs, CLAUDE.md skill lists, Windows bash, Windo
expect(r.row('CLAUDE.md').state, r.out).toBe('ok');
expect(replacement(r.out)).toEqual([]);
});
/** A JSON probe file: the discovery inputs resolveWindowsBash takes, in test/fixtures/windows-bash-resolution.json's shape. */
const probe = (f: Fixture, inputs: Record<string, unknown>) => { const file = path.join(f.base, 'bash-probe.json'); write(file, JSON.stringify(inputs)); return file; };
const GIT_BASH = 'C:/Program Files/Git/bin/bash.exe';
test('windows bash: on Windows the row names the resolved bash and what chose it; a refusal fails with BASH_UNAVAILABLE naming GSTACK_GIT_BASH; off Windows there is no row', () => {
const f = makeFixture();
const found = doctor(f, [], { GSTACK_DOCTOR_PLATFORM: 'windows', GSTACK_DOCTOR_BASH_PROBE: probe(f, { exists: [GIT_BASH], gitExecPath: 'C:/Program Files/Git/mingw64/libexec/git-core' }) });
expect(found.row('windows bash'), found.out).toEqual({ state: 'ok', detail: `${GIT_BASH} (chosen by git --exec-path)`, fix: '' });
const override = doctor(f, [], { GSTACK_DOCTOR_PLATFORM: 'windows', GSTACK_GIT_BASH: 'D:\\tools\\bash.exe', GSTACK_DOCTOR_BASH_PROBE: probe(f, { exists: ['D:/tools/bash.exe', GIT_BASH] }) });
expect(override.row('windows bash').detail).toBe('D:/tools/bash.exe (chosen by GSTACK_GIT_BASH)');
const differ = doctor(f, [], { GSTACK_DOCTOR_PLATFORM: 'windows', GSTACK_GIT_BASH: 'D:/a/bash.exe', GSTACK_BASH: 'D:/b/bash.exe', GSTACK_DOCTOR_BASH_PROBE: probe(f, { exists: ['D:/a/bash.exe', 'D:/b/bash.exe'] }) });
expect(differ.row('windows bash').state).toBe('warn');
expect(differ.row('windows bash').detail).toContain('D:/a/bash.exe (chosen by GSTACK_GIT_BASH)');
expect(differ.row('windows bash').detail).toContain('GSTACK_GIT_BASH (D:/a/bash.exe) and the deprecated GSTACK_BASH (D:/b/bash.exe) differ');
const refused = doctor(f, [], { GSTACK_DOCTOR_PLATFORM: 'windows', GSTACK_DOCTOR_BASH_PROBE: probe(f, { exists: [], gitExecPath: null, which: { bash: 'C:/Windows/System32/bash.exe' } }) });
const row = refused.row('windows bash');
expect(row.state, refused.out).toBe('fail');
expect(row.detail).toContain('(BASH_UNAVAILABLE)');
expect(row.detail).toContain('PATH C:/Windows/System32/bash.exe: WSL launcher, not Git Bash');
expect(row.detail).toContain(`install path ${GIT_BASH}: not found`);
expect(row.fix).toContain('GSTACK_GIT_BASH');
expect(row.fix).toContain('#bash-unavailable');
expect(refused.status).toBe(1);
expect(doctor(f, [], { GSTACK_DOCTOR_PLATFORM: 'posix' }).out).not.toContain('windows bash');
});
});