diff --git a/.github/workflows/windows-setup-e2e.yml b/.github/workflows/windows-setup-e2e.yml index 1d5f24f58..195c88466 100644 --- a/.github/workflows/windows-setup-e2e.yml +++ b/.github/workflows/windows-setup-e2e.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c1ba6f111..29aea90fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.** diff --git a/VERSION b/VERSION index 3556d31f1..563ace4b6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.88.0 +1.91.89.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 2e53138ce..a79d4df67 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -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, diff --git a/bin/gstack-hook-check b/bin/gstack-hook-check index 3e44b9075..f655cfa7e 100755 --- a/bin/gstack-hook-check +++ b/bin/gstack-hook-check @@ -34,7 +34,7 @@ # missing (no shim: setup skips registering it) # fail :: # 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 ] [--list] " >&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 diff --git a/bin/gstack-install-registry.sh b/bin/gstack-install-registry.sh index 60bf1bb32..44bed372a 100644 --- a/bin/gstack-install-registry.sh +++ b/bin/gstack-install-registry.sh @@ -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 </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 +} diff --git a/bin/gstack-session-update b/bin/gstack-session-update index 01e7c0a1a..844ec6133 100755 --- a/bin/gstack-session-update +++ b/bin/gstack-session-update @@ -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 — sets _hr_out to the checker's report on +# 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 ` or +# `fail : `, 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 — parse-check the hooks of (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 — 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 diff --git a/bin/gstack-wtree b/bin/gstack-wtree index b2c1c258c..341268623 100755 --- a/bin/gstack-wtree +++ b/bin/gstack-wtree @@ -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}" diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b863c3523..45b4be6ef 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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=`. +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 && git pull --ff-only --autostash && ./setup` (see +[the hook false positive](#setup-hook-does-not-parse)). ### `//SKILL.md is bytes, over the 160,000-byte limit. Fix: carve sections with usesLazySections() for this skill.` @@ -721,23 +725,41 @@ Bun first. ### `gstack setup: refusing to register hooks that do not parse (Claude Code would block tool calls with them): :: ` -**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 -`:`. +**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: 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 `:`. **Fix.** @@ -752,17 +774,78 @@ cd ~/.claude/skills/gstack && ./setup ### `gstack auto-update: update held (hook-does-not-parse: :: ); 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 `:`. +**On Windows (Git Bash), from 1.91.31: a false positive.** If the held +reason is `.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 `:`. + +**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). + +### `gstack auto-update: update held (hook-check-unavailable: ); nothing was installed or changed, and your current hooks keep running. ... Fix now: cd && 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 `` 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). + + +### `gstack setup: refusing to replace for : this runtime directory contains the source checkout setup runs from (), 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 `. + +**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`. + ### `pruned ( is not installed from this checkout): ...` / `kept : not proven generated (...)` / `host-render backup: (...)` diff --git a/gstack-upgrade/SKILL.md b/gstack-upgrade/SKILL.md index 133d88be4..51be1beca 100644 --- a/gstack-upgrade/SKILL.md +++ b/gstack-upgrade/SKILL.md @@ -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 diff --git a/gstack-upgrade/SKILL.md.tmpl b/gstack-upgrade/SKILL.md.tmpl index b7c80d17f..09dc8aed1 100644 --- a/gstack-upgrade/SKILL.md.tmpl +++ b/gstack-upgrade/SKILL.md.tmpl @@ -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 diff --git a/lib/path-spelling.ts b/lib/path-spelling.ts new file mode 100644 index 000000000..6cd6adefa --- /dev/null +++ b/lib/path-spelling.ts @@ -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); + } +} diff --git a/package.json b/package.json index 7a7e220ac..d66883363 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/lib/windows-curation.ts b/scripts/lib/windows-curation.ts index 2205c1c14..6a3805002 100644 --- a/scripts/lib/windows-curation.ts +++ b/scripts/lib/windows-curation.ts @@ -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' }, diff --git a/setup b/setup index 7372a5c25..bfe9217d6 100755 --- a/setup +++ b/setup @@ -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 <&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.", 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 diff --git a/spec/sections/gate-and-file.md b/spec/sections/gate-and-file.md index da55ba8e7..6455c3988 100644 --- a/spec/sections/gate-and-file.md +++ b/spec/sections/gate-and-file.md @@ -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) & diff --git a/spec/sections/gate-and-file.md.tmpl b/spec/sections/gate-and-file.md.tmpl index 9c552b105..79e46ded2 100644 --- a/spec/sections/gate-and-file.md.tmpl +++ b/spec/sections/gate-and-file.md.tmpl @@ -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) & diff --git a/test/gstack-wtree-windows-paths.test.ts b/test/gstack-wtree-windows-paths.test.ts new file mode 100644 index 000000000..6ac504946 --- /dev/null +++ b/test/gstack-wtree-windows-paths.test.ts @@ -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