diff --git a/.gitattributes b/.gitattributes index 65dc0f77a..397af035f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -24,6 +24,18 @@ setup text eol=lf bin/* text eol=lf **/scripts/* text eol=lf +# Nested executables (#3110). A pattern containing a slash is anchored at the +# repository root, so `bin/*` above covers only the top-level bin/. These are +# bash or sh entry points too; a Windows checkout wrote them with CRLF, and a +# Linux bash reading that checkout (a synced tree, WSL, a bind mount) failed +# with `set: pipefail: invalid option name`. setup's CRLF repair rewrites +# copies an older checkout already left with CRLF. +**/bin/* text eol=lf +hosts/*/hooks/* text eol=lf +lib/cso/images/* text eol=lf +scripts/app/* text eol=lf +browse/test/fixtures/mock-claude/* text eol=lf + # Dockerfiles build on Linux, and test/ci-image-cli-pin.test.ts joins their # `\`-continued RUN lines with a `\\\n` match; CRLF left each RUN split. Dockerfile* text eol=lf @@ -42,6 +54,7 @@ Dockerfile* text eol=lf *.gif binary *.ico binary *.pdf binary +*.icns binary # The committed diagram-render bundle is hash-pinned (BUILD_INFO sha256); # a CRLF rewrite on Windows checkout would break the drift test and change diff --git a/CHANGELOG.md b/CHANGELOG.md index 29aea90fe..4733c54fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [1.91.90.0] - 2026-10-11 + +**Windows checkouts stop breaking gstack's nested shell scripts on Linux, the timeline Stop hook repairs on Windows instead of timing out every turn, and the `/setup-gbrain` smoke test stops piping into `gbrain put`.** + +### What this means for you + +- **Nested scripts are LF everywhere (#3110).** `.gitattributes` pinned only the top-level `bin/` to LF, so Git for Windows (`core.autocrlf=true`) wrote skill `bin/` helpers, the Claude hook shims, `lib/cso/images/*` and `scripts/app/*` with CRLF. A Linux bash reading that checkout (a synced tree, WSL, a bind mount) failed with `set: pipefail: invalid option name`, and `/autoplan`'s publication hook exited 2, blocking every phase entry. Every tracked `#!` file now resolves to `eol=lf`. +- **`./setup` repairs existing CRLF copies.** Pulling the new rule does not rewrite a file whose content did not change, so setup finds tracked files whose rule says LF but whose copy has CRLF and checks them out again as LF. A file you edited is left alone and named, with the command that rewrites it and keeps your edit (`docs/troubleshooting.md#setup-crlf-repair`). +- **The timeline Stop hook returns at once and repairs (#3103).** Through Git Bash, `gstack-slug` took 2.2-2.8 s against the hook's 2 s budget, so on Windows the hook spent about 2.5 s on every turn and repaired nothing. It now reads the slug from gstack-slug's cache in-process (`GSTACK_PROJECT_SLUG` still wins). On a miss a hidden background process resolves the slug and repairs, one at a time under a lock, and a directory it cannot resolve is retried after 5 minutes instead of every turn. +- **The `/setup-gbrain` smoke test writes the page to a temp file (#1675)** and runs `gbrain put "$SLUG" < file`, the shape gstack's other `gbrain put` instructions already use. + +### For contributors + +- `test/gitattributes-eol.test.ts` checks `git check-attr eol` for every tracked `#!` file and that no file git stores as binary is marked text (`*.icns` is now `binary`, since `scripts/app/*` would otherwise cover the app icon). +- `test/setup-crlf-repair.test.ts` builds the broken state with a real `core.autocrlf=true` clone, runs setup's repair under the install registry lock, checks the repaired bytes and runs the hook. +- `test/timeline-stop-hook-slug.test.ts` runs a copy of the hook against a slow stub `gstack-slug` and is force-included in the Windows lane. Native Windows latency is measured only if that lane runs it. + +Thanks @Mr-B-1 (#3110) +Thanks @Peaky8linders (#3103) +Thanks @ElliotDrel (#1675) + ## [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.** diff --git a/VERSION b/VERSION index 563ace4b6..ed6c016a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.89.0 +1.91.90.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index a79d4df67..d5a98806a 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.89.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.90.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-gbrain-source-wireup b/bin/gstack-gbrain-source-wireup index 11248bf28..504e95d51 100755 --- a/bin/gstack-gbrain-source-wireup +++ b/bin/gstack-gbrain-source-wireup @@ -305,7 +305,7 @@ do_wireup() { case "$ew_rc" in 0) : ;; # success 2) - [ "$STRICT" = "1" ] && die "no $GSTACK_HOME/.git; run /setup-gbrain Step 7 (gstack-brain-init) first" 2 + [ "$STRICT" = "1" ] && die "no $GSTACK_HOME/.git; run /setup-gbrain Step 7 (gstack-artifacts-init) first" 2 warn "no $GSTACK_HOME/.git; skipping (benign skip)" exit 0 ;; diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 45b4be6ef..dad19862d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2345,3 +2345,32 @@ owner decides` line whose decision id is still pending, so it was left as is. **Fix.** Answer the gate for that id, then rerun `gstack-owner-brief close`; only decided lines are rewritten, each to its chosen option and the date. + + +### `warning: left locally edited file(s) with CRLF line endings that gstack pins to LF` + +**Meaning.** Git for Windows checks out with `core.autocrlf=true`, so gstack's +nested shell scripts (skill `bin/` helpers, the Claude hook shims, +`lib/cso/images/*`, `scripts/app/*`) were written with CRLF until +`.gitattributes` pinned them to LF (#3110). Git Bash runs them anyway, but a +Linux bash reading the same checkout (a synced tree, WSL, a bind mount) fails +with `set: pipefail: invalid option name`, and a hook that fails that way +blocks the tool call it guards. Pulling the new rule does not fix existing +copies, because git never rewrites a file whose content did not change. Setup +finds those files with one `git ls-files --eol` pass and checks them out again +as LF. It leaves a file you edited locally alone and names it in this warning. +The same section covers `skipped the CRLF repair` (another setup held the +install registry lock) and `the CRLF repair could not rewrite every file`. + +**Fix.** For edited files, stash and restore them; git writes them back with LF +and keeps your edits: + +```bash +cd ~/.claude/skills/gstack && git stash && git stash pop +``` + +If the repair was skipped, re-run `./setup` once the other setup finishes. If +it failed, `git checkout -- . && ./setup` in the checkout discards local edits +and rewrites every file. Use your install directory if it is not +`~/.claude/skills/gstack` (`./setup --status` prints it). Expected result: the +next `./setup` prints no CRLF warning. diff --git a/hosts/claude/hooks/timeline-stop-hook.ts b/hosts/claude/hooks/timeline-stop-hook.ts index f0e658e57..7ccd62ba3 100755 --- a/hosts/claude/hooks/timeline-stop-hook.ts +++ b/hosts/claude/hooks/timeline-stop-hook.ts @@ -27,6 +27,19 @@ * inside the window has its completion inside the window too: the window * can never fabricate a dangling entry, and idempotency holds. * + * Slug resolution (#3103): the fast path reads the slug in-process, from + * GSTACK_PROJECT_SLUG or gstack-slug's versioned cache entry + * (readVersionedSlugCache, the question-preference hook's reader); every skill + * preamble runs gstack-slug, so a project that used gstack is a hit. On a + * miss the hook spawns a detached copy of itself (`--repair `) and + * returns: through Git Bash on Windows, gstack-slug alone took 2.2-2.8 s, past + * the 2 s budget, so the old synchronous spawn timed out on every Stop and + * repaired nothing. The child runs gstack-slug with a long timeout (which + * writes the cache) and repairs. Every repair, foreground or child, holds an + * exclusive TTL lock in the state root, so one runs at a time. A per-cwd miss + * marker keeps a directory gstack-slug cannot resolve from spawning a child + * on every turn. windowsHide keeps both spawns from opening a console window. + * * Correlation limits, on purpose: the preamble's session id is a shell-local * "$$-epoch", not the Claude session id this hook receives, so entries can't * be attributed to THIS session specifically. Closing every dangling entry in @@ -34,14 +47,20 @@ * mid-skill in the same project may get its entry closed early, which shows * up as a traceable source:"stop-hook" completion rather than a silent leak. */ +import { spawn } from 'child_process'; +import { createHash } from 'crypto'; import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; +import { fileURLToPath } from 'url'; import { runBin } from './spawn-bin'; import { resolveStateRoot } from '../../../lib/state-root'; +import { readVersionedSlugCache } from '../../../lib/bin-context'; import { logHookError as sharedLogHookError } from './hook-log'; const DEADLINE_MS = 2000; +const REPAIR_SLUG_TIMEOUT_MS = 30_000; +const LOCK_TTL_MS = 60_000; +const MISS_TTL_MS = 5 * 60_000; const MAX_TIMELINE_BYTES = 10 * 1024 * 1024; const TAIL_WINDOW_BYTES = 256 * 1024; const startedAt = Date.now(); @@ -82,33 +101,134 @@ interface TimelineEntry { ts?: string; } -function main(): void { - let cwd = process.cwd(); +function stateDir(): string { + return path.join(resolveStateRoot(), 'timeline-stop-hook'); +} + +function lockPath(): string { + return path.join(stateDir(), 'repair.lock'); +} + +function isFresh(file: string, ttlMs: number): boolean { + try { + return Date.now() - fs.statSync(file).mtimeMs < ttlMs; + } catch { + return false; + } +} + +/** Exclusive create; a lock older than LOCK_TTL_MS belongs to a dead repair and is taken over. */ +function acquireRepairLock(): boolean { + const lock = lockPath(); + fs.mkdirSync(path.dirname(lock), { recursive: true }); + for (let attempt = 0; attempt < 2; attempt++) { + try { + fs.writeFileSync(lock, `${process.pid}\n`, { flag: 'wx' }); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST' || isFresh(lock, LOCK_TTL_MS)) return false; + try { + fs.unlinkSync(lock); + } catch { + // another process took it over first; the retry decides + } + } + } + return false; +} + +function releaseRepairLock(): void { + try { + if (fs.readFileSync(lockPath(), 'utf8').trim() === String(process.pid)) fs.unlinkSync(lockPath()); + } catch { + // already gone + } +} + +function withRepairLock(fn: () => void): void { + if (!acquireRepairLock()) return; // a repair is running; the next Stop picks up whatever it left + try { + fn(); + } finally { + releaseRepairLock(); + } +} + +/** GSTACK_PROJECT_SLUG, sanitized as gstack-slug does; an unusable pin is ignored. */ +function slugOverride(): string | undefined { + const slug = (process.env.GSTACK_PROJECT_SLUG || '').replace(/[^a-zA-Z0-9._-]/g, ''); + return slug && slug !== '.' && slug !== '..' ? slug : undefined; +} + +function readStopCwd(): string { try { const stdin = fs.readFileSync(0, 'utf8'); if (stdin.trim()) { const payload = JSON.parse(stdin) as { cwd?: string }; - if (payload.cwd && fs.existsSync(payload.cwd)) cwd = payload.cwd; + if (payload.cwd && fs.existsSync(payload.cwd)) return payload.cwd; } } catch { // Bad/absent stdin: fall through with process.cwd() — repair is still valid. } + return process.cwd(); +} - // Resolve the project slug the same way the preamble did (GSTACK_PROJECT_SLUG - // override, project-root walk, remote-derived slug). - let slug = ''; - try { - const r = runBin('gstack-slug', [], { cwd, encoding: 'utf8', timeout: DEADLINE_MS }); - const m = (r.stdout ?? '').toString().match(/^SLUG=([A-Za-z0-9._-]+)$/m); - if (m) slug = m[1]; - } catch { - // fall through - } - if (!slug) { - logHookError('could not resolve project slug — nothing repaired'); +/** + * Cache miss: hand the slow resolve to a detached copy of this hook and return + * now. Skipped while a repair holds the lock, and for MISS_TTL_MS after the + * last spawn for this cwd (the marker is written before the spawn, so a child + * that fails to resolve is not respawned every turn). + */ +function spawnRepair(cwd: string): void { + if (isFresh(lockPath(), LOCK_TTL_MS)) return; + const marker = path.join(stateDir(), 'miss', createHash('sha256').update(cwd).digest('hex').slice(0, 16)); + if (isFresh(marker, MISS_TTL_MS)) return; + fs.mkdirSync(path.dirname(marker), { recursive: true }); + fs.writeFileSync(marker, `${cwd}\n`); + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--repair', cwd], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }); + child.on('error', (err) => logHookError(`could not start the background repair: ${err.message}`)); + child.unref(); +} + +/** The detached child: resolve the slug with gstack-slug (which writes the cache), then repair. */ +function backgroundRepair(cwd: string): void { + withRepairLock(() => { + const r = runBin('gstack-slug', [], { cwd, encoding: 'utf8', timeout: REPAIR_SLUG_TIMEOUT_MS, windowsHide: true }); + const slug = (r.stdout ?? '').toString().match(/^SLUG=([A-Za-z0-9._-]+)$/m)?.[1]; + if (!slug) { + const why = r.error ? (r.error as NodeJS.ErrnoException).code ?? r.error.message : `exit ${r.status}`; + logHookError(`could not resolve project slug for ${cwd} (gstack-slug: ${why}) — nothing repaired; retried after ${MISS_TTL_MS / 60_000} min`); + return; + } + repairTimeline(slug, startedAt + LOCK_TTL_MS); + }); +} + +function main(): void { + if (process.argv[2] === '--repair' && process.argv[3]) { + backgroundRepair(process.argv[3]); return; } + const cwd = readStopCwd(); + const slug = slugOverride() ?? readVersionedSlugCache(resolveStateRoot(), cwd); + if (!slug) { + spawnRepair(cwd); + return; + } + withRepairLock(() => repairTimeline(slug, startedAt + DEADLINE_MS)); +} +/** + * Append a completion for every dangling "started" in the project's timeline. + * `deadline` (epoch ms) is re-checked before the write: the foreground hook's + * 2 s budget, or for the background child the lock TTL, so a write never lands + * after the lock could have been taken over. + */ +function repairTimeline(slug: string, deadline: number): void { const timelinePath = path.join(resolveStateRoot(), 'projects', slug, 'timeline.jsonl'); let stat: fs.Stats; try { @@ -171,8 +291,8 @@ function main(): void { } if (dangling.length === 0) return; - if (Date.now() - startedAt > DEADLINE_MS) { - logHookError('internal 2s budget exhausted before write — skipped (fail-open)'); + if (Date.now() > deadline) { + logHookError('time budget exhausted before write — skipped (fail-open)'); return; } diff --git a/package.json b/package.json index d66883363..8b6d55e4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.89", + "version": "1.91.90", "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 6a3805002..1e6e63fe2 100644 --- a/scripts/lib/windows-curation.ts +++ b/scripts/lib/windows-curation.ts @@ -370,6 +370,16 @@ export const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [ // orphan leak it pins was reported on Windows (#2019). reason: 'spawns terminal-agent (no Playwright), not the browse server; owner-orphan leak is a Windows defect', }, + { + file: 'test/timeline-stop-hook-slug.test.ts', + // Trips the "spawns bin/ shebang script" pattern via the fixture's + // path.join(tree, 'bin', 'gstack-slug') stub, but the hook runs through + // Bun argv and reaches the stub through runBin, which hands it to Git + // Bash on win32. #3103 is a Windows latency defect: this lane is the one + // place the hook's return-within-budget assertion meets real Windows + // process startup. + reason: '#3103: the hook runs through Bun argv and its gstack-slug stub through runBin (Git Bash on win32); the return-within-budget timing must run on windows-latest', + }, ]; /** diff --git a/setup b/setup index bfe9217d6..ceb9a0849 100755 --- a/setup +++ b/setup @@ -387,6 +387,59 @@ _persist_claude_model() { # ─── Quiet mode helper ──────────────────────────────────────── log() { [ "$QUIET" -eq 0 ] && echo "$@" || true; } +# _repair_crlf_checkout — #3110: a checkout made before an `eol=lf` rule +# existed (Git for Windows sets core.autocrlf=true) keeps its CRLF copies, +# because git never rewrites a stat-clean file; `add --renormalize`, +# `checkout --` and `checkout-index -f` all leave one alone. One +# `git ls-files --eol` pass finds tracked files whose rule says LF but whose +# working copy has CRLF. Locally edited ones are named and left alone; the +# rest are removed and checked out again in one batch. Runs under the install +# registry lock, the one lock every setup takes, so two setups never race on +# the same files. +_repair_crlf_checkout() { + local dir="$SOURCE_GSTACK_DIR" line info p e rc=0 + local stale=() edited=() repair=() + [ -e "$dir/.git" ] || return 0 + command -v git >/dev/null 2>&1 || return 0 + while IFS= read -r -d '' line; do + info="${line%% *}" + case "$info" in *eol=lf*) ;; *) continue ;; esac + case "$info" in *w/crlf*|*w/mixed*) stale+=("${line#* }") ;; esac + done < <(git -C "$dir" ls-files --eol -z 2>/dev/null) + [ "${#stale[@]}" -gt 0 ] || return 0 + if ! _gstack_registry_lock; then + echo " warning: skipped the CRLF repair of ${#stale[@]} file(s) in $dir: another setup holds the install registry lock." >&2 + echo " Fix: re-run ./setup once the other setup finishes (cd $dir && ./setup). See docs/troubleshooting.md#setup-crlf-repair" >&2 + return 0 + fi + while IFS= read -r -d '' line; do + edited+=("${line:3}") + done < <(git -C "$dir" --literal-pathspecs status --porcelain=v1 -z --untracked-files=no --no-renames -- "${stale[@]}" 2>/dev/null) + for p in "${stale[@]}"; do + for e in ${edited[@]+"${edited[@]}"}; do [ "$e" = "$p" ] && continue 2; done + repair+=("$p") + done + if [ "${#repair[@]}" -gt 0 ]; then + for p in "${repair[@]}"; do rm -f "$dir/$p" || rc=1; done + [ "$rc" -ne 0 ] || printf '%s\0' "${repair[@]}" \ + | git -C "$dir" --literal-pathspecs checkout --pathspec-from-file=- --pathspec-file-nul >/dev/null 2>&1 || rc=1 + if [ "$rc" -eq 0 ]; then + log " Rewrote ${#repair[@]} file(s) checked out with CRLF line endings as LF (#3110)." + else + echo " warning: the CRLF repair could not rewrite every file in $dir (rm or git checkout failed)." >&2 + echo " Fix: cd $dir && git checkout -- . && ./setup See docs/troubleshooting.md#setup-crlf-repair" >&2 + fi + fi + if [ "${#edited[@]}" -gt 0 ]; then + echo " warning: left ${#edited[@]} locally edited file(s) with CRLF line endings that gstack pins to LF; a Linux bash cannot run them:" >&2 + printf ' %s\n' "${edited[@]}" >&2 + echo " Why: setup rewrites only unmodified files, so your edits are kept." >&2 + echo " Fix (keeps your edits, rewrites them as LF): cd $dir && git stash && git stash pop See docs/troubleshooting.md#setup-crlf-repair" >&2 + fi + _gstack_registry_unlock +} +[ -n "${GSTACK_SETUP_REFRESH_CHILD:-}" ] || _repair_crlf_checkout + # ─── --refresh-registered: refresh exactly the registered installs (#1925) ─── # What /gstack-upgrade and the team-mode auto-update run. Each install this # checkout registered is refreshed by running setup for that one host, in its diff --git a/setup-gbrain/SKILL.md b/setup-gbrain/SKILL.md index 06c557d01..f24634b53 100644 --- a/setup-gbrain/SKILL.md +++ b/setup-gbrain/SKILL.md @@ -827,12 +827,16 @@ Do NOT print the actual token in the curl command — leave the placeholder ```bash SLUG="setup-gbrain-smoke-test-$(date +%s)" -echo "Set up on $(date). Smoke test for /setup-gbrain." | gbrain put "$SLUG" -gbrain search "smoke test" | grep -i "$SLUG" +SMOKE_PAGE=$(mktemp "${TMPDIR:-/tmp}/gstack-gbrain-smoke.XXXXXX") +echo "Set up on $(date). Smoke test for /setup-gbrain." > "$SMOKE_PAGE" +gbrain put "$SLUG" < "$SMOKE_PAGE"; PUT_RC=$? +rm -f "$SMOKE_PAGE" +[ "$PUT_RC" -eq 0 ] && gbrain search "smoke test" | grep -i "$SLUG" ``` -Confirms the round trip. On failure, surface `gbrain doctor --json` output -and STOP with a NEEDS_CONTEXT escalation. +Confirms the round trip. The page goes in from a temp file, not a pipe: on +Windows, gbrain failed to read a piped page. On failure, surface +`gbrain doctor --json` output and STOP with a NEEDS_CONTEXT escalation. --- diff --git a/setup-gbrain/SKILL.md.tmpl b/setup-gbrain/SKILL.md.tmpl index 3af5f03be..f49b5f5bb 100644 --- a/setup-gbrain/SKILL.md.tmpl +++ b/setup-gbrain/SKILL.md.tmpl @@ -483,12 +483,16 @@ Do NOT print the actual token in the curl command — leave the placeholder ```bash SLUG="setup-gbrain-smoke-test-$(date +%s)" -echo "Set up on $(date). Smoke test for /setup-gbrain." | gbrain put "$SLUG" -gbrain search "smoke test" | grep -i "$SLUG" +SMOKE_PAGE=$(mktemp "${TMPDIR:-/tmp}/gstack-gbrain-smoke.XXXXXX") +echo "Set up on $(date). Smoke test for /setup-gbrain." > "$SMOKE_PAGE" +gbrain put "$SLUG" < "$SMOKE_PAGE"; PUT_RC=$? +rm -f "$SMOKE_PAGE" +[ "$PUT_RC" -eq 0 ] && gbrain search "smoke test" | grep -i "$SLUG" ``` -Confirms the round trip. On failure, surface `gbrain doctor --json` output -and STOP with a NEEDS_CONTEXT escalation. +Confirms the round trip. The page goes in from a temp file, not a pipe: on +Windows, gbrain failed to read a piped page. On failure, surface +`gbrain doctor --json` output and STOP with a NEEDS_CONTEXT escalation. --- diff --git a/test/gitattributes-eol.test.ts b/test/gitattributes-eol.test.ts new file mode 100644 index 000000000..867c72245 --- /dev/null +++ b/test/gitattributes-eol.test.ts @@ -0,0 +1,83 @@ +/** + * .gitattributes line endings for executables (#3110). + * + * Git for Windows checks out with core.autocrlf=true. A tracked script with no + * `eol` rule is written with CRLF there, and a Linux bash reading that + * checkout (a synced tree, WSL, a bind mount) fails on `set -o pipefail\r`. + * `bin/*` is anchored at the root, so nested bin/ dirs, the hook shims, the + * CSO image scripts and the app launcher had no rule. This pins the rules for + * every tracked file that starts with `#!`, and keeps the broad patterns from + * marking a binary file as text. + */ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +const ROOT = path.resolve(import.meta.dir, '..'); + +function git(args: string[], input?: string): string { + const r = spawnSync('git', args, { cwd: ROOT, encoding: 'utf-8', input, timeout: 30_000, maxBuffer: 64 * 1024 * 1024 }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`); + return r.stdout; +} + +function trackedShebangFiles(): string[] { + return git(['ls-files', '-z']).split('\0').filter((f) => { + if (!f) return false; + const full = path.join(ROOT, f); + let fd: number | undefined; + try { + if (!fs.statSync(full).isFile()) return false; + fd = fs.openSync(full, 'r'); + const head = Buffer.alloc(2); + return fs.readSync(fd, head, 0, 2, 0) === 2 && head.toString('latin1') === '#!'; + } catch { + return false; + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + }); +} + +describe('.gitattributes eol rules (#3110)', () => { + test('every tracked file that starts with #! resolves to eol=lf', () => { + const files = trackedShebangFiles(); + expect(files.length).toBeGreaterThan(100); + const out = git(['check-attr', '-z', '--stdin', 'eol'], files.join('\0') + '\0').split('\0'); + const missing: string[] = []; + for (let i = 0; i + 2 < out.length; i += 3) { + if (out[i + 2] !== 'lf') missing.push(`${out[i]} (eol: ${out[i + 2]})`); + } + expect(missing).toEqual([]); + }); + + test('the nested executables named in #3110 are covered', () => { + const named = [ + 'autoplan/bin/phase-publication-hook', + 'plan-ceo-review/bin/mode-handoff-hook', + 'hosts/claude/hooks/timeline-stop-hook', + 'browse/bin/remote-slug', + 'lib/cso/images/entrypoint', + 'scripts/app/gstack-browser', + 'browse/test/fixtures/mock-claude/claude', + ]; + const out = git(['check-attr', 'eol', '--', ...named]); + for (const f of named) expect(out).toContain(`${f}: eol: lf`); + }); + + test('no file git stores as binary is marked text by the broad patterns', () => { + const wronglyText = git(['ls-files', '--eol']) + .split('\n') + .filter((line) => line.startsWith('i/-text') && /\sattr\/text\b/.test(line)); + expect(wronglyText).toEqual([]); + }); + + test('every shebang file is stored with LF in the index', () => { + const files = new Set(trackedShebangFiles()); + const crlf = git(['ls-files', '--eol']) + .split('\n') + .filter((line) => /^i\/(crlf|mixed)\s/.test(line) && files.has(line.split('\t')[1] ?? '')); + expect(crlf).toEqual([]); + }); +}); diff --git a/test/gstack-gbrain-source-wireup.test.ts b/test/gstack-gbrain-source-wireup.test.ts index 2fc906441..c792ff196 100644 --- a/test/gstack-gbrain-source-wireup.test.ts +++ b/test/gstack-gbrain-source-wireup.test.ts @@ -345,6 +345,16 @@ describe('gstack-gbrain-source-wireup — wireup mode', () => { expect(r.status).toBe(0); }); + test('--strict with no .git: exits 2 and names the current init command (#1675)', () => { + fs.mkdirSync(tmpHome, { recursive: true }); + fs.writeFileSync(path.join(tmpHome, '.gstack-brain-remote.txt'), 'git@github.com:user/gstack-brain-bob.git\n'); + makeFakeGbrain({}); + const r = run(['--strict']); + expect(r.status).toBe(2); + expect(r.stderr).toContain('run /setup-gbrain Step 7 (gstack-artifacts-init) first'); + expect(r.stderr).not.toContain('brain-init'); + }); + test('source-id from --source-id flag overrides everything', () => { setupGstackRepo('git@github.com:user/gstack-brain-different.git'); makeFakeGbrain({}); diff --git a/test/setup-crlf-repair.test.ts b/test/setup-crlf-repair.test.ts new file mode 100644 index 000000000..0173fe0c3 --- /dev/null +++ b/test/setup-crlf-repair.test.ts @@ -0,0 +1,158 @@ +/** + * setup's CRLF repair (#3110). + * + * Git for Windows checks out with core.autocrlf=true, so a script with no + * `eol` rule was written with CRLF. Adding the rule later does not fix the + * copy: git never rewrites a stat-clean file whose blob did not change. The + * fixture makes that state with a real autocrlf=true clone, adds the rule + * upstream, pulls, and runs setup's _repair_crlf_checkout under the real + * install-registry lock. The repaired hook must have LF bytes and run under + * bash; a locally edited CRLF file is named in a warning and left alone. + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8'); +const HOOK_SHIM = fs.readFileSync(path.join(ROOT, 'autoplan', 'bin', 'phase-publication-hook'), 'utf-8'); +const HOOK_REL = 'autoplan/bin/phase-publication-hook'; +const EDITED_REL = 'autoplan/bin/edited-hook'; + +function extractFn(name: string): string { + const start = SETUP_SRC.indexOf(`${name}() {`); + const end = SETUP_SRC.indexOf('\n}\n', start); + if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`); + return SETUP_SRC.slice(start, end + 2); +} + +let tmp: string; +let upstream: string; +let clone: string; +let stateRoot: string; +let env: Record; + +function run(cmd: string, args: string[], cwd: string, input?: string) { + return spawnSync(cmd, args, { cwd, env, input, encoding: 'utf-8', timeout: 30_000 }); +} + +function git(cwd: string, ...args: string[]): string { + const r = run('git', args, cwd); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); + return r.stdout; +} + +function write(dir: string, rel: string, body: string, mode = 0o644): void { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), body, { mode }); +} + +function repair() { + return run('bash', ['-c', [ + 'set -e', + 'QUIET=0', + 'log() { [ "$QUIET" -eq 0 ] && echo "$@" || true; }', + `SOURCE_GSTACK_DIR=${JSON.stringify(clone)}`, + `GSTACK_STATE_ROOT=${JSON.stringify(stateRoot)}`, + `. ${JSON.stringify(path.join(ROOT, 'bin', 'gstack-install-registry.sh'))}`, + extractFn('_repair_crlf_checkout'), + '_repair_crlf_checkout', + ].join('\n')], clone); +} + +function runHook() { + return run('bash', [path.join(clone, HOOK_REL)], clone, JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'ls' } })); +} + +beforeAll(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-crlf-')); + upstream = path.join(tmp, 'upstream'); + clone = path.join(tmp, 'clone'); + stateRoot = path.join(tmp, 'state'); + const gitconfig = path.join(tmp, 'gitconfig'); + fs.writeFileSync(gitconfig, '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n'); + env = { + ...process.env, + HOME: tmp, + GIT_CONFIG_GLOBAL: gitconfig, + GIT_CONFIG_NOSYSTEM: '1', + PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH ?? ''}`, + }; + + fs.mkdirSync(upstream); + git(upstream, 'init', '-q'); + write(upstream, '.gitattributes', '.gitattributes text eol=lf\n*.ts text eol=lf\n'); + write(upstream, HOOK_REL, HOOK_SHIM, 0o755); + write(upstream, `${HOOK_REL}.ts`, "process.stdout.write('{}');\n"); + write(upstream, EDITED_REL, '#!/usr/bin/env bash\nset -euo pipefail\necho upstream\n', 0o755); + git(upstream, 'add', '-A'); + git(upstream, 'commit', '-qm', 'old release: no eol rule for nested bin/'); + + git(tmp, 'clone', '-q', '-c', 'core.autocrlf=true', upstream, clone); + + write(upstream, '.gitattributes', '.gitattributes text eol=lf\n*.ts text eol=lf\n**/bin/* text eol=lf\n'); + git(upstream, 'commit', '-qam', 'new release: nested bin/ is LF'); + git(clone, 'pull', '-q', '--ff-only'); + + const edited = path.join(clone, EDITED_REL); + fs.writeFileSync(edited, fs.readFileSync(edited, 'utf-8') + 'echo local edit\r\n'); +}); + +afterAll(() => { + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe('setup CRLF repair (#3110)', () => { + test('fixture precondition: the pulled rule left the CRLF copy, and Linux bash cannot run it', () => { + expect(fs.readFileSync(path.join(clone, HOOK_REL), 'utf-8')).toContain('\r\n'); + expect(git(clone, 'check-attr', 'eol', '--', HOOK_REL)).toContain('eol: lf'); + expect(git(clone, 'status', '--porcelain', '--untracked-files=no')).toBe(` M ${EDITED_REL}\n`); + const r = runHook(); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain('pipefail'); + }); + + test('rewrites the unmodified file as LF, keeps the edited one, and the hook runs', () => { + const r = repair(); + expect(r.status).toBe(0); + expect(r.stdout).toContain('Rewrote 1 file(s)'); + + const repaired = fs.readFileSync(path.join(clone, HOOK_REL)); + expect(repaired.includes('\r')).toBe(false); + expect(repaired.toString('utf-8')).toBe(git(clone, 'show', `HEAD:${HOOK_REL}`)); + expect(fs.statSync(path.join(clone, HOOK_REL)).mode & 0o111).not.toBe(0); + expect(git(clone, 'status', '--porcelain', '--untracked-files=no')).toBe(` M ${EDITED_REL}\n`); + + const edited = fs.readFileSync(path.join(clone, EDITED_REL), 'utf-8'); + expect(edited).toContain('echo local edit\r\n'); + expect(edited).toContain('\r\n'); + expect(r.stderr).toContain(EDITED_REL); + expect(r.stderr).toContain(`cd ${clone} && git stash && git stash pop`); + expect(r.stderr).toContain('docs/troubleshooting.md#setup-crlf-repair'); + expect(fs.existsSync(path.join(stateRoot, 'installs.tsv.lock'))).toBe(false); + + const hook = runHook(); + expect(hook.stderr).toBe(''); + expect(hook.status).toBe(0); + expect(hook.stdout.trim()).toBe('{}'); + }); + + test('a second run rewrites nothing', () => { + const r = repair(); + expect(r.status).toBe(0); + expect(r.stdout).not.toContain('Rewrote'); + expect(r.stderr).toContain(EDITED_REL); + }); + + test('the printed fix rewrites the edited file as LF and keeps the edit', () => { + git(clone, 'stash', '-q'); + git(clone, 'stash', 'pop', '-q'); + const edited = fs.readFileSync(path.join(clone, EDITED_REL), 'utf-8'); + expect(edited.includes('\r')).toBe(false); + expect(edited).toContain('echo local edit\n'); + const r = repair(); + expect(r.stdout + r.stderr).toBe(''); + }); +}); diff --git a/test/timeline-stop-hook-slug.test.ts b/test/timeline-stop-hook-slug.test.ts new file mode 100644 index 000000000..3b96bd95e --- /dev/null +++ b/test/timeline-stop-hook-slug.test.ts @@ -0,0 +1,163 @@ +/** + * Timeline Stop hook slug resolution (#3103). + * + * Through Git Bash on Windows, gstack-slug took 2.2-2.8 s, past the hook's 2 s + * budget, so the synchronous spawn timed out on every Stop and nothing was + * repaired. The hook now reads gstack-slug's versioned cache in-process and, + * on a miss, hands the slow resolve to a detached child under a TTL lock with + * a per-cwd negative cache. + * + * Fixture: a copy of the hook and its imports in a temp tree whose + * bin/gstack-slug is a stub (runBin resolves bin/ from the hook's own + * location). The stub logs each call, sleeps, then prints a slug or fails. + * The hook runs through Bun argv, never as a shebang. + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { slugCacheFile } from '../lib/bin-context'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const HOOK_FILES = [ + 'hosts/claude/hooks/timeline-stop-hook.ts', + 'hosts/claude/hooks/spawn-bin.ts', + 'hosts/claude/hooks/hook-log.ts', + 'lib/state-root.ts', + 'lib/bin-context.ts', + 'lib/remote-identity.ts', +]; +const SLUG = 'stop-hook-slug-test'; +const BUDGET_MS = 2000; + +let tmp: string; +let tree: string; +let stateRoot: string; +let projectDir: string; +let callLog: string; + +function writeStub(body: string): void { + const stub = path.join(tree, 'bin', 'gstack-slug'); + fs.mkdirSync(path.dirname(stub), { recursive: true }); + fs.writeFileSync(stub, `#!/usr/bin/env bash\necho "$PWD" >> ${JSON.stringify(callLog)}\n${body}\n`, { mode: 0o755 }); +} + +function seedTimeline(): string { + const file = path.join(stateRoot, 'projects', SLUG, 'timeline.jsonl'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ skill: 'review', event: 'started', session: '11-1' }) + '\n'); + return file; +} + +function repairs(file: string): number { + return fs.readFileSync(file, 'utf-8').split('\n').filter((l) => l.includes('"source":"stop-hook"')).length; +} + +function runHook(): { status: number | null; ms: number } { + const env: Record = { ...process.env, GSTACK_STATE_ROOT: stateRoot }; + delete env.GSTACK_PROJECT_SLUG; + const t0 = Date.now(); + const r = spawnSync(process.execPath, [path.join(tree, 'hosts', 'claude', 'hooks', 'timeline-stop-hook.ts')], { + input: JSON.stringify({ hook_event_name: 'Stop', cwd: projectDir }), + env, + timeout: 15_000, + }); + return { status: r.status, ms: Date.now() - t0 }; +} + +function calls(): number { + return fs.existsSync(callLog) ? fs.readFileSync(callLog, 'utf-8').split('\n').filter(Boolean).length : 0; +} + +const lockFile = () => path.join(stateRoot, 'timeline-stop-hook', 'repair.lock'); + +async function until(cond: () => boolean, timeoutMs = 20_000): Promise { + const end = Date.now() + timeoutMs; + while (!cond()) { + if (Date.now() > end) throw new Error('condition not met before timeout'); + await Bun.sleep(100); + } +} + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-stop-slug-')); + tree = path.join(tmp, 'tree'); + stateRoot = path.join(tmp, 'state'); + projectDir = path.join(tmp, 'project'); + callLog = path.join(tmp, 'slug-calls.log'); + fs.mkdirSync(stateRoot); + fs.mkdirSync(projectDir); + for (const rel of HOOK_FILES) { + fs.mkdirSync(path.dirname(path.join(tree, rel)), { recursive: true }); + fs.copyFileSync(path.join(ROOT, rel), path.join(tree, rel)); + } +}); + +afterEach(async () => { + await until(() => !fs.existsSync(lockFile())).catch(() => {}); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe('timeline-stop-hook slug resolution (#3103)', () => { + test('cache hit: repairs in the foreground without spawning gstack-slug', () => { + writeStub('exit 1'); + const timeline = seedTimeline(); + const cache = slugCacheFile(stateRoot, projectDir); + fs.mkdirSync(path.dirname(cache), { recursive: true }); + fs.writeFileSync(cache, `v2:${SLUG}`); + + expect(runHook().status).toBe(0); + expect(repairs(timeline)).toBe(1); + expect(calls()).toBe(0); + expect(fs.existsSync(lockFile())).toBe(false); + }); + + test('cache miss with a slow gstack-slug: the hook returns within its budget and a background child repairs', async () => { + writeStub(`sleep 3\necho "SLUG=${SLUG}"\necho "BRANCH=main"`); + const timeline = seedTimeline(); + + const r = runHook(); + expect(r.status).toBe(0); + expect(r.ms).toBeLessThan(BUDGET_MS); + expect(repairs(timeline)).toBe(0); + + await until(() => repairs(timeline) === 1); + await until(() => !fs.existsSync(lockFile())); + expect(calls()).toBe(1); + expect(repairs(timeline)).toBe(1); + }, 30_000); + + test('negative cache: a directory gstack-slug cannot resolve spawns one child, not one per turn', async () => { + writeStub('exit 1'); + seedTimeline(); + + expect(runHook().status).toBe(0); + await until(() => calls() === 1 && !fs.existsSync(lockFile())); + expect(fs.readFileSync(path.join(stateRoot, 'hook-errors.log'), 'utf-8')).toContain('could not resolve project slug'); + + expect(runHook().status).toBe(0); + expect(runHook().status).toBe(0); + await Bun.sleep(1000); + expect(calls()).toBe(1); + }, 30_000); + + test('a fresh lock defers the repair; a lock past its TTL is taken over', () => { + writeStub('exit 1'); + const timeline = seedTimeline(); + const cache = slugCacheFile(stateRoot, projectDir); + fs.mkdirSync(path.dirname(cache), { recursive: true }); + fs.writeFileSync(cache, `v2:${SLUG}`); + fs.mkdirSync(path.dirname(lockFile()), { recursive: true }); + fs.writeFileSync(lockFile(), '999999\n'); + + expect(runHook().status).toBe(0); + expect(repairs(timeline)).toBe(0); + + const old = new Date(Date.now() - 120_000); + fs.utimesSync(lockFile(), old, old); + expect(runHook().status).toBe(0); + expect(repairs(timeline)).toBe(1); + expect(fs.existsSync(lockFile())).toBe(false); + }); +});