mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
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:
16 files changed
+698
-30
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
;;
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user