v1.91.90.0 fix(windows): LF for nested scripts with a setup CRLF repair, Stop hook slug fast path, setup-gbrain smoke test via temp file (Wave 9 Lane G) (#3142)

.gitattributes pins nested bin/, hook shims, lib/cso/images and scripts/app
to LF, and setup rewrites copies an older checkout left with CRLF, leaving
locally edited files alone with a warning (#3110). The timeline Stop hook
reads the slug from gstack-slug's cache and hands a miss to a detached,
locked, negatively cached repair child (#3103). The /setup-gbrain smoke test
writes its page to a temp file instead of piping into gbrain put (#1675).

Co-authored-by: Mr-B-1 <214900604+Mr-B-1@users.noreply.github.com>
Co-authored-by: Andrei Bacu <63974644+Peaky8linders@users.noreply.github.com>
Co-authored-by: Elliot Drel <156480527+ElliotDrel@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-11 17:59:13 +00:00
1 parent e6edff0265
commit 6e250405fe
16 files changed
+698 -30

No files matched your search

+13
View File
@@ -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
+21
View File
@@ -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.**
+1 -1
View File
@@ -1 +1 @@
1.91.89.0
1.91.90.0
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
;;
+29
View File
@@ -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.
<a id="setup-crlf-repair"></a>
### `warning: left <n> 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.
+138 -18
View File
@@ -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 <cwd>`) 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;
}
+1 -1
View File
@@ -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",
+10
View File
@@ -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',
},
];
/**
+53
View File
@@ -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
+8 -4
View File
@@ -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.
---
+8 -4
View File
@@ -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.
---
+83
View File
@@ -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([]);
});
});
+10
View File
@@ -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({});
+158
View File
@@ -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<string, string | undefined>;
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('');
});
});
+163
View File
@@ -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<string, string | undefined> = { ...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<void> {
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);
});
});