v1.91.89.0 fix(windows,setup): Git Bash hook false-positive recovery, incoming-checker auto-update, setup never deletes its own checkout (Wave 9 Lane A0) (#3140)

Git Bash hands native Bun C:/ paths while gstack-hook-check looked them up as
/c/, so every TypeScript hook was refused and team-mode auto-update held every
release since 1.91.31. The checker maps Bun's paths back to the shell spelling,
the auto-updater lets the incoming release's own checker decide (installed
checker only as a fallback, hook-check-unavailable when neither verifies), and
docs/troubleshooting.md gives stuck installs one recovery command.
/gstack-upgrade and /spec guards use the --show-prefix root check, gstack-wtree
treats drive-letter --git-path answers as absolute, and setup refuses to
replace a runtime root that is or contains its own checkout, Kiro's in-place
root included (#3084), with --status suggesting the move. New sourced helper
bin/gstack-path-spelling.sh and TS twin lib/path-spelling.ts.

Co-authored-by: yuriysan <63948747+yuriysan@users.noreply.github.com>
Co-authored-by: y$un_ <3579304+szsunyuan@users.noreply.github.com>
Co-authored-by: Frederik Reventlow <267160539+dmfrede@users.noreply.github.com>
Co-authored-by: skleinbe-ny <226563731+skleinbe-ny@users.noreply.github.com>
Co-authored-by: bkermen <184220642+bkermen@users.noreply.github.com>
Co-authored-by: Drew Jankowski <3200063+andrewkjankowski@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-11 16:48:11 +00:00
1 parent 3d52b26905
commit e6edff0265
27 files changed
+1253 -110

No files matched your search

+73 -1
View File
@@ -12,7 +12,11 @@ name: Windows Setup E2E
# binary must answer the side-effect-free --version that setup's Smart App
# Control probe runs (#2595). Last, two forced CSO failures: fatal under
# GSTACK_STRICT_BUILD=1, and a completed setup with the failure reported
# under GSTACK_STRICT_BUILD=0 (#3071).
# under GSTACK_STRICT_BUILD=0 (#3071). Git Bash path spelling (#3088,
# #3100): the hook check passes on this checkout, /gstack-upgrade's checkout
# guard passes at its root, and an install stuck on a release whose checker
# had the false positive recovers with the documented
# `git pull --ff-only --autostash && ./setup`.
#
# Separate from windows-free-tests.yml because that one runs a curated
# unit-test subset; this one exercises the install path itself.
@@ -40,6 +44,12 @@ on:
- 'bin/gstack-paths'
- 'bin/gstack-doctor'
- 'bin/gstack-launch-probe.sh'
- 'bin/gstack-hook-check'
- 'bin/gstack-session-update'
- 'bin/gstack-path-spelling.sh'
- 'lib/path-spelling.ts'
- 'hosts/claude/hooks/**'
- 'gstack-upgrade/**'
- '.github/workflows/windows-setup-e2e.yml'
workflow_dispatch:
@@ -191,6 +201,68 @@ jobs:
echo "$EMPTY_OUT" | grep -q 'no flow to run' || (echo "empty chain lacks the usage error" && exit 1)
shell: bash
- name: The hook check passes on this checkout under Git Bash (#3088, #3100)
run: |
set -uo pipefail
OUT=$(bash bin/gstack-hook-check "$PWD" 2>&1)
CODE=$?
echo "$OUT"
test "$CODE" -eq 0 || (echo "gstack-hook-check exited $CODE" && exit 1)
! echo "$OUT" | grep -q '^fail ' || (echo "gstack-hook-check reported a fail line" && exit 1)
echo "$OUT" | grep -q '^ok hosts/claude/hooks/question-log-hook$' || (echo "the TypeScript hooks were not checked" && exit 1)
shell: bash
- name: /gstack-upgrade's checkout guard passes at the checkout root and refuses below it
run: |
set -euo pipefail
GUARD=$(grep -m1 -- '--show-prefix' gstack-upgrade/SKILL.md.tmpl)
echo "guard: $GUARD"
INSTALL_DIR="$PWD" bash -c "cd \"\$INSTALL_DIR\" && $GUARD && echo GUARD_OK" | grep -q GUARD_OK || (echo "the guard refused the checkout root" && exit 1)
if INSTALL_DIR="$PWD/bin" bash -c "cd \"\$INSTALL_DIR\" && $GUARD && echo GUARD_OK" 2>/dev/null | grep -q GUARD_OK; then
echo "the guard accepted a directory below the root" && exit 1
fi
shell: bash
- name: An install stuck on the false positive recovers with the documented command (#3088, #3100)
# A release whose bin/gstack-hook-check had the Git Bash false positive
# (v1.91.83.0's checker on this tree), then this revision on top. The
# recovery is the one docs/troubleshooting.md gives.
run: |
set -euo pipefail
OLD_RELEASE=5cb5e1cfc115e82388df07896664640a1224fe5d
T="$RUNNER_TEMP/recovery"
E2E_HOME="$T/home"
INSTALL="$E2E_HOME/.claude/skills/gstack"
mkdir -p "$E2E_HOME/.claude/skills" "$T"
git fetch -q --no-tags --depth=1 origin "$OLD_RELEASE"
git show "$OLD_RELEASE:bin/gstack-hook-check" > "$T/old-check"
git init -q --bare -b main "$T/origin.git"
git -C "$T/origin.git" config receive.shallowUpdate true
git clone -q --no-local "$PWD" "$T/work"
cp "$T/old-check" "$T/work/bin/gstack-hook-check"
git -C "$T/work" commit -qam "release with the false positive"
STUCK=$(git -C "$T/work" rev-parse HEAD)
git -C "$T/work" checkout -q HEAD~1 -- bin/gstack-hook-check
git -C "$T/work" commit -qm "this revision"
git -C "$T/work" push -q "$T/origin.git" HEAD:refs/heads/main
git clone -q "$T/origin.git" "$INSTALL"
git -C "$INSTALL" reset -q --hard "$STUCK"
set +e
BEFORE=$(bash "$INSTALL/bin/gstack-hook-check" "$INSTALL" 2>&1)
set -e
echo "$BEFORE"
echo "$BEFORE" | grep -q 'bun could not bundle the TypeScript entry' || (echo "the old release's checker did not show the false positive" && exit 1)
set +e
OUT=$(cd "$INSTALL" && git pull --ff-only --autostash && HOME="$E2E_HOME" USERPROFILE="$(cygpath -w "$E2E_HOME")" \
GSTACK_SKIP_PLAYWRIGHT=1 GSTACK_STRICT_BUILD=0 ./setup 2>&1)
CODE=$?
set -e
echo "$OUT" | tail -n 40
test "$CODE" -eq 0 || (echo "the documented recovery exited $CODE" && exit 1)
! echo "$OUT" | grep -q 'refusing to register hooks' || (echo "setup still refused hooks after the recovery" && exit 1)
test "$(git -C "$INSTALL" rev-parse HEAD)" != "$STUCK" || (echo "the recovery did not move the checkout" && exit 1)
shell: bash
- name: A forced CSO failure is fatal under GSTACK_STRICT_BUILD=1 (#3071)
run: |
set -uo pipefail
+27
View File
@@ -1,5 +1,32 @@
# Changelog
## [1.91.89.0] - 2026-10-11
**Windows installs stuck on the Git Bash hook false positive get a one-line recovery, the auto-updater stops trusting only its own checker, and setup can no longer delete the checkout it runs from.**
Since 1.91.31, `gstack-hook-check` on Windows (Git Bash) reported every TypeScript hook as `bun could not bundle the TypeScript entry`: Git Bash hands native Bun `C:/…` paths while the checker looked them up as `/c/…`. Setup refused the hooks, and team-mode auto-update held every later release because it re-checked each one with the broken installed checker. Code on main cannot reach those installs, so the troubleshooting page their messages link to now carries the recovery.
### What this means for you
- **Stuck on Windows?** Run `cd ~/.claude/skills/gstack && git pull --ff-only --autostash && ./setup` (your install directory if it differs). It clears the setup refusal, the held auto-update and `/gstack-upgrade`'s `is not a gstack checkout` refusal. `docs/troubleshooting.md#auto-update-hook-does-not-parse` and `#setup-hook-does-not-parse` say so instead of "Nothing to do locally".
- **The hook check reads both path spellings.** Bun's entry, input and error paths are mapped back to Git Bash's spelling, so healthy hooks pass and a real syntax error still fails with its own file and line.
- **One checker bug can't hold every release again.** The auto-updater runs the incoming release's own `bin/gstack-hook-check` on the extracted archive. Only a verified, complete report counts; a crash, empty output or unreadable report falls back to the installed checker, and when neither gives one the update is held as `hook-check-unavailable` with the fix command. A pass the installed checker disputes is logged as `HOOK_CHECK_DISAGREE`.
- **`/gstack-upgrade` and `/spec` work in Git Bash.** Their checkout-root guards check `git rev-parse --show-prefix` instead of comparing `--show-toplevel` (`C:/…`) with `pwd -P` (`/c/…`); a static test rejects the old comparison in skill bash.
- **`/review` and `/ship` record a start in a linked worktree on Windows.** `gstack-wtree` treats Git for Windows' `C:/…` `--git-path` answers as absolute (#3101).
- **Setup never replaces a runtime root that holds its own checkout (#3084).** With the checkout at `~/.cursor/skills/gstack`, `./setup --host cursor` used to swap the Cursor runtime root in over it and delete it. Setup now refuses before writing anything (and again in `_activate_runtime_root`, which every host arm that swaps a root goes through; Kiro, which writes into its root in place, is refused too), prints the commands that move the checkout out, and `./setup --status` suggests that move instead of the destructive command. The install summary says "previous install was left in place" only after checking that the checkout, skills directory and runtime root still exist.
### For contributors
- `bin/gstack-path-spelling.sh` (shell spelling, native spelling, same-path, absolute, within) and its TS twin `lib/path-spelling.ts` own the `/c/…` vs `C:/…` class; only this release's call sites moved to them.
- The Windows lane force-includes `test/setup-hook-syntax-gate.test.ts`; `windows-setup-e2e` runs the hook check on the checkout, the upgrade guard, and the documented recovery from a release with the false positive.
Thanks @yuriysan (#3112)
Thanks @szsunyuan (#3108, #3128)
Thanks @dmfrede (#3101)
Thanks @skleinbe-ny (#3088)
Thanks @bkermen (#3100)
Thanks @andrewkjankowski (#3084)
## [1.91.88.0] - 2026-10-11
**Transcript ingest lands on gbrain 0.60, and `/retro global` counts every Claude Code session in its real repository.**
+1 -1
View File
@@ -1 +1 @@
1.91.88.0
1.91.89.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.88.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.89.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+30 -10
View File
@@ -34,7 +34,7 @@
# missing <hook> (no shim: setup skips registering it)
# fail <hook> <file>:<line>: <message>
# Exit: 0 when nothing failed, 1 when a hook failed, 2 on usage, 3 when bun
# (needed for the TypeScript check) is not found. Set GSTACK_HOOK_CHECK_BUN to
# (needed when a hook runs TypeScript) is not found. Set GSTACK_HOOK_CHECK_BUN to
# the bun to run (setup passes its own).
set -u
@@ -51,9 +51,22 @@ if [ -z "$ROOT" ] || [ ! -d "$ROOT" ]; then
echo "usage: gstack-hook-check [--setup <file>] [--list] <gstack root>" >&2
exit 2
fi
# A Windows path (D:\x or D:/x) from a native caller: use Git Bash's spelling.
case "$ROOT" in [A-Za-z]:[\\/]*) command -v cygpath >/dev/null 2>&1 && ROOT=$(cygpath -u "$ROOT") ;; esac
case "$ROOT" in /*|[A-Za-z]:[\\/]*) ;; *) ROOT="$PWD/$ROOT" ;; esac
# Git Bash hands native Bun its drive paths as C:/x, and spells the native temp
# directory /tmp: one spelling (/c/x) on both sides of every built.tsv lookup
# (#3088, #3100). The checker is self-contained on purpose (it runs on an
# extracted archive and must not fail for a missing helper); the same mapping
# is gstack_path_shell in bin/gstack-path-spelling.sh, and
# test/path-spelling.test.ts keeps the two equal.
case "$(uname -s 2>/dev/null)" in
MINGW*|MSYS*)
_native=$(cygpath -am "$ROOT" 2>/dev/null) || _native=""
case "$_native" in
[A-Za-z]:/*) ROOT="/$(printf '%s' "${_native%%:*}" | tr '[:upper:]' '[:lower:]')${_native#?:}" ;;
*) case "$ROOT" in [A-Za-z]:[\\/]*) command -v cygpath >/dev/null 2>&1 && ROOT=$(cygpath -u "$ROOT") ;; esac ;;
esac ;;
CYGWIN*) case "$ROOT" in [A-Za-z]:[\\/]*) ROOT=$(cygpath -u "$ROOT") ;; esac ;;
esac
[ "$ROOT" = / ] || ROOT=${ROOT%/}
SETUP_FILE="${SETUP_FILE:-$ROOT/setup}"
@@ -75,10 +88,6 @@ if [ "$LIST_ONLY" -eq 1 ]; then
fi
BUN="${GSTACK_HOOK_CHECK_BUN:-bun}"
if ! command -v "$BUN" >/dev/null 2>&1; then
echo "gstack-hook-check: bun not found, so TypeScript hook entries cannot be checked" >&2
exit 3
fi
TMP_OUT=$(mktemp -d "${TMPDIR:-/tmp}/gstack-hook-check.XXXXXX") || { echo "gstack-hook-check: cannot create a temp directory" >&2; exit 1; }
trap 'rm -rf "$TMP_OUT"' EXIT
@@ -155,16 +164,27 @@ for hook in $HOOKS; do
done
BUILT="$TMP_OUT/built.tsv"
: > "$BUILT"
if [ -n "$ENTRIES" ] && ! command -v "$BUN" >/dev/null 2>&1; then
echo "gstack-hook-check: bun not found, so TypeScript hook entries cannot be checked" >&2
exit 3
fi
if [ -n "$ENTRIES" ]; then
# Bun reports native spellings; shellPath maps them back to the keys looked
# up below (lib/path-spelling.ts shellPath; test/path-spelling.test.ts keeps
# the two equal).
# shellcheck disable=SC2086 # newline-split list (IFS is set to a newline below)
(cd "$ROOT" && IFS=$NL && "$BUN" -e '
const shellPath = (value) => process.platform === "win32"
? String(value).replace(/\\/g, "/").replace(/^([A-Za-z]):(?=\/|$)/, (_, drive) => `/${drive.toLowerCase()}`)
: String(value);
for (const entry of process.argv.slice(1)) {
const key = shellPath(entry);
const r = await Bun.build({ entrypoints: [entry], target: "bun", packages: "external", metafile: true, throw: false }).catch(e => ({ success: false, logs: [e] }));
if (r.success) for (const input of Object.keys(r.metafile?.inputs ?? {})) console.log(`${entry}\tok\t${input}`);
if (r.success) for (const input of Object.keys(r.metafile?.inputs ?? {})) console.log(`${key}\tok\t${shellPath(input)}`);
else {
const log = (r.logs ?? []).find(l => l?.level === "error") ?? r.logs?.[0] ?? {};
const at = log.position ? `${log.position.file}:${log.position.line}` : entry;
console.log(`${entry}\tfail\t${at}\t${String(log.message ?? log).split("\n")[0]}`);
const at = log.position ? `${shellPath(log.position.file)}:${log.position.line}` : key;
console.log(`${key}\tfail\t${at}\t${String(log.message ?? log).split("\n")[0]}`);
}
}' $ENTRIES) > "$BUILT" 2>/dev/null || true
fi
+29 -1
View File
@@ -25,6 +25,28 @@
# Callers must set GSTACK_STATE_ROOT first (bin/gstack-state-root.sh). Bash 3.2
# clean; no flock (macOS lacks it), so the lock is an atomic mkdir.
# Same-path and containment checks (bin/gstack-path-spelling.sh).
. "$(dirname "${BASH_SOURCE[0]}")/gstack-path-spelling.sh" 2>/dev/null || true
# gstack_install_move_hint SRC HOST — when HOST's runtime root ROOT is SRC (the
# checkout) or contains it, `./setup --host HOST` would replace the checkout
# (#3084): print the commands that move it out first and return 0. A symlinked
# ~/.claude/skills/gstack that points at SRC becomes the checkout itself.
# Kiro writes its runtime files into ROOT in place, which would link the
# checkout into itself, so it counts too. Claude installs in place from the
# checkout, and a ROOT that is a symlink is replaced as a link: those return 1.
gstack_install_move_hint() {
local src="$1" host="$2" root="$3" claude_link="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack"
[ "$host" = claude ] && return 1
[ -L "$root" ] && return 1
command -v gstack_path_within >/dev/null 2>&1 && gstack_path_within "$src" "$root" || return 1
if [ -L "$claude_link" ] && gstack_path_same "$claude_link" "$src"; then
printf 'rm %s && mv %s %s && cd %s && ./setup --host claude && ./setup --host %s\n' "$claude_link" "$src" "$claude_link" "$claude_link" "$host"
else
printf 'mv %s ~/gstack && cd ~/gstack && ./setup --host %s\n' "$src" "$host"
fi
}
gstack_install_registry_file() {
printf '%s/installs.tsv\n' "${GSTACK_STATE_ROOT:?gstack-install-registry: GSTACK_STATE_ROOT is not set}"
}
@@ -242,7 +264,7 @@ gstack_install_known_roots() {
# _gstack_status_rows — summary rows (see gstack_install_registry_render) for
# every registered install, then every unregistered install found on disk.
_gstack_status_rows() {
local host scope project dest root src ver prefix render ts cur result note seen="|"
local host scope project dest root src ver prefix render ts cur result note move seen="|"
while IFS=' ' read -r host scope project dest root src ver prefix render ts; do
[ -n "$host" ] || continue
seen="$seen$root|"
@@ -257,6 +279,9 @@ _gstack_status_rows() {
else
result=stale; note="refresh: cd $src && ./setup --host $host"
fi
if [ "$result" != missing ] && move="$(gstack_install_move_hint "$src" "$host" "$root")"; then
note="the gstack checkout lives in this host's runtime root ($root), so ./setup --host $host would replace it; move it first: $move (docs/troubleshooting.md#setup-runtime-root-contains-checkout)"
fi
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$host" "$scope" "$src" "$dest" "$ver" "$cur" "$result" "$note"
done <<ROWS
$(gstack_install_registry_rows)
@@ -273,6 +298,9 @@ ROWS
if [ "$scope" = global ] && [ "$host" != claude ] && _gstack_vendored_source "$src"; then
note="captured by the project-vendored copy at $src (#2879); choose: keep it (cd $src && ./setup --host $host --global) or repoint it at your global checkout (cd <global checkout> && ./setup --host $host)"
fi
if move="$(gstack_install_move_hint "$src" "$host" "$root")"; then
note="installed before the registry, and the gstack checkout lives in this host's runtime root ($root), so ./setup --host $host would replace it; move it first: $move (docs/troubleshooting.md#setup-runtime-root-contains-checkout)"
fi
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$host" "$scope" "$src" "$dest" "-" "$cur" unregistered "$note"
done <<ROOTS
$(gstack_install_known_roots)
+87
View File
@@ -0,0 +1,87 @@
# shellcheck shell=bash
# gstack-path-spelling.sh — sourced helper: one spelling for paths on Windows
# shells. Git Bash (MSYS) spells a drive path /c/x, native programs (git.exe,
# bun.exe) answer C:/x or C:\x, and MSYS converts /c/x arguments to C:/x before
# a native program starts. Comparing two spellings as strings is the bug class
# behind #3088, #3100 and #3101. TS twin: lib/path-spelling.ts.
#
# gstack_path_shell PATH Git Bash's drive spelling (/c/x) of an absolute
# PATH, mount aliases such as /tmp resolved through
# their drive path; unchanged outside Windows shells
# gstack_path_native PATH the native spelling (C:/x) on Windows shells;
# unchanged elsewhere
# gstack_path_same A B true when A and B name the same file: filesystem
# identity when both exist, else equal shell
# spellings
# gstack_path_absolute PATH true when PATH is absolute in any spelling (/x,
# C:/x, C:\x): Git for Windows answers C:/x
# gstack_path_within PATH DIR
# true when the existing DIR is PATH or one of its
# ancestors, by gstack_path_same on each ancestor
# of the absolute PATH
#
# No side effects beyond the _GSTACK_PS_* variables; safe under set -u.
case "$(uname -s 2>/dev/null)" in
MINGW*|MSYS*) _GSTACK_PS_KIND=msys ;;
CYGWIN*) _GSTACK_PS_KIND=cygwin ;;
*) _GSTACK_PS_KIND=posix ;;
esac
gstack_path_shell() {
case "$_GSTACK_PS_KIND" in
posix) printf '%s\n' "$1"; return 0 ;;
cygwin) cygpath -u "$1" 2>/dev/null || printf '%s\n' "$1"; return 0 ;;
esac
_gps_p=$(cygpath -am "$1" 2>/dev/null) || _gps_p=""
[ -n "$_gps_p" ] || _gps_p=$1
_gps_p=$(printf '%s' "$_gps_p" | tr '\\' '/')
case "$_gps_p" in
[A-Za-z]:|[A-Za-z]:/*)
_gps_d=$(printf '%s' "${_gps_p%%:*}" | tr '[:upper:]' '[:lower:]')
_gps_p="/$_gps_d${_gps_p#?:}" ;;
esac
printf '%s\n' "$_gps_p"
}
gstack_path_native() {
case "$_GSTACK_PS_KIND" in
posix) printf '%s\n' "$1"; return 0 ;;
esac
_gpn_p=$(cygpath -am "$1" 2>/dev/null) || _gpn_p=""
if [ -z "$_gpn_p" ]; then
_gpn_p=$(printf '%s' "$1" | tr '\\' '/')
case "$_gpn_p" in
/[A-Za-z]|/[A-Za-z]/*)
_gpn_d=$(printf '%s' "${_gpn_p:1:1}" | tr '[:lower:]' '[:upper:]')
_gpn_p="$_gpn_d:${_gpn_p:2}"
[ -n "${_gpn_p#?:}" ] || _gpn_p="$_gpn_p/" ;;
esac
fi
printf '%s\n' "$_gpn_p"
}
gstack_path_same() {
if { [ -e "$1" ] || [ -L "$1" ]; } && { [ -e "$2" ] || [ -L "$2" ]; }; then
[ "$1" -ef "$2" ]
return
fi
[ "$(gstack_path_shell "$1")" = "$(gstack_path_shell "$2")" ]
}
gstack_path_absolute() {
case "$1" in /*|[A-Za-z]:[\\/]*) return 0 ;; esac
return 1
}
gstack_path_within() {
[ -e "$2" ] || return 1
_gpw_p=$1
while [ -n "$_gpw_p" ]; do
gstack_path_same "$_gpw_p" "$2" && return 0
case "$_gpw_p" in /|[A-Za-z]:|[A-Za-z]:/) return 1 ;; esac
_gpw_p=${_gpw_p%/}
case "$_gpw_p" in */*) _gpw_p=${_gpw_p%/*}; [ -n "$_gpw_p" ] || _gpw_p=/ ;; *) return 1 ;; esac
done
return 1
}
+86 -13
View File
@@ -11,9 +11,12 @@
# changes (E1). Then it parse-checks every hook the incoming revision registers
# (bin/gstack-hook-check on a `git archive` of that revision): Claude Code runs
# hooks straight from this checkout, so an unparseable hook would block tool
# calls the moment the checkout moved. A failure holds the update with reason
# hook-does-not-parse and its file:line. Updaters older than a check cannot
# protect their first pull.
# calls the moment the checkout moved. The incoming revision's own checker
# decides; the installed one decides only when that gives no verified report.
# A failure holds the update with reason hook-does-not-parse and its
# file:line; no verified report from either holds it as
# hook-check-unavailable. Updaters older than a check cannot protect their
# first pull.
#
# Stages (B11): pull, setup and migrations are tracked separately. The
# "just upgraded" marker is written only when every stage succeeded. A failed
@@ -43,26 +46,94 @@ LOCK_DIR="$STATE_DIR/.setup-lock"
LOG_FILE="$STATE_DIR/analytics/session-update.log"
PENDING_FILE="$STATE_DIR/session-update-pending"
HOOK_CHECK="$(cd "$(dirname "$0")" && pwd)/gstack-hook-check"
# Path spelling (Git Bash /c/x vs native C:/x) for the held-reason prefix strip.
. "$(cd "$(dirname "$0")" && pwd)/gstack-path-spelling.sh" 2>/dev/null || { gstack_path_shell() { printf '%s\n' "$1"; }; gstack_path_native() { printf '%s\n' "$1"; }; }
THROTTLE_SECONDS=3600 # 1 hour
log_entry() {
mkdir -p "$(dirname "$LOG_FILE")"
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $1" >> "$LOG_FILE" 2>/dev/null || true # best-effort: logging never blocks a session
}
# hook_report <checker> <tree> — sets _hr_out to the checker's report on
# <tree> and returns 0 only when the report is verified and complete: exit 0
# with no fail line or exit 1 with one, every line `ok|missing <hook>` or
# `fail <hook> <where>: <why>`, and a line for each hook its own --list names. An empty list counts only from the
# installed checker (a tree without hooks). A crash, empty output or an
# unparseable report returns 1 with the reason in _hr_why. (No command
# substitution around it: the variables must reach the caller.)
hook_report() {
_hr_why="" _hr_rc=0
_hr_list=$(bash "$1" --list "$2" 2>/dev/null) || { _hr_why="--list exited $?"; return 1; }
if [ -z "$_hr_list" ] && [ "$1" != "$HOOK_CHECK" ]; then _hr_why="empty hook list"; return 1; fi
_hr_out=$(bash "$1" "$2" 2>"$_hc_dir.err") || _hr_rc=$?
case "$_hr_rc" in
0|1) ;;
*) _hr_why="exited $_hr_rc$(head -1 "$_hc_dir.err" 2>/dev/null | head -c 160 | sed 's/^/: /')"; rm -f "$_hc_dir.err"; return 1 ;;
esac
rm -f "$_hc_dir.err"
if ! printf '%s\n' "$_hr_out" | HR_LIST="$_hr_list" HR_RC="$_hr_rc" awk '
BEGIN { n = split(ENVIRON["HR_LIST"], h, "\n"); for (i = 1; i <= n; i++) if (h[i] != "") want[h[i]] = 1 }
$0 == "" { next }
/^fail [^ ]+ .+: / { if (!($2 in want)) bad = 1; failed[$2] = 1; any = 1; next }
/^(ok|missing) [^ ]+$/ { if (!($2 in want) || passed[$2]++) bad = 1; next }
{ bad = 1 }
END {
for (k in want) if ((k in passed) == (k in failed)) bad = 1
if (bad || (ENVIRON["HR_RC"] == 1) != (any == 1)) exit 1
}'; then
_hr_why="unparseable or incomplete report"
return 1
fi
}
# hook_incoming_hold <rev> — parse-check the hooks of <rev> (extracted with
# git archive) before the live checkout moves. Prints the held reason and
# returns 0 when a hook does not parse; otherwise prints nothing and returns 1
# (an archive or bun failure fails open, like every other error here).
# git archive) before the live checkout moves. The incoming revision's own
# bin/gstack-hook-check decides, so one checker bug in this install cannot hold
# every later release; when it gives no verified report (crash, empty output,
# unparseable), the installed checker decides. Prints the held reason and
# returns 0 when a hook does not parse or neither checker gave a verified
# report; otherwise prints nothing and returns 1. A pass from the incoming
# checker that the installed one disputes is logged.
hook_incoming_hold() {
_hc_dir=$(mktemp -d "${TMPDIR:-/tmp}/gstack-session-hooks-XXXXXX" 2>/dev/null) || return 1
_hc_out=""
if git -C "$GSTACK_DIR" archive --format=tar "$1" 2>/dev/null | tar -xf - -C "$_hc_dir" 2>/dev/null; then
_hc_out=$("$HOOK_CHECK" "$_hc_dir" 2>/dev/null)
_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; }
_hc_out="" _hc_why=""
if ! git -C "$GSTACK_DIR" archive --format=tar "$1" 2>/dev/null | tar -xf - -C "$_hc_dir" 2>/dev/null; then
_hc_why="git archive of the incoming revision failed"
elif [ -f "$_hc_dir/bin/gstack-hook-check" ] && hook_report "$_hc_dir/bin/gstack-hook-check" "$_hc_dir"; then
_hc_out=$_hr_out
if ! printf '%s\n' "$_hc_out" | grep -q '^fail '; then
if hook_report "$HOOK_CHECK" "$_hc_dir"; then
_hc_inst=$(printf '%s\n' "$_hr_out" | sed -n 's/^fail [^ ]* //p' | head -1)
else
_hc_inst="no verified report ($_hr_why)"
fi
[ -z "$_hc_inst" ] || log_entry "HOOK_CHECK_DISAGREE incoming=pass installed=$(hook_hold_path "$_hc_inst" | head -c 200) rev=$1"
fi
else
[ -f "$_hc_dir/bin/gstack-hook-check" ] && log_entry "HOOK_CHECK_FALLBACK incoming checker gave no verified report ($_hr_why); the installed checker decides rev=$1"
if hook_report "$HOOK_CHECK" "$_hc_dir"; then
_hc_out=$_hr_out
else
_hc_why="the installed bin/gstack-hook-check gave no verified report either ($_hr_why)"
fi
fi
_hc_fail=$(hook_hold_path "$(printf '%s\n' "$_hc_out" | sed -n 's/^fail [^ ]* //p' | head -1)")
rm -rf "$_hc_dir" "$_hc_dir.err"
if [ -n "$_hc_why" ]; then
printf 'hook-check-unavailable: %s\n' "$_hc_why" | head -c 300
return 0
fi
rm -rf "$_hc_dir"
_hc_fail=$(printf '%s\n' "$_hc_out" | sed -n 's/^fail [^ ]* //p' | head -1)
[ -n "$_hc_fail" ] || return 1
printf 'hook-does-not-parse: %s\n' "${_hc_fail#"$_hc_dir"/}" | head -c 300
printf 'hook-does-not-parse: %s\n' "$_hc_fail" | head -c 300
}
# hook_hold_path <text> — <text> without the archive directory's prefix, in
# each spelling a checker reports it (/tmp/x, /c/…/x or C:/…/x).
hook_hold_path() {
_hp=$1
for _hp_dir in "$_hc_dir" "$(gstack_path_shell "$_hc_dir")" "$(gstack_path_native "$_hc_dir")"; do
_hp=${_hp#"$_hp_dir"/}
done
printf '%s\n' "$_hp"
}
pending_get() { sed -n "s/^$1=//p" "$PENDING_FILE" 2>/dev/null | head -1; }
# pending_save — persist the P_* stage state; nothing pending removes the file.
@@ -97,6 +168,8 @@ if [ -f "$PENDING_FILE" ]; then
echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed. Fix now: bun upgrade && cd $GSTACK_DIR && git pull --ff-only && ./setup" ;;
hook-does-not-parse*)
echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed, and your current hooks keep running. This is a gstack bug; report ${_P_PULL#hook-does-not-parse: } at https://github.com/garrytan/gstack/issues. gstack retries at the next check. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-does-not-parse" ;;
hook-check-unavailable*)
echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed, and your current hooks keep running. gstack retries at the next check. Fix now: cd $GSTACK_DIR && git pull --ff-only --autostash && ./setup (setup checks every hook it registers). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-check-unavailable" ;;
*)
echo "gstack auto-update: git pull did not finish ($_P_PULL); gstack is not updating. Fix now: cd $GSTACK_DIR && git pull --ff-only && ./setup" ;;
esac
+7 -9
View File
@@ -54,15 +54,13 @@ 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"
# Resolve relative --git-path output against the repo root.
case "$REAL_INDEX" in
""|/*) ;;
*) REAL_INDEX="$TOP/$REAL_INDEX" ;;
esac
case "$REAL_OBJECTS" in
""|/*) ;;
*) REAL_OBJECTS="$TOP/$REAL_OBJECTS" ;;
esac
# 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
export GIT_OBJECT_DIRECTORY="$OBJ_DIR"
export GIT_ALTERNATE_OBJECT_DIRECTORIES="$REAL_OBJECTS${GIT_ALTERNATE_OBJECT_DIRECTORIES:+:$GIT_ALTERNATE_OBJECT_DIRECTORIES}"
+107 -24
View File
@@ -659,6 +659,10 @@ inside gstack's own checkout. A block ran without the path Step 2 printed, so it
stopped instead of touching your project.
**Fix.** Run Step 2 again and prefix the block with `INSTALL_DIR=<printed path>`.
On Windows (Git Bash) with `INSTALL_DIR` set correctly, older /gstack-upgrade
text compared two spellings of the same path and refused anyway; recover with
`cd <install dir> && git pull --ff-only --autostash && ./setup` (see
[the hook false positive](#setup-hook-does-not-parse)).
<a id="skill-over-size-limit"></a>
### `<host>/<skill>/SKILL.md is <bytes> bytes, over the 160,000-byte limit. Fix: carve sections with usesLazySections() for this skill.`
@@ -721,23 +725,41 @@ Bun first.
<a id="setup-hook-does-not-parse"></a>
### `gstack setup: refusing to register hooks that do not parse (Claude Code would block tool calls with them): <file>:<line>: <error>`
**Meaning.** Claude Code runs gstack's hook shims through `/bin/sh`, and a hook
that does not parse exits 2, which blocks the tool call it guards in every
session. Setup parse-checks every hook it registers and every hook a skill's
frontmatter runs (`/autoplan`, `/careful`, `/freeze`, `/guard`, `/investigate`
and `/plan-ceo-review`): the shim, the gstack shell helpers it sources (such as
`careful/bin/hook-extract.sh` and `bin/gstack-state-root.sh`), and the
TypeScript it runs with its local imports. A merge conflict marker line in any
of those files fails as `unresolved merge conflict marker`, even when the file
still parses (markers inside a heredoc, a string or a template literal do).
The TypeScript is bundled from the gstack checkout, so the `tsconfig.json` or
`bunfig.toml` of the project you run it from is never read. It registers the
hooks that parse, skips the ones listed, finishes the rest of the install, and
exits non-zero. Claude Code runs hooks straight from `~/.claude/skills/gstack`,
so a skipped hook that an earlier setup registered, or that a skill runs,
keeps running the broken file until it is fixed. This is a gstack bug, or a
half-applied edit or merge in your checkout: report the printed
`<file>:<line>`.
**On Windows (Git Bash), from 1.91.31: a false positive.** If every
TypeScript hook fails with `bun could not bundle the TypeScript entry` (the
question-log, question-preference, auq-error-fallback and timeline-stop hooks,
`/autoplan`'s publication hook and `/plan-ceo-review`'s mode-handoff hook),
your hooks are fine. Git Bash handed Bun its paths as `C:/…` while the checker
looked them up as `/c/…` (#3088, #3100); the checker on main reads both
spellings. The same Git Bash spelling made `/gstack-upgrade` stop with
`ERROR: <dir> is not a gstack checkout; nothing was changed.` One recovery
covers both: update by hand, then run setup from the update.
```bash
cd ~/.claude/skills/gstack && git pull --ff-only --autostash && ./setup
```
Use your install directory if it is not `~/.claude/skills/gstack` (`./setup
--status` prints it). Expected result: setup finishes with exit 0 and no
refusal line, and later auto-updates go through.
**Meaning, everywhere else.** Claude Code runs gstack's hook shims through
`/bin/sh`, and a hook that does not parse exits 2, which blocks the tool call
it guards in every session. Setup parse-checks every hook it registers and
every hook a skill's frontmatter runs (`/autoplan`, `/careful`, `/freeze`,
`/guard`, `/investigate` and `/plan-ceo-review`): the shim, the gstack shell
helpers it sources (such as `careful/bin/hook-extract.sh` and
`bin/gstack-state-root.sh`), and the TypeScript it runs with its local imports.
A merge conflict marker line in any of those files fails as `unresolved merge
conflict marker`, even when the file still parses (markers inside a heredoc, a
string or a template literal do). The TypeScript is bundled from the gstack
checkout, so the `tsconfig.json` or `bunfig.toml` of the project you run it
from is never read. It registers the hooks that parse, skips the ones listed,
finishes the rest of the install, and exits non-zero. Claude Code runs hooks
straight from `~/.claude/skills/gstack`, so a skipped hook that an earlier
setup registered, or that a skill runs, keeps running the broken file until it
is fixed. This is a gstack bug, or a half-applied edit or merge in your
checkout: report the printed `<file>:<line>`.
**Fix.**
@@ -752,17 +774,78 @@ cd ~/.claude/skills/gstack && ./setup
<a id="auto-update-hook-does-not-parse"></a>
### `gstack auto-update: update held (hook-does-not-parse: <file>:<line>: <error>); nothing was installed or changed, and your current hooks keep running. ...`
**Meaning.** Team-mode auto-update fetched a release with a hook that does not
parse or still holds a merge conflict marker (the same check setup runs). It
checked the incoming revision before moving your checkout, so your
checkout, installed skills and registered hooks stay at the current revision.
gstack checks again at the next update check and installs the first release
whose hooks parse. This is a gstack bug: report the printed `<file>:<line>`.
**On Windows (Git Bash), from 1.91.31: a false positive.** If the held
reason is `<hook>.ts:1: bun could not bundle the TypeScript entry`, the
release is fine: your installed checker misread Git Bash's `C:/…` paths
(#3088, #3100), and the auto-updater checks every release with it, so it
holds every release until you update once by hand. Recovery (this also clears
`/gstack-upgrade`'s `is not a gstack checkout` refusal):
**Fix.** Nothing to do locally. A manual `git pull` followed by `./setup`
```bash
cd ~/.claude/skills/gstack && git pull --ff-only --autostash && ./setup
```
Use your install directory if it is not `~/.claude/skills/gstack`. Expected
result: setup finishes with exit 0, the next session start prints no held
line, and later updates install on their own.
**Meaning, everywhere else.** Team-mode auto-update fetched a release with a
hook that does not parse or still holds a merge conflict marker (the same
check setup runs). It checked the incoming revision before moving your
checkout, so your checkout, installed skills and registered hooks stay at the
current revision. The incoming release's own `bin/gstack-hook-check` decides,
so a checker bug in your install cannot hold later releases; your installed
checker decides only when the incoming one crashes, prints nothing or prints
a report it cannot verify. gstack checks again at the next update check and
installs the first release whose hooks parse. This is a gstack bug: report the
printed `<file>:<line>`.
**Fix.** Wait for the next release. A manual `git pull` followed by `./setup`
cannot be checked before the pull; setup then refuses the broken hook (see the
entry above).
<a id="auto-update-hook-check-unavailable"></a>
### `gstack auto-update: update held (hook-check-unavailable: <reason>); nothing was installed or changed, and your current hooks keep running. ... Fix now: cd <dir> && git pull --ff-only --autostash && ./setup ...`
**Meaning.** Neither the incoming release's hook checker nor your installed
one gave a verified report on the incoming hooks (a crash, no output, an
unreadable report, or no Bun to bundle the TypeScript hooks), so the
auto-updater cannot tell whether moving your checkout would break Claude Code
tool calls. It left your checkout, skills and hooks at the current revision.
The `<reason>` names what each checker did.
**Fix.** Run the printed command. Setup checks every hook it registers and
says what is wrong (for example that Bun is missing).
<a id="setup-runtime-root-contains-checkout"></a>
### `gstack setup: refusing to replace <root> for <host>: this runtime directory contains the source checkout setup runs from (<checkout>), so replacing it would delete the checkout. Nothing was changed.`
**Meaning.** Setup installs Codex, Factory, OpenCode, Cursor, Copilot and
Antigravity by building a runtime root (such as `~/.cursor/skills/gstack`) and
swapping it in for the old one, and Kiro by writing into `~/.kiro/skills/gstack`.
Your gstack checkout lives at that root or inside it, so the swap would delete
the checkout (#3084), and Kiro would link the checkout into itself. Setup refuses before
writing anything. `./setup --status` and `gstack-doctor` suggest the move for
that host instead of `./setup --host <host>`.
**Fix.** Move the checkout out, then run setup from its new place. Setup
prints the exact commands. When `~/.claude/skills/gstack` is a symlink to the
checkout, the checkout takes the symlink's place:
```bash
rm ~/.claude/skills/gstack && mv ~/.cursor/skills/gstack ~/.claude/skills/gstack
cd ~/.claude/skills/gstack && ./setup --host claude && ./setup --host cursor
```
Otherwise move it anywhere outside the host's skills directory:
```bash
mv ~/.cursor/skills/gstack ~/gstack && cd ~/gstack && ./setup --host cursor
```
**Expected result.** Both setups finish with exit 0, and `./setup --status`
lists each host as `current`.
<a id="host-renders-pruned"></a>
### `pruned <dir> (<host> is not installed from this checkout): ...` / `kept <path>: not proven generated (...)` / `host-render backup: <dir> (...)`
+3 -3
View File
@@ -140,7 +140,7 @@ uses. `--autostash` carries local edits over the pull; render-footprint dirt
is discarded first because it is regenerable and poisons stashes:
```bash
cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}" || exit 1
{ [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && [ "$(git rev-parse --show-toplevel 2>/dev/null)" = "$(pwd -P)" ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; }
{ [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; }
# Discard render-footprint dirt: older gbrain-enabled installs rendered
# generated SKILL.md / sections files IN PLACE. They are regenerable (setup
# re-renders to ~/.gstack/render), so discarding is lossless.
@@ -186,7 +186,7 @@ those commits. Gate it:
```bash
cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}" || exit 1
{ [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && [ "$(git rev-parse --show-toplevel 2>/dev/null)" = "$(pwd -P)" ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; }
{ [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; }
STASH_OUTPUT=$(git stash 2>&1)
git reset --hard origin/main
./setup --refresh-registered
@@ -246,7 +246,7 @@ echo "TEAM_MODE=$_TEAM_MODE"
```bash
(cd -- "${LOCAL_GSTACK:?LOCAL_GSTACK is not set: re-run the detection block above and substitute the printed path}" && [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ]) || { echo "ERROR: LOCAL_GSTACK=${LOCAL_GSTACK:-} is not a gstack copy; nothing was changed." >&2; exit 1; }
_ROOT=${LOCAL_GSTACK%/.claude/skills/gstack}
{ [ "$_ROOT" != "$LOCAL_GSTACK" ] && cd -- "${_ROOT:?}" && [ "$(git rev-parse --show-toplevel 2>/dev/null)" = "$(pwd -P)" ]; } || { echo "ERROR: $LOCAL_GSTACK is not the vendored copy at the root of a git repository; nothing was changed." >&2; exit 1; }
{ [ "$_ROOT" != "$LOCAL_GSTACK" ] && cd -- "${_ROOT:?}" && _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $LOCAL_GSTACK is not the vendored copy at the root of a git repository; nothing was changed." >&2; exit 1; }
git rm -r --cached .claude/skills/gstack/ 2>/dev/null || true
if ! grep -qF '.claude/skills/gstack/' .gitignore 2>/dev/null; then
echo '.claude/skills/gstack/' >> .gitignore
+3 -3
View File
@@ -137,7 +137,7 @@ uses. `--autostash` carries local edits over the pull; render-footprint dirt
is discarded first because it is regenerable and poisons stashes:
```bash
cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}" || exit 1
{ [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && [ "$(git rev-parse --show-toplevel 2>/dev/null)" = "$(pwd -P)" ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; }
{ [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; }
# Discard render-footprint dirt: older gbrain-enabled installs rendered
# generated SKILL.md / sections files IN PLACE. They are regenerable (setup
# re-renders to ~/.gstack/render), so discarding is lossless.
@@ -183,7 +183,7 @@ those commits. Gate it:
```bash
cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}" || exit 1
{ [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && [ "$(git rev-parse --show-toplevel 2>/dev/null)" = "$(pwd -P)" ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; }
{ [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; }
STASH_OUTPUT=$(git stash 2>&1)
git reset --hard origin/main
{{SETUP_COMMAND}} --refresh-registered
@@ -243,7 +243,7 @@ echo "TEAM_MODE=$_TEAM_MODE"
```bash
(cd -- "${LOCAL_GSTACK:?LOCAL_GSTACK is not set: re-run the detection block above and substitute the printed path}" && [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ]) || { echo "ERROR: LOCAL_GSTACK=${LOCAL_GSTACK:-} is not a gstack copy; nothing was changed." >&2; exit 1; }
_ROOT=${LOCAL_GSTACK%/.claude/skills/gstack}
{ [ "$_ROOT" != "$LOCAL_GSTACK" ] && cd -- "${_ROOT:?}" && [ "$(git rev-parse --show-toplevel 2>/dev/null)" = "$(pwd -P)" ]; } || { echo "ERROR: $LOCAL_GSTACK is not the vendored copy at the root of a git repository; nothing was changed." >&2; exit 1; }
{ [ "$_ROOT" != "$LOCAL_GSTACK" ] && cd -- "${_ROOT:?}" && _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $LOCAL_GSTACK is not the vendored copy at the root of a git repository; nothing was changed." >&2; exit 1; }
git rm -r --cached .claude/skills/gstack/ 2>/dev/null || true
if ! grep -qF '.claude/skills/gstack/' .gitignore 2>/dev/null; then
echo '.claude/skills/gstack/' >> .gitignore
+39
View File
@@ -0,0 +1,39 @@
/**
* One spelling for paths on Windows shells. Git Bash (MSYS) spells a drive
* path /c/x, native programs (git.exe, bun.exe) answer C:/x or C:\x, and MSYS
* converts /c/x arguments to C:/x before a native program starts. Comparing two
* spellings as strings is the bug class behind #3088, #3100 and #3101.
* Bash twin: bin/gstack-path-spelling.sh.
*/
import * as fs from 'node:fs';
/** Git Bash's drive spelling (/c/x) of a native path on Windows; unchanged elsewhere. */
export function shellPath(value: string, platform: string = process.platform): string {
if (platform !== 'win32') return value;
return value.replace(/\\/g, '/').replace(/^([A-Za-z]):(?=\/|$)/, (_, drive: string) => `/${drive.toLowerCase()}`);
}
/** The native spelling (C:/x) of a Git Bash drive path on Windows; unchanged elsewhere. */
export function nativePath(value: string, platform: string = process.platform): string {
if (platform !== 'win32') return value;
const slashed = value.replace(/\\/g, '/');
const drive = /^\/([A-Za-z])(?=\/|$)/.exec(slashed);
if (!drive) return slashed;
return `${drive[1]!.toUpperCase()}:${slashed.slice(2) || '/'}`;
}
/** True when a and b name the same file: filesystem identity when both exist, else equal spellings. */
export function samePath(a: string, b: string, platform: string = process.platform): boolean {
const left = nativePath(a, platform);
const right = nativePath(b, platform);
try {
const sa = fs.statSync(left, { bigint: true });
const sb = fs.statSync(right, { bigint: true });
return sa.dev === sb.dev && sa.ino === sb.ino;
} catch {
const trim = (v: string) => (v.length > 1 ? v.replace(/\/+$/, '') : v);
return platform === 'win32'
? trim(left).toLowerCase() === trim(right).toLowerCase()
: trim(left) === trim(right);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.88",
"version": "1.91.89",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+3
View File
@@ -275,6 +275,9 @@ export const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [
{ file: 'test/cso-windows-docker.test.ts', reason: '/cso docker.exe discovery under known-folder roots (real path, no reparse points) and the native-transport-unsupported outcome; pure win32 path logic' },
{ 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-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/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' },
{ file: 'test/state-root-parity.test.ts', reason: 'runs the bash twin and lib/state-root.ts over an env table with PATH empty; no shebang execution, raw-string comparison is platform-neutral' },
{ file: 'test/generator-eexist.test.ts',
reason: 'E4: runs the generators through Bun argv with the Windows EEXIST emulation preload; no shebang execution' },
+54 -13
View File
@@ -86,12 +86,13 @@ done
# `sections/...` pointer counts in a carved skill (sections/ installed, or a
# **STOP.** line); prefixed ones ($GSTACK_ROOT, ~, absolute, ../) everywhere.
_status_install_checks() {
local reg="$GSTACK_STATE_ROOT/installs.tsv" host scope project dest root src rest router md dir stop tok names n path checked broken first fix any_broken=0
local reg="$GSTACK_STATE_ROOT/installs.tsv" host scope project dest root src rest router md dir stop tok names n path checked broken first fix _move any_broken=0
[ -f "$reg" ] || return 0
echo "Install checks:"
while IFS=' ' read -r host scope project dest root src rest; do
case "$host" in ''|'#'*) continue ;; esac
fix="Fix: cd $src && ./setup --host $host"
_move="$(gstack_install_move_hint "$src" "$host" "$root")" && fix="Fix: the checkout lives in this host's runtime root, so move it first: $_move"
router="$root/SKILL.md"
if [ -L "$router" ]; then router="router is a symlink, which Codex skips. $fix"
elif [ -f "$router" ]; then router="router is a real file"
@@ -331,11 +332,32 @@ SOURCE_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd -P)"
# One state root for everything setup writes (bin/gstack-state-root.sh, docs/state-root.md).
. "$SOURCE_GSTACK_DIR/bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $SOURCE_GSTACK_DIR/bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
. "$SOURCE_GSTACK_DIR/bin/gstack-path-spelling.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-path-spelling.sh is missing. fix: reinstall from a fresh clone" >&2; exit 1; }
. "$SOURCE_GSTACK_DIR/bin/gstack-install-registry.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-install-registry.sh is missing. fix: reinstall from a fresh clone (docs/ADDING_A_HOST.md)" >&2; exit 1; }
. "$SOURCE_GSTACK_DIR/bin/gstack-render-claude.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-render-claude.sh is missing. fix: reinstall from a fresh clone" >&2; exit 1; }
. "$SOURCE_GSTACK_DIR/bin/gstack-host-renders.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-host-renders.sh is missing. fix: reinstall from a fresh clone" >&2; exit 1; }
SETUP_VERSION="$(cat "$SOURCE_GSTACK_DIR/VERSION" 2>/dev/null || echo unknown)"
# _runtime_root_refused HOST ROOT — setup never replaces or removes a host
# runtime root that is, or contains, the checkout it runs from (#3084: the
# Cursor arm's swap deleted a checkout at ~/.cursor/skills/gstack). Prints the
# refusal with the commands that move the checkout out, records it for the
# install summary, and returns 0; returns 1 when ROOT is safe to replace (a
# symlinked ROOT is: only the link goes).
_SETUP_REFUSED=""
_runtime_root_refused() {
local host="$1" root="$2" move
move="$(gstack_install_move_hint "$SOURCE_GSTACK_DIR" "$host" "$root")" || return 1
{
echo "gstack setup: refusing to replace $root for $host: this runtime directory contains the source checkout setup runs from ($SOURCE_GSTACK_DIR), so replacing it would delete the checkout. Nothing was changed."
echo " Fix: move the checkout out of $host's skills directory (a symlinked ~/.claude/skills/gstack that points at it becomes the checkout), then run setup from its new place:"
echo " $move"
echo " https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#setup-runtime-root-contains-checkout"
} >&2
_SETUP_REFUSED="refused: $root is or contains this checkout, so setup did not replace it; nothing was changed. Move it first: $move"
return 0
}
# --claude-model is validated before anything is written, and persisted once
# Claude is known to be selected (below, and in the --refresh-registered parent).
if [ "$CLAUDE_MODEL_SET" -eq 1 ]; then
@@ -565,14 +587,12 @@ if [ "$CODEX_REPO_LOCAL" -eq 1 ]; then
fi
fi
# A Codex runtime root that IS the checkout is a direct install, migrated
# below (migrate_direct_codex_install); one that contains it is refused.
if { [ "$CODEX_REPO_LOCAL" -eq 1 ] || [ "$HOST" = codex ] || { [ "$HOST" = auto ] && command -v codex >/dev/null 2>&1; }; } \
&& [ -d "$CODEX_GSTACK" ] && [ ! -L "$CODEX_GSTACK" ]; then
_codex_runtime="$(cd "$CODEX_GSTACK" && pwd -P)"
case "$SOURCE_GSTACK_DIR" in
"$_codex_runtime"/*)
echo "Refusing: Codex runtime directory contains the source checkout: $CODEX_GSTACK -> $_codex_runtime (source: $SOURCE_GSTACK_DIR). Use a separate skills directory; setup will not relocate or replace it automatically." >&2
exit 1 ;;
esac
&& [ -d "$CODEX_GSTACK" ] && ! gstack_path_same "$CODEX_GSTACK" "$SOURCE_GSTACK_DIR" \
&& _runtime_root_refused codex "$CODEX_GSTACK"; then
exit 1
fi
IS_WINDOWS=0
@@ -1134,8 +1154,10 @@ _print_windows_copy_note_once() {
# Rows publish to the registry only after a host arm finished activating
# (bin/gstack-install-registry.sh). The summary uses the same renderer as
# ./setup --status and /gstack-upgrade. A setup that dies inside a host arm
# reports that host as failed (its previous install is left in place) with the
# exact retry command, from the EXIT trap.
# reports that host as failed from the EXIT trap: "previous install was left
# in place" with the retry command only when the checkout, the skills
# directory and the runtime root still exist; a refusal (#3084) with the move
# commands; otherwise the fresh-clone reinstall.
_SETUP_ROWS=""
_SETUP_ARM=""
_SETUP_SUMMARY_DONE=0
@@ -1146,7 +1168,7 @@ _setup_row() {
_setup_retry_command() {
printf 'cd %s && ./setup --host %s' "$SOURCE_GSTACK_DIR" "$1"
}
_setup_arm_begin() { _SETUP_ARM="$1 $2 $3"; }
_setup_arm_begin() { _SETUP_ARM="$1 $2 $3"; _SETUP_ARM_ROOT=""; }
_setup_arm_publish() {
local host="$1" scope="$2" project="$3" dest="$4" root="$5" prefix="$6" render="${7:-committed}" old
old="$(gstack_install_registry_rows | awk -F '\t' -v h="$host" -v d="$dest" '$1 == h && $4 == d { v = $7 } END { print v }')"
@@ -1189,7 +1211,14 @@ _setup_exit_summary() {
IFS=' ' read -r host scope dest <<EOF
$_SETUP_ARM
EOF
_setup_row "$host" "$scope" "$dest" - "$SETUP_VERSION" failed "setup stopped (exit $rc) while installing $host; its previous install was left in place. Retry: $(_setup_retry_command "$host")"
if [ -n "$_SETUP_REFUSED" ]; then
_setup_row "$host" "$scope" "$dest" - "$SETUP_VERSION" failed "$_SETUP_REFUSED"
elif [ -f "$SOURCE_GSTACK_DIR/setup" ] && [ -d "$dest" ] \
&& { [ -z "${_SETUP_ARM_ROOT:-}" ] || [ -e "$_SETUP_ARM_ROOT" ] || [ -L "$_SETUP_ARM_ROOT" ]; }; then
_setup_row "$host" "$scope" "$dest" - "$SETUP_VERSION" failed "setup stopped (exit $rc) while installing $host; its previous install was left in place. Retry: $(_setup_retry_command "$host")"
else
_setup_row "$host" "$scope" "$dest" - "$SETUP_VERSION" failed "setup stopped (exit $rc) while installing $host, and its install could not be verified (checkout $SOURCE_GSTACK_DIR, runtime root ${_SETUP_ARM_ROOT:-unknown}). Reinstall from a fresh clone: git clone --depth 1 https://github.com/garrytan/gstack.git ~/gstack && cd ~/gstack && ./setup --host $host"
fi
fi
[ "$rc" -ne 0 ] || [ -n "$_SETUP_ROWS" ] || return 0
_setup_print_summary >&2
@@ -1377,6 +1406,15 @@ if [ "$CLAUDE_MODEL_SET" -eq 1 ] && [ "$INSTALL_CLAUDE" -eq 0 ]; then
echo "Error: --claude-model is supported only when Claude is selected (the default host, --host claude, or --host auto with Claude Code installed). Nothing was installed or changed." >&2
exit 1
fi
# Before anything is written: refuse a host whose runtime root is, or
# contains, this checkout (#3084). _activate_runtime_root checks again; Kiro
# writes into its root in place, so it is refused here too.
for _rr in "$INSTALL_KIRO kiro $KIRO_SKILLS/gstack" "$INSTALL_FACTORY factory $FACTORY_GSTACK" "$INSTALL_OPENCODE opencode $OPENCODE_GSTACK" \
"$INSTALL_CURSOR cursor $CURSOR_GSTACK" "$INSTALL_COPILOT copilot $COPILOT_GSTACK" "$INSTALL_AGY agy $AGY_GSTACK"; do
[ "${_rr%% *}" -eq 1 ] || continue
_rr=${_rr#* }
_runtime_root_refused "${_rr%% *}" "${_rr#* }" && exit 1
done
_persist_claude_model
migrate_direct_codex_install() {
@@ -2730,9 +2768,12 @@ create_agents_sidecar() {
# when the build succeeded. A failed refresh leaves the previous runtime root,
# and so the host's last working install, in place. The swap is two renames;
# a run killed between them leaves "$ROOT.gstack-old.<pid>", which the next
# run restores when ROOT is missing.
# run restores when ROOT is missing. A ROOT that is, or contains, the running
# checkout is refused before anything moves (_runtime_root_refused, #3084).
_activate_runtime_root() {
local fn="$1" src="$2" root="$3" new="$3.gstack-new.$$" old="$3.gstack-old.$$" rc leftover
_SETUP_ARM_ROOT="$root"
_runtime_root_refused "${_SETUP_ARM%% *}" "$root" && return 1
for leftover in "$root".gstack-old.*; do
[ -e "$leftover" ] || [ -L "$leftover" ] || continue
if [ ! -e "$root" ] && [ ! -L "$root" ]; then mv "$leftover" "$root"; else rm -rf "$leftover"; fi
+1 -1
View File
@@ -451,7 +451,7 @@ Handoff format. A lane reports in this order, identifiers first: PR URL (or `no
```bash
[ -r "${ARCHIVE_PATH:?ARCHIVE_PATH is not set: substitute the archived spec path}" ] || { echo "ERROR: cannot read $ARCHIVE_PATH; nothing was spawned." >&2; exit 1; }
cd -- "${SPAWN_PATH:?SPAWN_PATH is not set: substitute the printed worktree path}" || exit 1
[ "$(git rev-parse --show-toplevel 2>/dev/null)" = "$(pwd -P)" ] || { echo "ERROR: $SPAWN_PATH is not a git worktree root; nothing was spawned." >&2; exit 1; }
{ _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $SPAWN_PATH is not a git worktree root; nothing was spawned." >&2; exit 1; }
SPAWN_PATH=$(pwd -P)
CONTRACT=$(~/.claude/skills/gstack/bin/gstack-autoplan contract) || { echo "ERROR: gstack-autoplan contract failed; nothing was spawned." >&2; exit 1; }
{ cat "$ARCHIVE_PATH"; printf '\n\n%s\n' "$CONTRACT"; } | (cd "$SPAWN_PATH" && GSTACK_SESSION_KIND=spawned claude -p 2>&1) &
+1 -1
View File
@@ -299,7 +299,7 @@ followed by the coordinator contract every lane inherits word for word
```bash
[ -r "${ARCHIVE_PATH:?ARCHIVE_PATH is not set: substitute the archived spec path}" ] || { echo "ERROR: cannot read $ARCHIVE_PATH; nothing was spawned." >&2; exit 1; }
cd -- "${SPAWN_PATH:?SPAWN_PATH is not set: substitute the printed worktree path}" || exit 1
[ "$(git rev-parse --show-toplevel 2>/dev/null)" = "$(pwd -P)" ] || { echo "ERROR: $SPAWN_PATH is not a git worktree root; nothing was spawned." >&2; exit 1; }
{ _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $SPAWN_PATH is not a git worktree root; nothing was spawned." >&2; exit 1; }
SPAWN_PATH=$(pwd -P)
CONTRACT=$(~/.claude/skills/gstack/bin/gstack-autoplan contract) || { echo "ERROR: gstack-autoplan contract failed; nothing was spawned." >&2; exit 1; }
{ cat "$ARCHIVE_PATH"; printf '\n\n%s\n' "$CONTRACT"; } | (cd "$SPAWN_PATH" && GSTACK_SESSION_KIND=spawned claude -p 2>&1) &
+88
View File
@@ -0,0 +1,88 @@
/**
* #3101: gstack-wtree must fingerprint a linked worktree on Windows, where
* `git rev-parse --git-path index|objects` answers with a drive-letter absolute
* path (C:/…). Read as relative, that path was re-rooted under the worktree, so
* the private-object-directory fingerprint (gstack-review-log --start, E4) lost
* the repository's objects and /review and /ship could not record a start.
*
* Windows-safe by construction: gstack-wtree runs through explicit `bash <script>`,
* never shebang execution, and paths are handed to bash in forward-slash form.
*/
import { afterEach, beforeEach, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
const ROOT = resolve(import.meta.dir, '..');
const WTREE = join(ROOT, 'bin', 'gstack-wtree');
const posix = (value: string) => value.replace(/\\/g, '/');
let root = '', worktree = '', objects = '', env: NodeJS.ProcessEnv = {};
const git = (cwd: string, ...args: string[]) => {
const result = spawnSync('git', args, { cwd, env, encoding: 'utf8', timeout: 30_000 });
expect(result.status, result.stderr).toBe(0);
return result.stdout.trim();
};
beforeEach(() => {
root = realpathSync(mkdtempSync(join(tmpdir(), 'gstack-wtree-paths-')));
const main = join(root, 'main');
worktree = join(root, 'linked worktree');
objects = join(root, 'objects');
mkdirSync(main);
mkdirSync(objects);
const globalConfig = join(root, 'gitconfig');
writeFileSync(globalConfig, '');
env = {
...process.env, HOME: root, USERPROFILE: root,
GIT_CONFIG_GLOBAL: globalConfig, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_COUNT: '0',
GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_COMMON_DIR: undefined, GIT_INDEX_FILE: undefined,
GIT_OBJECT_DIRECTORY: undefined, GIT_ALTERNATE_OBJECT_DIRECTORIES: undefined,
};
git(main, 'init', '-q');
// A real file, so HEAD's tree lives only in the repository's object store.
writeFileSync(join(main, 'a.txt'), 'hello\n');
git(main, 'add', 'a.txt');
git(main, '-c', 'user.email=fixture@example.test', '-c', 'user.name=Fixture', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture');
git(main, 'worktree', 'add', '-q', '--detach', worktree);
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
// The review-start call shape: new objects go to a private directory the caller owns.
function expectFingerprintOfHead() {
const result = spawnSync('bash', [WTREE], {
cwd: worktree, env: { ...env, GSTACK_WTREE_OBJECT_DIR: posix(objects) }, encoding: 'utf8', timeout: 30_000,
});
expect(result.status, result.stderr).toBe(0);
expect(result.stdout.trim()).toBe(git(worktree, 'rev-parse', 'HEAD^{tree}'));
}
test('a linked worktree with a private object directory gets its fingerprint (#3101)', () => {
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
// quoted entry, the way Windows git reads it.
test.skipIf(process.platform === 'win32')('a drive-letter --git-path answer is not re-rooted under the worktree (#3101)', () => {
const real = JSON.stringify(Bun.which('git')!);
const shim = join(root, 'shim');
mkdirSync(shim);
writeFileSync(join(shim, 'git'), `#!/usr/bin/env bash
case " $* " in *" rev-parse --git-path "*)
out=$(${real} "$@") || exit $?
case "$out" in /*) printf 'C:%s\\n' "$out" ;; *) printf '%s\\n' "$out" ;; esac
exit 0 ;;
esac
if [ -n "\${GIT_ALTERNATE_OBJECT_DIRECTORIES:-}" ]; then
export GIT_ALTERNATE_OBJECT_DIRECTORIES="\\"\${GIT_ALTERNATE_OBJECT_DIRECTORIES#C:}\\""
fi
exec ${real} "$@"
`, { mode: 0o755 });
env.PATH = `${shim}${delimiter}${env.PATH}`;
expect(git(worktree, 'rev-parse', '--git-path', 'objects')).toStartWith('C:/');
expectFingerprintOfHead();
});
+225
View File
@@ -0,0 +1,225 @@
/**
* Path spelling on Windows shells (#3088, #3100, #3101): Git Bash spells a
* drive path /c/x, native git and Bun answer C:/x. bin/gstack-path-spelling.sh
* and its TS twin lib/path-spelling.ts give one spelling; skill bash never
* compares `git rev-parse --show-toplevel` with `pwd -P`, it checks "am I at
* the checkout root" with `git rev-parse --show-prefix`, which has no spelling.
*
* Windows-safe: bash runs through explicit `bash -c` with forward-slash paths.
* On Linux and macOS a uname/cygpath shim stands in for MSYS.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { nativePath, samePath, shellPath } from '../lib/path-spelling';
const ROOT = path.resolve(import.meta.dir, '..');
const posix = (value: string) => value.replace(/\\/g, '/');
const HELPER = posix(path.join(ROOT, 'bin', 'gstack-path-spelling.sh'));
const ROOT_CHECK = '_p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]';
const bases: string[] = [];
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
function tmp(): string {
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'path-spelling-')));
bases.push(base);
return base;
}
function bash(script: string, opts: { cwd?: string; env?: Record<string, string | undefined> } = {}) {
const r = spawnSync('bash', ['-c', script], { cwd: opts.cwd, env: { ...process.env, ...opts.env }, encoding: 'utf8', timeout: 30_000 });
return { code: r.status, out: r.stdout.trim(), err: r.stderr };
}
/** A PATH whose uname says MINGW64 and whose cygpath maps /tmp-style paths under C:/msys64. */
function msysShim(): string {
const shim = path.join(tmp(), 'shim');
fs.mkdirSync(shim);
fs.writeFileSync(path.join(shim, 'uname'), '#!/usr/bin/env bash\necho MINGW64_NT-10.0\n', { mode: 0o755 });
fs.writeFileSync(path.join(shim, 'cygpath'), [
'#!/usr/bin/env bash',
'[ "$1" = -am ] || exit 1',
'case "$2" in',
' [A-Za-z]:*) printf "%s\\n" "$2" | tr "\\\\\\\\" / ;;',
' /[a-z]/*) d=${2:1:1}; printf "%s:%s\\n" "$(printf %s "$d" | tr a-z A-Z)" "${2:2}" ;;',
' /*) printf "C:/msys64%s\\n" "$2" ;;',
' *) exit 1 ;;',
'esac',
].join('\n') + '\n', { mode: 0o755 });
return `${shim}${path.delimiter}${process.env.PATH}`;
}
describe('bin/gstack-path-spelling.sh', () => {
test.skipIf(process.platform === 'win32')('outside Windows shells every spelling is the path itself', () => {
const r = bash(`. '${HELPER}'; gstack_path_shell /a/b; gstack_path_native /a/b; gstack_path_shell C:/x`);
expect(r.out.split('\n')).toEqual(['/a/b', '/a/b', 'C:/x']);
});
test.skipIf(process.platform === 'win32')('under MSYS: drive, native and mount-alias spellings meet at /c/…', () => {
const r = bash([
`. '${HELPER}'`,
'gstack_path_shell "C:\\\\Users\\\\me\\\\gstack"',
'gstack_path_shell C:/Users/me/gstack',
'gstack_path_shell /c/Users/me/gstack',
'gstack_path_shell /tmp/x',
'gstack_path_native /c/Users/me/gstack',
'gstack_path_native /tmp/x',
'gstack_path_same C:/Users/me/missing /c/Users/me/missing && echo same',
'gstack_path_same C:/Users/me/a /c/Users/me/b || echo different',
].join('\n'), { env: { PATH: msysShim() } });
expect(r.code).toBe(0);
expect(r.out.split('\n')).toEqual([
'/c/Users/me/gstack', '/c/Users/me/gstack', '/c/Users/me/gstack', '/c/msys64/tmp/x',
'C:/Users/me/gstack', 'C:/msys64/tmp/x', 'same', 'different',
]);
});
test('existing paths compare by filesystem identity, through a symlink too', () => {
const base = tmp();
fs.mkdirSync(path.join(base, 'real'));
let linked = true;
try { fs.symlinkSync(path.join(base, 'real'), path.join(base, 'link'), 'dir'); } catch { linked = false; }
const b = posix(base);
const r = bash([
`. '${HELPER}'`,
`gstack_path_same '${b}/real' '${b}/real/.' && echo same`,
`gstack_path_same '${b}/real' '${b}' || echo different`,
linked ? `gstack_path_same '${b}/link' '${b}/real' && echo linked` : 'echo linked',
].join('\n'));
expect(r.out.split('\n')).toEqual(['same', 'different', 'linked']);
});
test('absolute in any spelling', () => {
const r = bash(`. '${HELPER}'; for p in /a C:/a 'C:\\a' a ./a .git/index; do gstack_path_absolute "$p" && echo "abs $p" || echo "rel $p"; done`);
expect(r.out.split('\n')).toEqual(['abs /a', 'abs C:/a', 'abs C:\\a', 'rel a', 'rel ./a', 'rel .git/index']);
});
});
describe('lib/path-spelling.ts (the TS twin)', () => {
test('win32 spellings', () => {
expect(shellPath('C:\\Users\\me\\x.ts', 'win32')).toBe('/c/Users/me/x.ts');
expect(shellPath('D:/a', 'win32')).toBe('/d/a');
expect(shellPath('hosts\\claude\\x.ts', 'win32')).toBe('hosts/claude/x.ts');
expect(shellPath('/c/a', 'win32')).toBe('/c/a');
expect(nativePath('/c/Users/me', 'win32')).toBe('C:/Users/me');
expect(nativePath('/c', 'win32')).toBe('C:/');
expect(nativePath('C:\\a', 'win32')).toBe('C:/a');
expect(samePath('C:/Users/Me/missing', '/c/users/me/missing/', 'win32')).toBe(true);
expect(samePath('C:/a/missing', '/c/b/missing', 'win32')).toBe(false);
});
test('POSIX paths pass through unchanged', () => {
expect(shellPath('C:/x', 'linux')).toBe('C:/x');
expect(nativePath('/c/x', 'darwin')).toBe('/c/x');
expect(samePath('/a/b', '/a/b/', 'linux')).toBe(true);
});
test('existing paths compare by filesystem identity', () => {
const base = tmp();
fs.mkdirSync(path.join(base, 'real'));
expect(samePath(path.join(base, 'real'), path.join(base, 'real', '.'))).toBe(true);
expect(samePath(path.join(base, 'real'), base)).toBe(false);
});
test.skipIf(process.platform === 'win32')('agrees with the bash helper on native spellings under MSYS', () => {
const inputs = ['C:/Users/me/gstack', 'D:\\work\\x', 'c:/lower'];
const r = bash(`. '${HELPER}'; for p in ${inputs.map(i => `'${i}'`).join(' ')}; do gstack_path_shell "$p"; done`, { env: { PATH: msysShim() } });
expect(r.out.split('\n')).toEqual(inputs.map(i => shellPath(i, 'win32')));
});
});
describe('bin/gstack-hook-check keeps its own copy of the mapping (it must run without helpers)', () => {
const CHECK = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-hook-check'), 'utf8');
test('its Bun-side shellPath equals lib/path-spelling.ts shellPath', () => {
const src = /const shellPath = (\(value\) => [\s\S]*?: String\(value\));/.exec(CHECK)?.[1];
expect(src).toBeTruthy();
const inline = new Function('process', `return ${src};`);
const inputs = ['C:\\Users\\me\\x.ts', 'D:/a/b', 'c:/lower', 'hosts\\claude\\x.ts', '/c/a', 'C:'];
for (const platform of ['win32', 'linux']) {
const mapped = inline({ platform });
for (const i of inputs) expect(mapped(i)).toBe(shellPath(i, platform));
}
});
test.skipIf(process.platform === 'win32')('its root canonicalization equals gstack_path_shell under MSYS', () => {
const block = /(case "\$\(uname -s 2>\/dev\/null\)" in\n MINGW\*\|MSYS\*\)[\s\S]*?\nesac)\n/.exec(CHECK)?.[1];
expect(block).toBeTruthy();
const roots = ['C:/Users/me/gstack', 'D:\\work\\gstack', '/c/Users/me/gstack', '/tmp/x'];
const r = bash(roots.map(p => `ROOT='${p}'\n${block}\nprintf '%s\\n' "$ROOT"`).join('\n') + `\n. '${HELPER}'\n` + roots.map(p => `gstack_path_shell '${p}'`).join('\n'), { env: { PATH: msysShim() } });
const lines = r.out.split('\n');
expect(lines.slice(0, roots.length)).toEqual(lines.slice(roots.length));
expect(lines[0]).toBe('/c/Users/me/gstack');
});
});
/** The guard each template block runs, with the commands before it on the same line. */
function templateGuards(): Array<{ file: string; line: string }> {
const guards: Array<{ file: string; line: string }> = [];
for (const file of ['gstack-upgrade/SKILL.md.tmpl', 'spec/sections/gate-and-file.md.tmpl']) {
for (const line of fs.readFileSync(path.join(ROOT, file), 'utf8').split('\n')) {
if (line.includes('--show-prefix')) guards.push({ file, line });
}
}
return guards;
}
describe('checkout-root guards in skill bash (/gstack-upgrade, /spec)', () => {
test('all four guards use the --show-prefix root check', () => {
const guards = templateGuards();
expect(guards.map(g => g.file)).toEqual([
'gstack-upgrade/SKILL.md.tmpl', 'gstack-upgrade/SKILL.md.tmpl', 'gstack-upgrade/SKILL.md.tmpl', 'spec/sections/gate-and-file.md.tmpl',
]);
for (const g of guards) expect(g.line).toContain(ROOT_CHECK);
});
test('the root check passes at the root and through a symlink, and refuses below it, outside a repo and inside .git', () => {
const base = tmp();
const repo = path.join(base, 'gstack');
fs.mkdirSync(path.join(repo, 'bin'), { recursive: true });
for (const f of ['VERSION', 'setup', 'bin/gstack-config']) fs.writeFileSync(path.join(repo, f), 'x\n');
fs.mkdirSync(path.join(repo, 'sub'));
fs.mkdirSync(path.join(base, 'outside'));
expect(bash('git init -q', { cwd: repo }).code).toBe(0);
let linked = true;
try { fs.symlinkSync(repo, path.join(base, 'link'), 'dir'); } catch { linked = false; }
const env = { GIT_CEILING_DIRECTORIES: posix(base), GIT_DIR: undefined, GIT_WORK_TREE: undefined };
const upgradeGuard = templateGuards()[0]!.line;
const run = (dir: string) => {
const guard = bash(`${upgradeGuard}\necho GUARD_PASSED`, { cwd: dir, env: { ...env, INSTALL_DIR: posix(dir) } });
const bare = bash(`${ROOT_CHECK} && echo ROOT || echo REFUSED`, { cwd: dir, env });
return { guard: guard.out.includes('GUARD_PASSED'), bare: bare.out };
};
expect(run(repo)).toEqual({ guard: true, bare: 'ROOT' });
if (linked) expect(run(path.join(base, 'link'))).toEqual({ guard: true, bare: 'ROOT' });
expect(run(path.join(repo, 'sub')).bare).toBe('REFUSED');
expect(run(path.join(base, 'outside'))).toEqual({ guard: false, bare: 'REFUSED' });
expect(run(path.join(repo, '.git')).bare).toBe('REFUSED');
expect(run(path.join(repo, '.git', 'objects')).bare).toBe('REFUSED');
});
test('no skill template compares --show-toplevel with pwd -P', () => {
const offenders: string[] = [];
const walk = (dir: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name.endsWith('.tmpl') || (dir.endsWith(path.join('scripts', 'resolvers')) && entry.name.endsWith('.ts'))) {
const text = fs.readFileSync(full, 'utf8');
for (const block of text.match(/```bash\n[\s\S]*?```/g) ?? [text]) {
const vars = [...block.matchAll(/(\w+)=\$\(git rev-parse --show-toplevel/g)].map(m => m[1]!);
for (const line of block.split('\n')) {
const toplevel = line.includes('--show-toplevel') || vars.some(v => line.includes(`"$${v}"`));
if (toplevel && line.includes('$(pwd -P)') && /\s!?=\s/.test(line)) offenders.push(`${path.relative(ROOT, full)}: ${line.trim()}`);
}
}
}
}
};
walk(ROOT);
expect(offenders).toEqual([]);
});
});
+115 -2
View File
@@ -73,8 +73,8 @@ function release(fx: Fx, version: string, edit: () => void) {
git(fx.seed, 'push', '-q', 'origin', 'main');
}
function runHook(fx: Fx) {
return spawnSync('bash', [SCRIPT], {
function runHook(fx: Fx, script = SCRIPT) {
return spawnSync('bash', [script], {
encoding: 'utf8',
env: { PATH: `${path.dirname(process.execPath)}:${process.env.PATH}`, HOME: fx.home, GSTACK_DIR: fx.install, GSTACK_STATE_ROOT: fx.state, SETUP_CALLS: fx.calls, TMPDIR: fx.base },
timeout: 20_000,
@@ -159,3 +159,116 @@ describe.skipIf(process.platform === 'win32')('gstack-session-update: incoming h
expect(ff).toBeGreaterThan(hooks);
});
});
// The incoming revision's own checker decides (#3088, #3100: an installed
// checker with a false positive held every later release). The installed one
// decides only when the incoming checker gives no verified, complete report.
describe.skipIf(process.platform === 'win32')('gstack-session-update: the incoming revision\'s checker decides', () => {
const FALSE_POSITIVE = `if [ "$1" = --list ]; then echo ${HOOK}; exit 0; fi\necho "fail ${HOOK} $1/${HOOK}.ts:1: bun could not bundle the TypeScript entry"; exit 1`;
const PASSES = `if [ "$1" = --list ]; then echo ${HOOK}; exit 0; fi\necho "ok ${HOOK}"; exit 0`;
const CRASHES = `if [ "$1" = --list ]; then echo ${HOOK}; exit 0; fi\necho "ok ${HOOK}"; kill -SEGV $$`;
/** The updater installed beside a stub bin/gstack-hook-check. */
function installedUpdater(fx: Fx, checker: string): string {
const bin = path.join(fx.base, 'installed-bin');
fs.mkdirSync(bin, { recursive: true });
for (const f of ['gstack-session-update', 'gstack-state-root.sh', 'gstack-egress-lib.sh', 'gstack-egress-receipt', 'gstack-bun-version.sh', 'gstack-path-spelling.sh']) {
fs.copyFileSync(path.join(ROOT, 'bin', f), path.join(bin, f));
}
fs.writeFileSync(path.join(bin, 'gstack-hook-check'), `#!/usr/bin/env bash\n${checker}\n`, { mode: 0o755 });
return path.join(bin, 'gstack-session-update');
}
/** Ship the real checker (or a stub) in the incoming revision. */
function incomingChecker(fx: Fx, stub?: string) {
if (stub === undefined) fs.copyFileSync(path.join(ROOT, 'bin', 'gstack-hook-check'), path.join(fx.seed, 'bin', 'gstack-hook-check'));
else fs.writeFileSync(path.join(fx.seed, 'bin', 'gstack-hook-check'), `#!/usr/bin/env bash\n${stub}\n`, { mode: 0o755 });
}
async function expectHeld(fx: Fx, script: string, reason: RegExp) {
const oldHead = git(fx.install, 'rev-parse', 'HEAD');
runHook(fx, script);
const log = await waitFor(fx, /Z HELD /);
expect(log).toMatch(reason);
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(oldHead);
expect(fs.readFileSync(path.join(fx.install, 'VERSION'), 'utf8')).toBe('1.0.0\n');
expect(fs.existsSync(fx.calls)).toBe(false);
expect(fs.readdirSync(fx.base).filter(f => f.startsWith('gstack-session-hooks-'))).toEqual([]);
return log;
}
test('an installed checker that always fails no longer holds a release whose own checker passes', async () => {
const fx = makeFixture();
const script = installedUpdater(fx, FALSE_POSITIVE);
release(fx, '1.1.0', () => {
incomingChecker(fx);
fs.writeFileSync(path.join(fx.seed, path.dirname(HOOK), 'note.ts'), "export const note = 'new hook ran';\n");
});
runHook(fx, script);
const log = await waitFor(fx, /Z UPDATED from=1\.0\.0 to=1\.1\.0/);
expect(log).not.toContain('HELD');
expect(log).toContain(`HOOK_CHECK_DISAGREE incoming=pass installed=${HOOK}.ts:1: bun could not bundle the TypeScript entry`);
expect(fs.readFileSync(path.join(fx.install, 'VERSION'), 'utf8')).toBe('1.1.0\n');
expect(runRegisteredHook(fx).stdout).toContain('new hook ran');
});
test('an invalid incoming hook holds even when the installed checker would pass it', async () => {
const fx = makeFixture();
const script = installedUpdater(fx, PASSES);
release(fx, '1.1.0', () => {
incomingChecker(fx);
fs.writeFileSync(path.join(fx.seed, path.dirname(HOOK), 'note.ts'), 'export const note = ;\n');
});
await expectHeld(fx, script, new RegExp(`HELD hook-does-not-parse: ${path.dirname(HOOK)}/note.ts:1: `));
expect(runRegisteredHook(fx).stdout).toContain('old hook ran');
});
test('a crashing incoming checker falls back to the installed one, which holds', async () => {
const fx = makeFixture();
const script = installedUpdater(fx, FALSE_POSITIVE);
release(fx, '1.1.0', () => incomingChecker(fx, CRASHES));
const log = await expectHeld(fx, script, new RegExp(`HELD hook-does-not-parse: ${HOOK}.ts:1: bun could not bundle`));
expect(log).toMatch(/HOOK_CHECK_FALLBACK incoming checker gave no verified report \(exited 139/);
});
test('an incoming checker with empty output falls back to the installed one, which holds', async () => {
const fx = makeFixture();
const script = installedUpdater(fx, FALSE_POSITIVE);
release(fx, '1.1.0', () => incomingChecker(fx, 'exit 0'));
const log = await expectHeld(fx, script, /HELD hook-does-not-parse: /);
expect(log).toContain('HOOK_CHECK_FALLBACK incoming checker gave no verified report (empty hook list)');
});
// Git Bash: the checker may report the archive directory as /c/…/x or
// C:/…/x while mktemp named it /tmp/x. A uname and cygpath shim stands in
// for MSYS; the held reason never carries the archive path.
test('the held reason drops the archive prefix in its native spelling', async () => {
const fx = makeFixture();
const shim = path.join(fx.base, 'msys-shim');
fs.mkdirSync(shim);
fs.writeFileSync(path.join(shim, 'uname'), '#!/usr/bin/env bash\necho MINGW64_NT-10.0\n', { mode: 0o755 });
fs.writeFileSync(path.join(shim, 'cygpath'), '#!/usr/bin/env bash\n[ "$1" = -am ] && { printf \'C:/msys64%s\\n\' "$2"; exit 0; }\nexit 1\n', { mode: 0o755 });
const script = installedUpdater(fx, PASSES);
release(fx, '1.1.0', () => incomingChecker(fx, `if [ "$1" = --list ]; then echo ${HOOK}; exit 0; fi\necho "fail ${HOOK} $(cygpath -am "$1")/${HOOK}.ts:3: Unexpected ;"; exit 1`));
const oldHead = git(fx.install, 'rev-parse', 'HEAD');
spawnSync('bash', [script], {
encoding: 'utf8',
env: { PATH: `${shim}:${path.dirname(process.execPath)}:${process.env.PATH}`, HOME: fx.home, GSTACK_DIR: fx.install, GSTACK_STATE_ROOT: fx.state, SETUP_CALLS: fx.calls, TMPDIR: fx.base },
timeout: 20_000,
});
const log = await waitFor(fx, /Z HELD /);
expect(log).toContain(`HELD hook-does-not-parse: ${HOOK}.ts:3: Unexpected ;`);
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(oldHead);
});
test('no verified report from either checker holds as hook-check-unavailable, with the recovery', async () => {
const fx = makeFixture();
const script = installedUpdater(fx, CRASHES);
release(fx, '1.1.0', () => incomingChecker(fx, `if [ "$1" = --list ]; then echo ${HOOK}; exit 0; fi\necho "all good"; exit 0`));
await expectHeld(fx, script, /HELD hook-check-unavailable: the installed bin\/gstack-hook-check gave no verified report either \(exited 139/);
const next = runHook(fx, script).stdout;
expect(next).toContain('gstack auto-update: update held (hook-check-unavailable: ');
expect(next).toContain(`Fix now: cd ${fx.install} && git pull --ff-only --autostash && ./setup`);
expect(next).toContain('https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-check-unavailable');
});
});
+1 -1
View File
@@ -30,7 +30,7 @@ function fixture(copy: boolean) {
put(path.join(root, 'VERSION'), '2.0.0.0\n');
put(path.join(root, 'ETHOS.md'), 'Fixture ethos\n');
put(path.join(root, 'package.json'), JSON.stringify({ type: 'module', scripts: { 'gen:skill-docs': 'bun run scripts/gen-skill-docs.ts' } }));
for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-models', 'gstack-state-root.sh', 'gstack-bun-version.sh', 'gstack-install-registry.sh', 'gstack-render-claude.sh', 'gstack-host-renders.sh']) {
for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-models', 'gstack-state-root.sh', 'gstack-bun-version.sh', 'gstack-path-spelling.sh', 'gstack-install-registry.sh', 'gstack-render-claude.sh', 'gstack-host-renders.sh']) {
const target = path.join(root, 'bin', bin);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(path.join(ROOT, 'bin', bin), target);
+58 -23
View File
@@ -10,16 +10,22 @@
* heal, SessionStart, plan-tune and Stop registration, and the final refusal)
* against a fixture install and a temp settings.json; driving all of ./setup
* (builds, browser install) here is disproportionate.
*
* Windows lane (force-included in scripts/lib/windows-curation.ts): the
* checker reports paths in Git Bash's spelling (/c/x), so expectations go
* through shellPath (lib/path-spelling.ts), and bash gets forward slashes.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { shellPath } from '../lib/path-spelling';
const ROOT = path.resolve(import.meta.dir, '..');
const CHECK = path.join(ROOT, 'bin', 'gstack-hook-check');
const SETUP = fs.readFileSync(path.join(ROOT, 'setup'), 'utf8');
const posix = (value: string) => value.replace(/\\/g, '/');
function slice(from: string, to: string | null): string {
const start = SETUP.indexOf(from);
@@ -108,6 +114,25 @@ describe('gstack-hook-check: the hook list is the registration code', () => {
});
describe('gstack-hook-check: what fails', () => {
// #3112 (@yuriysan): Git Bash hands native Bun C:/… entry paths, so every
// TypeScript hook was reported as "bun could not bundle the TypeScript
// entry" (#3088, #3100). Healthy hooks pass in a root with spaces, and a
// real syntax error is still rejected with its own diagnostic.
test('Bun drive-path spellings match the shell lookup keys without hiding a syntax error', () => {
const root = path.join(tmpBase(), 'hook root with spaces');
fixtureTree(root);
const healthy = check(root);
expect(healthy.out).not.toContain('fail ');
expect(healthy.code).toBe(0);
expect(healthy.out.trim().split('\n')).toEqual(HOOKS.map(h => `ok ${h}`));
fs.writeFileSync(path.join(root, 'hosts/claude/hooks/question-log-hook.ts'), 'export const broken = ;\n');
const broken = check(root);
expect(broken.code).toBe(1);
expect(broken.out).toContain(`fail hosts/claude/hooks/question-log-hook ${shellPath(path.join(root, 'hosts/claude/hooks/question-log-hook.ts'))}:1: `);
expect(broken.out).not.toContain('bun could not bundle the TypeScript entry');
}, 90_000); // Git Bash process startup makes two checks slow on Windows.
test('healthy fixture passes', () => {
const root = tmpBase();
fixtureTree(root);
@@ -122,7 +147,7 @@ describe('gstack-hook-check: what fails', () => {
fs.appendFileSync(shim, '<<<<<<< HEAD\nif [ -n "$x" ]; then\n');
const r = check(root);
expect(r.code).toBe(1);
expect(r.out).toMatch(new RegExp(`^fail hosts/claude/hooks/question-preference-hook ${shim}:${lines}: syntax error`, 'm'));
expect(r.out).toMatch(new RegExp(`^fail hosts/claude/hooks/question-preference-hook ${shellPath(shim)}:${lines}: syntax error`, 'm'));
});
test('a TypeScript entry with a syntax error fails with its file and line', () => {
@@ -132,7 +157,7 @@ describe('gstack-hook-check: what fails', () => {
fs.writeFileSync(ts, 'export const ok = 1;\nexport const x = {;\n');
const r = check(root);
expect(r.code).toBe(1);
expect(r.out).toContain(`fail hosts/claude/hooks/timeline-stop-hook ${ts}:2: Expected identifier`);
expect(r.out).toContain(`fail hosts/claude/hooks/timeline-stop-hook ${shellPath(ts)}:2: Expected identifier`);
});
test('a broken module the entry imports fails at the imported file', () => {
@@ -143,7 +168,7 @@ describe('gstack-hook-check: what fails', () => {
fs.writeFileSync(path.join(dir, 'hook-log.ts'), 'export function log(x: unknown) {\n return x +;\n}\n');
const r = check(root);
expect(r.code).toBe(1);
expect(r.out).toContain(`fail hosts/claude/hooks/question-log-hook ${path.join(dir, 'hook-log.ts')}:2:`);
expect(r.out).toContain(`fail hosts/claude/hooks/question-log-hook ${shellPath(path.join(dir, 'hook-log.ts'))}:2:`);
expect(r.out).not.toContain('playwright');
});
@@ -154,7 +179,7 @@ describe('gstack-hook-check: what fails', () => {
fs.writeFileSync(entry, "import { x } from '../../lib/gone';\nconsole.log(x);\n");
const r = check(root);
expect(r.code).toBe(1);
expect(r.out).toContain(`fail autoplan/bin/phase-publication-hook ${entry}:1: Could not resolve: "../../lib/gone"`);
expect(r.out).toContain(`fail autoplan/bin/phase-publication-hook ${shellPath(entry)}:1: Could not resolve: "../../lib/gone"`);
});
test('a missing entry fails instead of being skipped', () => {
@@ -164,7 +189,7 @@ describe('gstack-hook-check: what fails', () => {
fs.rmSync(entry);
const r = check(root);
expect(r.code).toBe(1);
expect(r.out).toContain(`fail hosts/claude/hooks/auq-error-fallback-hook ${entry}:1: missing TypeScript entry`);
expect(r.out).toContain(`fail hosts/claude/hooks/auq-error-fallback-hook ${shellPath(entry)}:1: missing TypeScript entry`);
});
test('a sourced helper that does not parse fails at the helper', () => {
@@ -174,8 +199,8 @@ describe('gstack-hook-check: what fails', () => {
fs.appendFileSync(helper, 'if then\n');
const r = check(root);
expect(r.code).toBe(1);
expect(r.out).toMatch(new RegExp(`^fail careful/bin/check-careful.sh ${helper}:\\d+: syntax error`, 'm'));
expect(r.out).toMatch(new RegExp(`^fail freeze/bin/check-freeze.sh ${helper}:\\d+: syntax error`, 'm'));
expect(r.out).toMatch(new RegExp(`^fail careful/bin/check-careful.sh ${shellPath(helper)}:\\d+: syntax error`, 'm'));
expect(r.out).toMatch(new RegExp(`^fail freeze/bin/check-freeze.sh ${shellPath(helper)}:\\d+: syntax error`, 'm'));
});
test('a conflict marker inside a heredoc of a transitively sourced helper still parses, and fails', () => {
@@ -187,8 +212,8 @@ describe('gstack-hook-check: what fails', () => {
expect(spawnSync('bash', ['-n', helper], { timeout: 10_000 }).status).toBe(0);
const r = check(root);
expect(r.code).toBe(1);
expect(r.out).toContain(`fail careful/bin/check-careful.sh ${helper}:${lines + 1}: unresolved merge conflict marker`);
expect(r.out).toContain(`fail bin/gstack-session-update ${helper}:${lines + 1}: unresolved merge conflict marker`);
expect(r.out).toContain(`fail careful/bin/check-careful.sh ${shellPath(helper)}:${lines + 1}: unresolved merge conflict marker`);
expect(r.out).toContain(`fail bin/gstack-session-update ${shellPath(helper)}:${lines + 1}: unresolved merge conflict marker`);
});
test('a conflict marker in a module the TypeScript entry imports fails, even inside a template literal', () => {
@@ -199,7 +224,7 @@ describe('gstack-hook-check: what fails', () => {
fs.writeFileSync(path.join(dir, 'hook-msg.ts'), 'export const msg = `\n<<<<<<< HEAD\na\n=======\nb\n>>>>>>> other\n`;\n');
const r = check(root);
expect(r.code).toBe(1);
expect(r.out).toContain(`fail hosts/claude/hooks/question-log-hook ${path.join(dir, 'hook-msg.ts')}:2: unresolved merge conflict marker`);
expect(r.out).toContain(`fail hosts/claude/hooks/question-log-hook ${shellPath(path.join(dir, 'hook-msg.ts'))}:2: unresolved merge conflict marker`);
});
test("the caller's tsconfig.json and bunfig.toml are not read, and a root with spaces works", () => {
@@ -252,33 +277,43 @@ describe('setup: registers the hooks that parse, refuses the rest, exits non-zer
const script = [
'set -e',
"log() { printf '%s\\n' \"$*\"; }",
`BUN_CMD='${process.execPath}'`,
`SOURCE_GSTACK_DIR='${ROOT}'`,
`SETTINGS_HOOK='${ROOT}/bin/gstack-settings-hook'`,
`GSTACK_CONFIG='${ROOT}/bin/gstack-config'`,
`BUN_CMD='${posix(process.execPath)}'`,
`SOURCE_GSTACK_DIR='${posix(ROOT)}'`,
`SETTINGS_HOOK='${posix(ROOT)}/bin/gstack-settings-hook'`,
`GSTACK_CONFIG='${posix(ROOT)}/bin/gstack-config'`,
'TEAM_MODE=1 NO_TEAM_MODE=0 IS_WINDOWS=0 QUIET=1 PLAN_TUNE_HOOKS_MODE="" TIMELINE_STOP_HOOK_MODE=""',
HOOK_SECTIONS,
'echo SETUP_HOOKS_DONE',
].join('\n');
const env: Record<string, string> = {
PATH: `${path.dirname(process.execPath)}:${process.env.PATH}`,
PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH}`,
HOME: fx.home,
GSTACK_STATE_ROOT: path.join(fx.home, '.gstack'),
GSTACK_SETTINGS_FILE: fx.settings,
GSTACK_PLAN_TUNE_HOOKS: 'yes',
TMPDIR: fx.base,
};
const r = spawnSync('bash', ['-c', script], { encoding: 'utf8', env, timeout: 60_000 });
// A file, not `bash -c`: the script is longer than Windows' 32,767-character command line.
const file = path.join(fx.base, 'setup-hooks.sh');
fs.writeFileSync(file, script);
const r = spawnSync('bash', [file], { encoding: 'utf8', env, timeout: 60_000 });
return { code: r.status, out: `${r.stdout}${r.stderr}` };
}
function registeredCommands(fx: Fx): string[] {
const settings = JSON.parse(fs.readFileSync(fx.settings, 'utf8'));
return Object.values(settings.hooks ?? {}).flatMap((entries: any) =>
entries.flatMap((e: any) => (e.hooks ?? []).map((h: any) => String(h.command)))).sort();
const commands: string[] = Object.values(settings.hooks ?? {}).flatMap((entries: any) =>
entries.flatMap((e: any) => (e.hooks ?? []).map((h: any) => String(h.command))));
if (process.platform !== 'win32') return commands.sort();
// Git Bash may spell them /tmp/…, /d/… or D:/… (HOME through a mount, MSYS
// env conversion); cygpath -m gives every spelling one native form.
const r = spawnSync('bash', ['-c', 'for p; do cygpath -m "$p"; done', '_', ...commands], { encoding: 'utf8', timeout: 15_000 });
return r.stdout.trim().split('\n').sort();
}
const shim = (fx: Fx, rel: string) => path.join(fx.canonical, rel);
/** A shim in the native spelling registeredCommands() compares in. */
const registered = (fx: Fx, rel: string) => (process.platform === 'win32' ? posix(shim(fx, rel)) : shim(fx, rel));
const ALL = ['bin/gstack-session-update', 'hosts/claude/hooks/question-log-hook', 'hosts/claude/hooks/question-preference-hook',
'hosts/claude/hooks/auq-error-fallback-hook', 'hosts/claude/hooks/timeline-stop-hook'];
@@ -288,7 +323,7 @@ describe('setup: registers the hooks that parse, refuses the rest, exits non-zer
expect(r.out).toContain('SETUP_HOOKS_DONE');
expect(r.code).toBe(0);
expect(r.out).not.toContain('refusing to register');
expect(registeredCommands(fx)).toEqual(ALL.map(rel => shim(fx, rel)).sort());
expect(registeredCommands(fx)).toEqual(ALL.map(rel => registered(fx, rel)).sort());
});
test('fresh install: the unparseable hook is not registered, the rest are, and setup exits 1 with file:line', () => {
@@ -300,14 +335,14 @@ describe('setup: registers the hooks that parse, refuses the rest, exits non-zer
expect(r.code).toBe(1);
expect(r.out).not.toContain('SETUP_HOOKS_DONE');
expect(r.out).toContain('gstack setup: refusing to register hooks that do not parse');
expect(r.out).toContain(` ${broken}:${lines}: syntax error`);
expect(r.out).toContain(` ${shellPath(broken)}:${lines}: syntax error`);
expect(r.out).toContain('Skipped: hosts/claude/hooks/question-log-hook');
expect(r.out).toContain(`This is a gstack bug; report ${broken}:${lines}`);
expect(r.out).toContain(`This is a gstack bug; report ${shellPath(broken)}:${lines}`);
expect(r.out).toContain('Hooks that parse register as usual: bin/gstack-session-update hosts/claude/hooks/question-preference-hook');
expect(r.out).toContain('https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#setup-hook-does-not-parse');
expect(r.out).not.toContain('previous install is still active');
expect(r.out).not.toContain('no stable install');
expect(registeredCommands(fx)).toEqual(ALL.filter(rel => rel !== 'hosts/claude/hooks/question-log-hook').map(rel => shim(fx, rel)).sort());
expect(registeredCommands(fx)).toEqual(ALL.filter(rel => rel !== 'hosts/claude/hooks/question-log-hook').map(rel => registered(fx, rel)).sort());
});
test('upgrade over live registrations: the others stay registered, the broken one is not re-registered, exit 1', () => {
@@ -320,7 +355,7 @@ describe('setup: registers the hooks that parse, refuses the rest, exits non-zer
fs.writeFileSync(shim(fx, 'hosts/claude/hooks/timeline-stop-hook.ts'), 'export const x = {;\n');
const r = runSetupHooks(fx);
expect(r.code).toBe(1);
expect(r.out).toContain(`${shim(fx, 'hosts/claude/hooks/timeline-stop-hook.ts')}:1: Expected identifier`);
expect(r.out).toContain(`${shellPath(shim(fx, 'hosts/claude/hooks/timeline-stop-hook.ts'))}:1: Expected identifier`);
expect(r.out).toContain('Skipped: hosts/claude/hooks/timeline-stop-hook');
expect(r.out).toContain('keeps running the broken file until it is fixed');
expect(r.out).not.toContain('previous install is still active');
+165
View File
@@ -0,0 +1,165 @@
/**
* #3084 (data loss): with the gstack checkout at ~/.cursor/skills/gstack and
* ~/.claude/skills/gstack a symlink to it, `./setup --host cursor` swapped the
* Cursor runtime root in over the checkout and deleted it, and `--status` had
* suggested exactly that command. Setup now refuses to replace or remove a
* runtime root that is, or contains, the running checkout (before any write,
* and again in _activate_runtime_root, which covers all six host arms), and
* `--status` suggests moving the checkout instead. Positive controls: the
* canonical Claude install, a repeat ./setup, and the Codex direct-install
* migration still succeed.
*/
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { cleanupFixtures, cleanupSeed, fixtureMkdirSync, makeFixture, makeSource, registryRows, runSetup, tree } from './helpers/install-fixture';
afterEach(cleanupFixtures);
afterAll(cleanupSeed);
const ANCHOR = 'docs/troubleshooting.md#setup-runtime-root-contains-checkout';
describe.skipIf(process.platform === 'win32')('setup never replaces a runtime root that holds the running checkout (#3084)', () => {
function cursorLayout() {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.cursor/skills/gstack'));
const claudeLink = join(f.home, '.claude/skills/gstack');
fixtureMkdirSync(join(f.home, '.claude/skills'), { recursive: true });
symlinkSync(src, claudeLink, 'dir');
return { f, src: realpathSync(src), claudeLink };
}
test('the issue layout: --status suggests the move, and ./setup --host cursor is refused with the checkout byte-identical', () => {
const { f, src, claudeLink } = cursorLayout();
const move = `rm ${claudeLink} && mv ${src} ${claudeLink} && cd ${claudeLink} && ./setup --host claude && ./setup --host cursor`;
const status = runSetup(f, join(src, 'setup'), ['--status']);
expect(status.stdout).toContain(move);
expect(status.stdout).toContain(ANCHOR);
expect(status.stdout).not.toContain(`register it: cd ${src} && ./setup --host cursor`);
const before = tree(src);
const r = runSetup(f, join(src, 'setup'), ['--host', 'cursor']);
expect(r.status, r.stdout + r.stderr).toBe(1);
expect(r.stderr).toContain(`gstack setup: refusing to replace ${src} for cursor: this runtime directory contains the source checkout setup runs from`);
expect(r.stderr).toContain('Nothing was changed.');
expect(r.stderr).toContain(` ${move}`);
expect(r.stderr).toContain(`https://github.com/garrytan/gstack/blob/main/${ANCHOR}`);
expect(r.stderr).not.toContain('previous install was left in place');
expect(tree(src)).toEqual(before);
expect(lstatSync(claudeLink).isSymbolicLink()).toBe(true);
expect(registryRows(f).filter(row => row[0] === 'cursor')).toEqual([]);
}, 90_000);
test('a checkout nested inside a host runtime root is refused for every arm that writes a root', () => {
for (const [host, root] of [['kiro', '.kiro/skills/gstack'], ['factory', '.factory/skills/gstack'], ['opencode', '.config/opencode/skills/gstack'],
['copilot', '.copilot/skills/gstack'], ['agy', '.gemini/antigravity-cli/skills/gstack']] as const) {
const f = makeFixture();
const src = makeSource(f, join(f.home, root, 'checkout'));
const before = tree(join(f.home, root));
const r = runSetup(f, join(src, 'setup'), ['--host', host]);
expect(r.status, `${host}: ${r.stdout}${r.stderr}`).toBe(1);
expect(r.stderr).toContain(`refusing to replace ${realpathSync(join(f.home, root))} for ${host}`);
expect(r.stderr).toContain(`mv ${realpathSync(src)} ~/gstack && cd ~/gstack && ./setup --host ${host}`);
expect(tree(join(f.home, root))).toEqual(before);
}
}, 240_000);
test('the guard sits in _activate_runtime_root, which every host arm that swaps a root calls', () => {
const setup = readFileSync(join(import.meta.dir, '..', 'setup'), 'utf8');
const fn = setup.slice(setup.indexOf('_activate_runtime_root() {'));
expect(fn.slice(0, fn.indexOf('\n}\n'))).toMatch(/_runtime_root_refused "\$\{_SETUP_ARM%%\t\*\}" "\$root" && return 1/);
const arms = [...setup.matchAll(/_activate_runtime_root create_(\w+)_runtime_root/g)].map(m => m[1]);
expect([...new Set(arms)].sort()).toEqual(['agy', 'codex', 'copilot', 'cursor', 'factory', 'opencode']);
});
test('control: the canonical ~/.claude/skills/gstack install and a repeat ./setup succeed', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
for (let run = 0; run < 2; run++) {
const r = runSetup(f, join(src, 'setup'), ['--host', 'claude', '--no-team']);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stderr).not.toContain('refusing to replace');
}
expect(existsSync(join(src, 'setup'))).toBe(true);
expect(registryRows(f).some(row => row[0] === 'claude')).toBe(true);
}, 120_000);
// @szsunyuan (#3128): only the link goes, so a symlinked root is replaced.
test('control: a runtime root that is a symlink to the checkout is replaced, and the checkout survives', () => {
const f = makeFixture();
const src = makeSource(f, join(f.dir, 'gstack'));
fixtureMkdirSync(join(f.home, '.cursor/skills'), { recursive: true });
symlinkSync(src, join(f.home, '.cursor/skills/gstack'), 'dir');
const r = runSetup(f, join(src, 'setup'), ['--host', 'cursor']);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stderr).not.toContain('refusing to replace');
expect(lstatSync(join(f.home, '.cursor/skills/gstack')).isSymbolicLink()).toBe(false);
expect(existsSync(join(f.home, '.cursor/skills/gstack/SKILL.md'))).toBe(true);
for (const file of ['setup', 'VERSION', 'bin/gstack-hook-check']) expect(existsSync(join(src, file))).toBe(true);
}, 120_000);
test('a Kiro checkout at ~/.kiro/skills/gstack is refused, not linked into itself', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.kiro/skills/gstack'));
const before = tree(src);
const r = runSetup(f, join(src, 'setup'), ['--host', 'kiro']);
expect(r.status, r.stdout + r.stderr).toBe(1);
expect(r.stderr).toContain(`refusing to replace ${realpathSync(src)} for kiro`);
expect(tree(src)).toEqual(before);
}, 120_000);
test('control: a Cursor install from a checkout elsewhere replaces its runtime root, twice', () => {
const f = makeFixture();
const src = makeSource(f, join(f.dir, 'gstack'));
for (let run = 0; run < 2; run++) {
const r = runSetup(f, join(src, 'setup'), ['--host', 'cursor']);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stderr).not.toContain('refusing to replace');
}
expect(existsSync(join(f.home, '.cursor/skills/gstack/SKILL.md'))).toBe(true);
expect(existsSync(join(src, 'setup'))).toBe(true);
}, 120_000);
test('control: the Codex direct-install migration still moves ~/.codex/skills/gstack to ~/.gstack/repos/gstack', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.codex/skills/gstack'));
const r = runSetup(f, join(src, 'setup'), ['--host', 'codex']);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stdout + r.stderr).toContain('Migrating direct Codex install');
expect(r.stderr).not.toContain('refusing to replace');
expect(existsSync(join(f.home, '.gstack/repos/gstack/setup'))).toBe(true);
expect(existsSync(join(f.home, '.codex/skills/gstack/SKILL.md'))).toBe(true);
}, 120_000);
test('the failure summary says "previous install was left in place" only after verifying it', () => {
const setup = readFileSync(join(import.meta.dir, '..', 'setup'), 'utf8');
const helpers = setup.slice(setup.indexOf('_SETUP_ROWS=""'), setup.indexOf('# Aside (aside.com'));
const base = realpathSync(mkdtempSync(join(tmpdir(), 'setup-summary-')));
try {
mkdirSync(join(base, 'src'));
writeFileSync(join(base, 'src', 'setup'), '');
mkdirSync(join(base, 'skills', 'gstack'), { recursive: true });
const summary = (src: string, root: string, refused = '') => spawnSync('bash', ['-c', [
'QUIET=0 SETUP_VERSION=1.0 _DISABLED_SKILLS=""',
'gstack_install_registry_render() { cat; }',
helpers,
`SOURCE_GSTACK_DIR='${src}'`,
`_setup_arm_begin cursor global '${join(base, 'skills')}'`,
`_SETUP_ARM_ROOT='${root}' _SETUP_REFUSED='${refused}'`,
'_setup_exit_summary 1',
].join('\n')], { encoding: 'utf8', timeout: 10_000 }).stderr;
expect(summary(join(base, 'src'), join(base, 'skills', 'gstack'))).toContain('its previous install was left in place. Retry: cd ');
const gone = summary(join(base, 'gone'), join(base, 'skills', 'gstack'));
expect(gone).not.toContain('left in place');
expect(gone).toContain('its install could not be verified');
expect(gone).toContain('git clone --depth 1 https://github.com/garrytan/gstack.git ~/gstack && cd ~/gstack && ./setup --host cursor');
expect(summary(join(base, 'src'), join(base, 'skills', 'missing-root'))).toContain('could not be verified');
expect(summary(join(base, 'src'), join(base, 'skills', 'gstack'), 'refused: moved')).toContain('refused: moved');
} finally {
rmSync(base, { recursive: true, force: true });
}
});
});
+1 -1
View File
@@ -455,7 +455,7 @@ describe('setup --team / --no-team / -q', () => {
try {
for (const rel of [
'setup', 'VERSION', 'SKILL.md', 'qa/SKILL.md', 'bin/gstack-config', 'bin/gstack-patch-names',
'bin/gstack-state-root.sh', 'bin/gstack-bun-version.sh', 'bin/gstack-install-registry.sh', 'bin/gstack-render-claude.sh', 'bin/gstack-host-renders.sh',
'bin/gstack-state-root.sh', 'bin/gstack-bun-version.sh', 'bin/gstack-path-spelling.sh', 'bin/gstack-install-registry.sh', 'bin/gstack-render-claude.sh', 'bin/gstack-host-renders.sh',
'scripts/resolve-codex-generation-model.ts', 'scripts/models.ts', 'scripts/preflight-codex-overlap.ts',
'scripts/discover-skills.ts', 'scripts/external-skill-names.ts', 'scripts/host-config.ts',
'lib/claude-code-migration.ts', 'lib/model-policy.ts', 'lib/model-catalog.ts',
@@ -0,0 +1,46 @@
/**
* Installs stuck on the Git Bash hook-check false positive (#3088, #3100) run
* their own old updater and old /gstack-upgrade text, so code on main cannot
* reach them; the troubleshooting page their held-update and setup messages
* link to can. Both anchors name the false positive and give one copy-paste
* recovery, which also covers /gstack-upgrade's "is not a gstack checkout".
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
const ROOT = path.resolve(import.meta.dir, '..');
const DOC = fs.readFileSync(path.join(ROOT, 'docs/troubleshooting.md'), 'utf8');
const RECOVERY = 'cd ~/.claude/skills/gstack && git pull --ff-only --autostash && ./setup';
function section(anchor: string): string {
const start = DOC.indexOf(`<a id="${anchor}"></a>`);
expect(start).toBeGreaterThan(-1);
const next = DOC.indexOf('<a id="', start + 1);
return DOC.slice(start, next === -1 ? undefined : next);
}
describe('troubleshooting: the Git Bash hook false positive', () => {
for (const anchor of ['setup-hook-does-not-parse', 'auto-update-hook-does-not-parse']) {
test(`#${anchor} names the false positive and the one recovery`, () => {
const text = section(anchor);
expect(text).toContain('Git Bash');
expect(text).toContain('from 1.91.31');
expect(text).toContain('bun could not bundle the TypeScript entry');
expect(text).toContain(RECOVERY);
expect(text).toContain('is not a gstack checkout');
expect(text).not.toContain('Nothing to do locally');
});
}
test('#upgrade-not-a-checkout points Git Bash users at the same recovery', () => {
expect(section('upgrade-not-a-checkout')).toContain('cd <install dir> && git pull --ff-only --autostash && ./setup');
});
test('every anchor the updater, setup and doctor link to exists', () => {
const sources = ['bin/gstack-session-update', 'setup', 'bin/gstack-doctor', 'bin/gstack-install-registry.sh'];
const anchors = new Set(sources.flatMap(f => [...fs.readFileSync(path.join(ROOT, f), 'utf8').matchAll(/troubleshooting\.md#([a-z0-9-]+)/g)].map(m => m[1]!)));
for (const a of ['auto-update-hook-does-not-parse', 'auto-update-hook-check-unavailable', 'setup-hook-does-not-parse', 'setup-runtime-root-contains-checkout']) expect(anchors.has(a)).toBe(true);
for (const a of anchors) expect(DOC, a).toContain(`<a id="${a}"></a>`);
});
});
+1 -1
View File
@@ -50,7 +50,7 @@ describe.skipIf(process.platform === 'win32')('upgrade setup recovery (real shel
try {
const bin = join(root, 'bin');
mkdirSync(bin);
writeFileSync(join(bin, 'git'), '#!/bin/sh\nif [ "$1 $2" = "rev-parse --show-toplevel" ]; then pwd -P; elif [ "$1" = rev-parse ]; then echo old-commit; fi\nexit 0\n', { mode: 0o755 });
writeFileSync(join(bin, 'git'), '#!/bin/sh\nif [ "$1 $2" = "rev-parse --show-prefix" ]; then :; elif [ "$1 $2" = "rev-parse --is-inside-work-tree" ]; then echo true; elif [ "$1" = rev-parse ]; then echo old-commit; fi\nexit 0\n', { mode: 0o755 });
writeFileSync(join(root, 'setup'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
// C9: the git fence verifies it is inside gstack's own checkout first.
writeFileSync(join(root, 'VERSION'), '1.0.0.0\n');