From cc28128d27c9a8a27b465a522d3d533de85c3821 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 18:36:49 +0000 Subject: [PATCH] fix(windows): custom-root installs keep their paths; git helpers work with MSYS_NO_PATHCONV=1 (#3498, #3468) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-doctor | 2 + bin/gstack-ff-update | 3 + bin/gstack-session-update | 7 ++- bin/gstack-wtree | 8 ++- changelog.d/gsta208-windows-msys-paths.md | 13 ++++ docs/troubleshooting.md | 17 +++++ scripts/gen-skill-docs.ts | 7 ++- scripts/lib/windows-curation.ts | 2 + setup | 5 ++ test/ff-update-windows-msys.test.ts | 62 +++++++++++++++++++ ...en-skill-docs-install-root-windows.test.ts | 37 +++++++++++ test/gstack-doctor.test.ts | 8 +++ test/gstack-wtree-windows-paths.test.ts | 8 +++ test/setup-install-root-windows.test.ts | 56 +++++++++++++++++ 14 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 changelog.d/gsta208-windows-msys-paths.md create mode 100644 test/ff-update-windows-msys.test.ts create mode 100644 test/gen-skill-docs-install-root-windows.test.ts create mode 100644 test/setup-install-root-windows.test.ts diff --git a/bin/gstack-doctor b/bin/gstack-doctor index 0690fb3a9..3613979e7 100755 --- a/bin/gstack-doctor +++ b/bin/gstack-doctor @@ -531,6 +531,8 @@ check_windows_bash() { 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 "${MSYS_NO_PATHCONV:-}" ]; then + row warn "windows bash" "$bash (chosen by $source); MSYS_NO_PATHCONV is set, so gstack's Bun commands cannot read --config=/dev/null and fail before they start (#3468)" "unset MSYS_NO_PATHCONV and set it only on the command that needs it, such as MSYS_NO_PATHCONV=1 docker ... ($DOCS#msys-no-pathconv)" elif [ -n "$warning" ]; then row warn "windows bash" "$bash (chosen by $source); $warning" "unset GSTACK_BASH, or set it to the same path" else diff --git a/bin/gstack-ff-update b/bin/gstack-ff-update index 877f27672..923f6109d 100755 --- a/bin/gstack-ff-update +++ b/bin/gstack-ff-update @@ -130,6 +130,9 @@ HB_PID=$! # ── Inside the lock ── HEAD_OID=$(g rev-parse -q --verify 'HEAD^{commit}' 2>/dev/null) || refuse 2 not-a-checkout "$DIR has no HEAD commit" TMP=$(mktemp -d "${TMPDIR:-/tmp}/gstack-ff-XXXXXX" 2>/dev/null) || refuse 3 autostash-unrecoverable "cannot create a temp directory under ${TMPDIR:-/tmp}; nothing was changed" +# Its index files go to Git for Windows, which cannot open /tmp/... with MSYS +# path conversion off (#3468): use the native spelling where cygpath exists. +if command -v cygpath >/dev/null 2>&1 && _ff_native=$(cygpath -am "$TMP" 2>/dev/null) && [ -n "$_ff_native" ]; then TMP=$_ff_native; fi g ls-files -u -z > "$TMP/unmerged.raw" 2>"$TMP/err" || refuse 5 index-unreadable "cannot read the index of $DIR ($(head -c 200 "$TMP/err")); nothing was changed" UNMERGED=$(tr '\0' '\n' < "$TMP/unmerged.raw") # Nothing to move: an up-to-date install keeps its edits and staged state as they are. diff --git a/bin/gstack-session-update b/bin/gstack-session-update index 4722cf0eb..b1bc48529 100755 --- a/bin/gstack-session-update +++ b/bin/gstack-session-update @@ -131,9 +131,12 @@ hook_report() { hook_incoming_hold() { _hc_dir=$(mktemp -d "${TMPDIR:-/tmp}/gstack-session-hooks-XXXXXX" 2>/dev/null) \ || { echo "hook-check-unavailable: cannot create a temp directory under ${TMPDIR:-/tmp}"; return 0; } + # With MSYS path conversion off, Git for Windows cannot open /tmp/... (#3468). + _hc_git=$_hc_dir + [ -z "${MSYS_NO_PATHCONV:-}" ] || _hc_git=$(gstack_path_native "$_hc_dir") _hc_out="" _hc_why="" - if ! ( export GIT_INDEX_FILE="$_hc_dir.index" - git -C "$GSTACK_DIR" read-tree "$1" && git -C "$GSTACK_DIR" -c core.symlinks=false checkout-index -a --prefix="$_hc_dir/" ) 2>"$_hc_dir.err"; then + if ! ( export GIT_INDEX_FILE="$_hc_git.index" + git -C "$GSTACK_DIR" read-tree "$1" && git -C "$GSTACK_DIR" -c core.symlinks=false checkout-index -a --prefix="$_hc_git/" ) 2>"$_hc_dir.err"; then _hc_why="cannot extract the incoming revision: $(ff_flatten "$(head -c 200 "$_hc_dir.err" 2>/dev/null)" 200)" elif [ -f "$_hc_dir/bin/gstack-hook-check" ] && hook_report "$_hc_dir/bin/gstack-hook-check" "$_hc_dir"; then _hc_out=$_hr_out diff --git a/bin/gstack-wtree b/bin/gstack-wtree index c8034e375..1c600365b 100755 --- a/bin/gstack-wtree +++ b/bin/gstack-wtree @@ -59,12 +59,14 @@ TMPIDX=$(mktemp "${TMPDIR:-/tmp}/gstack-wtree-XXXXXX" 2>/dev/null) || { } trap 'rm -f "$TMPIDX"' EXIT trap 'exit 130' INT TERM -export GIT_INDEX_FILE="$TMPIDX" +# shellcheck source=bin/gstack-path-spelling.sh +. "$(cd "$(dirname "$0")" && pwd)/gstack-path-spelling.sh" +# Git for Windows gets the native spelling: with MSYS path conversion off +# (MSYS_NO_PATHCONV=1) it cannot open /tmp/... (#3468). +export GIT_INDEX_FILE="$(gstack_path_native "$TMPIDX")" # Resolve relative --git-path output against the repo root. Git for Windows # answers in drive-letter form (C:/…) wherever the path is absolute (a linked # worktree, a submodule, --separate-git-dir); that is absolute too (#3101). -# shellcheck source=bin/gstack-path-spelling.sh -. "$(cd "$(dirname "$0")" && pwd)/gstack-path-spelling.sh" if [ -n "$REAL_INDEX" ] && ! gstack_path_absolute "$REAL_INDEX"; then REAL_INDEX="$TOP/$REAL_INDEX"; fi if [ -n "$REAL_OBJECTS" ] && ! gstack_path_absolute "$REAL_OBJECTS"; then REAL_OBJECTS="$TOP/$REAL_OBJECTS"; fi if [ -n "$OBJ_DIR" ]; then diff --git a/changelog.d/gsta208-windows-msys-paths.md b/changelog.d/gsta208-windows-msys-paths.md new file mode 100644 index 000000000..154bbb624 --- /dev/null +++ b/changelog.d/gsta208-windows-msys-paths.md @@ -0,0 +1,13 @@ +**Windows custom-root installs keep their own paths, and gstack's git helpers work with `MSYS_NO_PATHCONV=1` exported.** Git Bash hands native Bun a setup install root like `/c/Users/you/isolated/...` as `C:/Users/you/isolated/...`, and the renderer refused any root that did not start with `/`. Setup warned and the isolated install kept the default-install paths (#3498, reported by DanCanadian). With `MSYS_NO_PATHCONV=1` exported (common for docker), Git for Windows received raw `/tmp/...` temp index paths it cannot open, so `gstack-wtree` and every review record built on it failed. `gstack-ff-update` and the session updater's hook check failed the same way (#3468, reported by Simba3696). + +### Fixed +- **`scripts/gen-skill-docs.ts --install-root`** accepts a drive-letter root and renders it in the `/c/...` spelling setup checks. A root with characters outside the plain set still refuses. +- **`setup`** converts a drive-form root with `cygpath -u` before its plain-path guard, so the guard, the render and setup's own post-render check agree. +- **`gstack-wtree`, `gstack-ff-update` and `gstack-session-update`** hand Git their temp index paths in Windows form. +- **`gstack-doctor`** warns on the `windows bash` row when `MSYS_NO_PATHCONV` is exported. Bun-based gstack commands start with `--config=/dev/null`, and with path conversion off native Bun cannot open `/dev/null`. No spelling works for both Bun on Windows and Linux/macOS (checked on windows-latest), so the fix is to set the variable per command. See docs/troubleshooting.md#msys-no-pathconv. + +### Tests +- `test/gen-skill-docs-install-root-windows.test.ts`: a `C:/` root renders as `/c/...`, and a root with a space refuses. +- `test/setup-install-root-windows.test.ts` (windows-latest): `_render_install` takes `/c/...` and `C:/...` roots past its guard to native Bun. +- `test/gstack-wtree-windows-paths.test.ts` and `test/ff-update-windows-msys.test.ts` (windows-latest): both run with `MSYS_NO_PATHCONV=1`. +- `test/gstack-doctor.test.ts`: the new warning. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 61886e363..daea38278 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2155,6 +2155,23 @@ or in Windows PowerShell: --- + +### `gstack-doctor`: `windows bash ... MSYS_NO_PATHCONV is set`, or a gstack command fails with `EINVAL ... while reading config "/dev/null"` + +**Meaning.** gstack's Bun commands start with `bun --config=/dev/null` so a +`bunfig.toml` in your project cannot change them. Git Bash normally turns +`/dev/null` and `/tmp/...` into paths native Windows programs can open. With +`MSYS_NO_PATHCONV=1` exported, it passes them through unchanged: native Bun +cannot open `/dev/null`, so every Bun-based gstack command fails before it +starts. `gstack-wtree`, `gstack-ff-update` and the session updater hand Git +their temp files in Windows form, so they work either way (#3468). + +**Fix.** Don't export `MSYS_NO_PATHCONV`. Set it only on the command that needs +it, for example `MSYS_NO_PATHCONV=1 docker run -v /c/src:/src ...`, or remove +the export from `~/.bashrc` and open a new shell. + +--- + ### `gstack: no Git Bash found to run gstack's bash scripts on Windows (tried ...); fix: ... (BASH_UNAVAILABLE)` diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 7869e377d..3a350618d 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -30,6 +30,7 @@ import { insertRuntimePreludes } from './resolvers/runtime-root'; import { generateSharedPreambleFile, hoistsPreamble, SHARED_PREAMBLE_FILE } from './resolvers/preamble-hoist'; import { extractVoiceTriggers } from './resolvers/voice-triggers'; import { frontmatterList, withTriggerLead } from '../lib/skill-triggers'; +import { toMsysPath } from '../lib/bin-context'; import { ALL_HOST_NAMES, resolveHostArg, getHostConfig } from '../hosts/index'; import type { HostConfig } from './host-config'; @@ -154,9 +155,11 @@ function parseGenerationArgs(args: string[]): GenerationOptions { if (modules && (outDir === undefined || path.resolve(outDir) === ROOT)) { throw new ModuleError('MODULE_SPEC_INVALID', `--${value('--modules') === undefined ? 'loadout' : 'modules'} with exclusions needs an --out-dir outside the checkout root; the committed tree is always the full render`, 'bin/gstack-modules render --host --modules --out-dir '); } - const installRoot = value('--install-root'); + // Git Bash hands native Bun its /c/... root as C:/... (#3498): render the spelling setup checked. + const givenInstallRoot = value('--install-root'); + const installRoot = givenInstallRoot !== undefined && /^[A-Za-z]:[\\/]/.test(givenInstallRoot) ? toMsysPath(givenInstallRoot) : givenInstallRoot; if (installRoot !== undefined && !INSTALL_ROOT_PATTERN.test(installRoot)) { - throw new Error(`--install-root must be an absolute path of letters, digits and . _ - + @ / (got ${JSON.stringify(installRoot)})`); + throw new Error(`--install-root must be an absolute path (/x, or C:/x on Windows) of letters, digits and . _ - + @ / (got ${JSON.stringify(givenInstallRoot)})`); } // Swap-in callers use --link-root for the FINAL serving path (#2692). // Direct --out-dir callers retain their existing links into the render. diff --git a/scripts/lib/windows-curation.ts b/scripts/lib/windows-curation.ts index b1d0fd291..d8abe9e35 100644 --- a/scripts/lib/windows-curation.ts +++ b/scripts/lib/windows-curation.ts @@ -338,6 +338,8 @@ export const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [ { file: 'test/copilot-windows-bash.test.ts', reason: 'the Copilot glossary line that runs bash blocks in Git for Windows Bash; reads host config only' }, { file: 'test/ship-hook-windows-paths.test.ts', reason: 'runs bin/ helpers through explicit bash and Bun argv with forward-slash paths; never executes a shebang; the path-spelling simulation is skipIf win32' }, { file: 'test/gstack-wtree-windows-paths.test.ts', reason: '#3101: runs bin/gstack-wtree through explicit bash in an os.tmpdir linked worktree with forward-slash paths; never executes a shebang; the drive-letter simulation is skipIf win32' }, + { file: 'test/setup-install-root-windows.test.ts', reason: '#3498: runs setup\'s _render_install through Git Bash with a native Bun recorder; the drive-path conversion it guards only happens on Windows (skipIf not win32)' }, + { file: 'test/ff-update-windows-msys.test.ts', reason: '#3468: runs gstack-ff-update through lib/script-invocation.ts with MSYS path conversion off; the /tmp index path only fails in Git for Windows' }, { file: 'test/setup-hook-syntax-gate.test.ts', reason: '#3088/#3100: runs bin/gstack-hook-check and the setup hook sections through explicit bash with forward-slash paths; never executes a shebang. Its Bun drive-path spelling is the Windows bug it guards' }, { file: 'test/bun-config-shell-bins.test.ts', reason: '#3415: runs the record bins through lib/script-invocation.ts (Git Bash on Windows) with MSYS path conversion off, the case it guards; its bun and cygpath stand-ins and simulated-OSTYPE cases are skipIf win32' }, { file: 'test/path-spelling.test.ts', reason: 'sources bin/gstack-path-spelling.sh through explicit bash and checks lib/path-spelling.ts; on Windows it exercises the real Git Bash spellings' }, diff --git a/setup b/setup index ce2a5cccc..1bc40a0d5 100755 --- a/setup +++ b/setup @@ -1124,6 +1124,11 @@ _render_install() { && [ "$got" = "$(cd "$(dirname "$default")" 2>/dev/null && pwd -P)" ] && return 0 render="$(gstack_install_render_dir "$host" "$root")" named="$root" + # A drive-form root (C:/x) gets the /c/x spelling the renderer writes, so the + # guard below and both post-render checks agree with the render (#3498). + if [ "$IS_WINDOWS" -eq 1 ] && command -v cygpath >/dev/null 2>&1; then + named="$(cygpath -u "$root" 2>/dev/null)" || named="$root" + fi if ! printf '%s' "$named" | grep -Eq '^/[A-Za-z0-9_.@+/-]*$'; then named="$render.root" if [ "$IS_WINDOWS" -eq 1 ] || ! printf '%s' "$named" | grep -Eq '^/[A-Za-z0-9_.@+/-]*$'; then diff --git a/test/ff-update-windows-msys.test.ts b/test/ff-update-windows-msys.test.ts new file mode 100644 index 000000000..7fa8b72e6 --- /dev/null +++ b/test/ff-update-windows-msys.test.ts @@ -0,0 +1,62 @@ +/** + * #3468: gstack-ff-update stages its capture indexes under a mktemp + * directory and hands them to git as GIT_INDEX_FILE. With MSYS path + * conversion off (MSYS_NO_PATHCONV=1, common for docker), Git for Windows got + * the raw /tmp/... path and could not open it, so an install could not move. + * + * Windows-safe by construction: gstack-ff-update runs through + * lib/script-invocation.ts (Git Bash on win32), fixtures live under os.tmpdir(). + */ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { runScriptSync } from '../lib/script-invocation'; + +const ROOT = path.resolve(import.meta.dir, '..'); +let base = ''; +let env: NodeJS.ProcessEnv = {}; +const posix = (p: string) => p.replace(/\\/g, '/'); + +function git(cwd: string, ...args: string[]): string { + const r = spawnSync('git', args, { cwd, env, encoding: 'utf8', timeout: 30_000 }); + expect(r.status, r.stderr).toBe(0); + return r.stdout.trim(); +} + +beforeEach(() => { + base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ff-msys-'))); + const home = path.join(base, 'home'); + fs.mkdirSync(home); + fs.writeFileSync(path.join(home, '.gitconfig'), '[user]\n\tname = t\n\temail = t@t\n[commit]\n\tgpgsign = false\n'); + env = { ...process.env, HOME: home, USERPROFILE: home, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: path.join(home, '.gitconfig'), GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined }; +}); +afterEach(() => fs.rmSync(base, { recursive: true, force: true })); + +test('an install moves to a new release with MSYS path conversion off (#3468)', () => { + const origin = path.join(base, 'origin.git'); + const seed = path.join(base, 'seed'); + const install = path.join(base, 'install'); + git(base, 'init', '-q', '--bare', '-b', 'main', origin); + fs.mkdirSync(seed); + fs.writeFileSync(path.join(seed, 'VERSION'), '1.0.0\n'); + git(seed, 'init', '-q', '-b', 'main'); + git(seed, 'add', '-A'); + git(seed, 'commit', '-q', '-m', 'seed'); + git(seed, 'remote', 'add', 'origin', origin); + git(seed, 'push', '-q', 'origin', 'main'); + git(base, 'clone', '-q', origin, install); + fs.writeFileSync(path.join(seed, 'VERSION'), '1.1.0\n'); + git(seed, 'commit', '-q', '-am', 'release'); + git(seed, 'push', '-q', 'origin', 'main'); + git(install, 'fetch', '-q'); + fs.writeFileSync(path.join(install, 'local-note.txt'), 'kept\n'); + const r = runScriptSync(path.join(ROOT, 'bin', 'gstack-ff-update'), [posix(install), 'origin/main'], { + cwd: base, env: { ...env, MSYS_NO_PATHCONV: '1' }, encoding: 'utf8', timeout: 120_000, + }); + expect(r.error).toBeUndefined(); + expect(r.status, `${r.stdout}${r.stderr}`).toBe(0); + expect(git(install, 'rev-parse', 'HEAD')).toBe(git(seed, 'rev-parse', 'HEAD')); + expect(fs.readFileSync(path.join(install, 'local-note.txt'), 'utf8')).toBe('kept\n'); +}, 150_000); diff --git a/test/gen-skill-docs-install-root-windows.test.ts b/test/gen-skill-docs-install-root-windows.test.ts new file mode 100644 index 000000000..9f65749a9 --- /dev/null +++ b/test/gen-skill-docs-install-root-windows.test.ts @@ -0,0 +1,37 @@ +/** + * #3498: on Windows, Git Bash converts setup's /c/... install root to C:/... + * when it hands the argument to native Bun, and the renderer refused any root + * not starting with "/", so a custom-root install kept the default-install + * paths. A drive-letter root is accepted and rendered in the /c/... form that + * setup verified; anything else outside the plain charset still refuses. + */ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { bunIsolationArgs } from '../lib/bun-isolation'; + +const ROOT = path.resolve(import.meta.dir, '..'); +let out = ''; + +beforeEach(() => { out = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-install-root-'))); }); +afterEach(() => fs.rmSync(out, { recursive: true, force: true })); + +const render = (installRoot: string) => spawnSync(process.execPath, [ + ...bunIsolationArgs(), path.join(ROOT, 'scripts', 'gen-skill-docs.ts'), '--host', 'claude', '--out-dir', path.join(out, 'render'), '--install-root', installRoot, +], { cwd: ROOT, encoding: 'utf8', timeout: 240_000 }); + +test('a drive-letter install root renders as its /c/... spelling (#3498)', () => { + const r = render('C:/Users/example/isolated/claude-home/skills/gstack'); + expect(r.status, r.stderr).toBe(0); + const skill = fs.readFileSync(path.join(out, 'render', 'SKILL.md'), 'utf8'); + expect(skill).toContain('/c/Users/example/isolated/claude-home/skills/gstack/bin/gstack-skill-start --skill'); + expect(skill).not.toContain('C:/Users/example'); +}, 260_000); + +test('a root with a space still refuses (#3498)', () => { + const r = render('C:/Users/First Last/skills/gstack'); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain('--install-root must be'); +}, 260_000); diff --git a/test/gstack-doctor.test.ts b/test/gstack-doctor.test.ts index 1becf6c50..a7f0a3d54 100644 --- a/test/gstack-doctor.test.ts +++ b/test/gstack-doctor.test.ts @@ -1131,6 +1131,14 @@ describe('gstack-doctor: rescue refs, CLAUDE.md skill lists, Windows bash', () = expect(override.row('windows bash').detail).toBe('D:/tools/bash.exe (chosen by GSTACK_GIT_BASH)'); }); + test('windows bash: an exported MSYS_NO_PATHCONV warns that Bun commands cannot start (#3468)', () => { + const f = makeFixture(); + const r = doctor(f, [], { GSTACK_DOCTOR_PLATFORM: 'windows', MSYS_NO_PATHCONV: '1', GSTACK_DOCTOR_BASH_PROBE: probe(f, { exists: [GIT_BASH], gitExecPath: 'C:/Program Files/Git/mingw64/libexec/git-core' }) }); + expect(r.row('windows bash').state, r.out).toBe('warn'); + expect(r.row('windows bash').detail).toContain('MSYS_NO_PATHCONV is set'); + expect(r.row('windows bash').fix).toContain('#msys-no-pathconv'); + }); + test('windows bash: GSTACK_GIT_BASH and a different deprecated GSTACK_BASH warn naming both', () => { const f = makeFixture(); 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'] }) }); diff --git a/test/gstack-wtree-windows-paths.test.ts b/test/gstack-wtree-windows-paths.test.ts index 6ac504946..68926b33a 100644 --- a/test/gstack-wtree-windows-paths.test.ts +++ b/test/gstack-wtree-windows-paths.test.ts @@ -63,6 +63,14 @@ test('a linked worktree with a private object directory gets its fingerprint (#3 expectFingerprintOfHead(); }); +// #3468: with MSYS path conversion off (MSYS_NO_PATHCONV=1, common for docker +// calls), Git for Windows received the raw /tmp/... temp index path and could +// not open it. +test('the temp index reaches git with MSYS path conversion off (#3468)', () => { + env = { ...env, MSYS_NO_PATHCONV: '1' }; + expectFingerprintOfHead(); +}); + // Linux and macOS reproduce Git for Windows: `--git-path` answers in drive-letter // form, and GIT_ALTERNATE_OBJECT_DIRECTORIES is one ;-separated list in which C:/… // is absolute. Linux git splits on ':', so the shim hands that list over as one diff --git a/test/setup-install-root-windows.test.ts b/test/setup-install-root-windows.test.ts new file mode 100644 index 000000000..bea39c93d --- /dev/null +++ b/test/setup-install-root-windows.test.ts @@ -0,0 +1,56 @@ +/** + * #3498, installer side: setup's _render_install must accept a custom Windows + * install root in either spelling (/c/... or C:/...), pass its plain-path + * guard, and hand native Bun an --install-root the renderer accepts. Bun is a + * native program, so Git Bash converts /c/... to C:/... on the way in; the + * renderer test (gen-skill-docs-install-root-windows) covers what it does then. + * + * Windows only: the guard and the conversion are Git Bash behavior. The + * function is sliced from setup and run through explicit bash; bun_cmd is a + * native Bun that records the argv it received and fails the render. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { runBashSync } from '../lib/script-invocation'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SETUP = fs.readFileSync(path.join(ROOT, 'setup'), 'utf8'); +const posix = (p: string) => p.replace(/\\/g, '/'); + +function slice(name: string): string { + const start = SETUP.indexOf(`\n${name}() {`); + const end = SETUP.indexOf('\n}\n', start); + if (start < 0 || end < 0) throw new Error(`cannot find ${name}() in setup`); + return SETUP.slice(start + 1, end + 2); +} + +let tmp = ''; +beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-setup-root-'))); }); +afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); + +describe.skipIf(process.platform !== 'win32')('setup _render_install with a custom Windows root (#3498)', () => { + for (const root of ['/c/Users/example/isolated/claude-home/skills/gstack', 'C:/Users/example/isolated/claude-home/skills/gstack']) { + test(`root spelled ${root.slice(0, 2)}... passes the guard and reaches the renderer`, () => { + const argvFile = posix(path.join(tmp, 'argv.json')); + const script = [ + 'IS_WINDOWS=1', + `HOME=${JSON.stringify(posix(tmp))}`, + `SOURCE_GSTACK_DIR=${JSON.stringify(posix(ROOT))}`, + `GSTACK_STATE_ROOT=${JSON.stringify(posix(path.join(tmp, 'state')))}`, + '_GSTACK_OVERLAY=claude', + `gstack_install_render_dir() { printf '%s\\n' ${JSON.stringify(posix(path.join(tmp, 'render')))}; }`, + `bun_cmd() { ${JSON.stringify(posix(process.execPath))} -e 'require("fs").writeFileSync(process.env.ARGV_FILE, JSON.stringify(process.argv.slice(1)))' -- "$@"; return 1; }`, + `export ARGV_FILE=${JSON.stringify(argvFile)}`, + slice('_render_install'), + `_render_install claude ${JSON.stringify(root)}; echo "rc=$?"`, + ].join('\n'); + const r = runBashSync(script, { encoding: 'utf8', timeout: 60_000 }); + expect(r.stderr).not.toContain('is not a plain path'); + const argv: string[] = JSON.parse(fs.readFileSync(path.join(tmp, 'argv.json'), 'utf8')); + const given = argv[argv.indexOf('--install-root') + 1]; + expect(given, JSON.stringify(argv)).toMatch(/^(?:[Cc]:|\/c)\/Users\/example\/isolated\/claude-home\/skills\/gstack$/); + }); + } +});