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$/);
+ });
+ }
+});