From 75e425b1d5c8bb4dae574957cb20012bf7bedf84 Mon Sep 17 00:00:00 2001 From: garrytan Date: Fri, 16 Oct 2026 23:54:14 +0000 Subject: [PATCH] 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> --- bin/gstack-doctor | 49 +++++++++++++++++++++++++++++++++++--- bin/gstack-windows-bash.ts | 43 +++++++++++++++++++++++++++++++++ test/gstack-doctor.test.ts | 27 +++++++++++++++++++++ 3 files changed, 116 insertions(+), 3 deletions(-) create mode 100755 bin/gstack-windows-bash.ts diff --git a/bin/gstack-doctor b/bin/gstack-doctor index 3b51a0ff3..75be5e044 100755 --- a/bin/gstack-doctor +++ b/bin/gstack-doctor @@ -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= 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"} &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 diff --git a/bin/gstack-windows-bash.ts b/bin/gstack-windows-bash.ts new file mode 100755 index 000000000..55f095733 --- /dev/null +++ b/bin/gstack-windows-bash.ts @@ -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 ] + * + * Prints `WINDOWS_BASH: ` and `WINDOWS_BASH_SOURCE: ` (exit 0), + * or `WINDOWS_BASH_REFUSED: ` + * (exit 1); `WINDOWS_BASH_WARNING: ` 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 ]'; +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; uname?: Record }; + 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}`); diff --git a/test/gstack-doctor.test.ts b/test/gstack-doctor.test.ts index 40a358a95..ccc57c759 100644 --- a/test/gstack-doctor.test.ts +++ b/test/gstack-doctor.test.ts @@ -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) => { 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'); + }); });