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

Co-authored-by: yuriysan <63948747+yuriysan@users.noreply.github.com>
Co-authored-by: y$un_ <3579304+szsunyuan@users.noreply.github.com>
Co-authored-by: Frederik Reventlow <267160539+dmfrede@users.noreply.github.com>
Co-authored-by: skleinbe-ny <226563731+skleinbe-ny@users.noreply.github.com>
Co-authored-by: bkermen <184220642+bkermen@users.noreply.github.com>
Co-authored-by: Drew Jankowski <3200063+andrewkjankowski@users.noreply.github.com>
2026-10-11 16:48:11 +00:00

275 lines
15 KiB
TypeScript

import { afterEach, describe, expect, test } from 'bun:test';
import { execFileSync, spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
// The auto-updater fast-forwards the live checkout before setup runs, and
// Claude Code runs hooks straight from that checkout, so setup's parse gate
// alone cannot protect an auto-update. The updater parse-checks every hook of
// the incoming revision (git archive into a temp dir, bin/gstack-hook-check)
// before the fast-forward; a failure holds the update like a too-old Bun:
// the checkout does not move and the installed hooks keep running.
const ROOT = path.resolve(import.meta.dir, '..');
const SCRIPT = path.join(ROOT, 'bin', 'gstack-session-update');
const HOOK = 'hosts/claude/hooks/question-log-hook';
const bases: string[] = [];
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
function git(cwd: string, ...args: string[]): string {
return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 }).trim();
}
function makeFixture() {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-supd-hooks-'));
bases.push(base);
const origin = path.join(base, 'origin.git');
const seed = path.join(base, 'seed');
const install = path.join(base, 'install');
const state = path.join(base, 'state');
const home = path.join(base, 'home');
fs.mkdirSync(state, { recursive: true });
fs.mkdirSync(path.join(home, '.claude'), { recursive: true });
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 });
fs.mkdirSync(path.join(seed, 'bin'), { recursive: true });
fs.mkdirSync(path.join(seed, path.dirname(HOOK)), { recursive: true });
fs.writeFileSync(path.join(seed, 'VERSION'), '1.0.0\n');
fs.writeFileSync(path.join(seed, 'bin', 'gstack-config'),
'#!/usr/bin/env bash\nif [ "$1" = "get" ]; then case "$2" in auto_upgrade) echo true;; skill_prefix) echo false;; *) echo "";; esac; fi\nexit 0\n', { mode: 0o755 });
fs.writeFileSync(path.join(seed, 'bin', 'gstack-patch-names'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
// Stub setup: records each run, and registers the hook the way real setup
// does, which is where the check reads the hook list from.
fs.writeFileSync(path.join(seed, 'setup'), [
'#!/usr/bin/env bash',
'echo "$*" >> "$SETUP_CALLS"',
`LOG_HOOK="$(_hook_command_path ${HOOK} || true)"`,
'exit 0',
].join('\n') + '\n', { mode: 0o755 });
fs.copyFileSync(path.join(ROOT, HOOK), path.join(seed, HOOK));
fs.chmodSync(path.join(seed, HOOK), 0o755);
fs.writeFileSync(path.join(seed, `${HOOK}.ts`), "import { note } from './note';\nconsole.log(note);\n");
fs.writeFileSync(path.join(seed, path.dirname(HOOK), 'note.ts'), "export const note = 'old hook ran';\n");
git(seed, 'init', '-q');
git(seed, 'add', '-A');
git(seed, 'commit', '-q', '-m', 'seed');
git(seed, 'branch', '-M', 'main');
git(seed, 'remote', 'add', 'origin', origin);
git(seed, 'push', '-q', 'origin', 'main');
execFileSync('git', ['clone', '-q', origin, install], { timeout: 30_000 });
// An existing registration pointing into the live checkout.
const settings = path.join(home, '.claude', 'settings.json');
const hookCmd = path.join(install, HOOK);
fs.writeFileSync(settings, JSON.stringify({ hooks: { PostToolUse: [{ matcher: 'AskUserQuestion', hooks: [{ type: 'command', command: hookCmd }] }] } }, null, 2));
return { base, seed, install, state, home, settings, hookCmd, calls: path.join(base, 'calls') };
}
type Fx = ReturnType<typeof makeFixture>;
function release(fx: Fx, version: string, edit: () => void) {
edit();
fs.writeFileSync(path.join(fx.seed, 'VERSION'), `${version}\n`);
git(fx.seed, 'add', '-A');
git(fx.seed, 'commit', '-qm', `release ${version}`);
git(fx.seed, 'push', '-q', 'origin', 'main');
}
function runHook(fx: Fx, script = SCRIPT) {
return spawnSync('bash', [script], {
encoding: 'utf8',
env: { PATH: `${path.dirname(process.execPath)}:${process.env.PATH}`, HOME: fx.home, GSTACK_DIR: fx.install, GSTACK_STATE_ROOT: fx.state, SETUP_CALLS: fx.calls, TMPDIR: fx.base },
timeout: 20_000,
});
}
async function waitFor(fx: Fx, pattern: RegExp, ms = 15_000): Promise<string> {
const logFile = path.join(fx.state, 'analytics', 'session-update.log');
const deadline = Date.now() + ms;
let content = '';
while (Date.now() < deadline) {
content = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : '';
if (pattern.test(content) && !fs.existsSync(path.join(fx.state, '.setup-lock'))) return content;
await new Promise(r => setTimeout(r, 100));
}
throw new Error(`timed out waiting for ${pattern}; log:\n${content}`);
}
/** Run the hook the existing registration names, as Claude Code would. */
function runRegisteredHook(fx: Fx) {
const settings = JSON.parse(fs.readFileSync(fx.settings, 'utf8'));
const cmd = settings.hooks.PostToolUse[0].hooks[0].command as string;
return spawnSync('/bin/sh', ['-c', cmd], { encoding: 'utf8', input: '{}', env: { PATH: `${path.dirname(process.execPath)}:${process.env.PATH}` }, timeout: 20_000 });
}
describe.skipIf(process.platform === 'win32')('gstack-session-update: incoming hooks are parse-checked before the fast-forward', () => {
test('an unparseable shim at the incoming revision holds the update; the checkout and the old hook stay', async () => {
const fx = makeFixture();
const oldHead = git(fx.install, 'rev-parse', 'HEAD');
const shimLines = fs.readFileSync(path.join(fx.seed, HOOK), 'utf8').split('\n').length;
release(fx, '1.1.0', () => fs.appendFileSync(path.join(fx.seed, HOOK), 'if then\n'));
expect(runHook(fx).stdout).toBe('');
const log = await waitFor(fx, /Z HELD hook-does-not-parse/);
expect(log).toContain(`HELD hook-does-not-parse: ${HOOK}:${shimLines}: syntax error`);
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(oldHead);
expect(fs.readFileSync(path.join(fx.install, 'VERSION'), 'utf8')).toBe('1.0.0\n');
expect(fs.existsSync(fx.calls)).toBe(false);
expect(fs.readFileSync(path.join(fx.state, 'session-update-pending'), 'utf8')).toContain(`pull=hook-does-not-parse: ${HOOK}:${shimLines}:`);
expect(fs.existsSync(path.join(fx.state, 'just-upgraded-from'))).toBe(false);
const ran = runRegisteredHook(fx);
expect(ran.status).toBe(0);
expect(ran.stdout).toContain('old hook ran');
// No leftover archive extraction.
expect(fs.readdirSync(fx.base).filter(f => f.startsWith('gstack-session-hooks-'))).toEqual([]);
// The next session start says what was held, where, and that the old hooks run.
const next = runHook(fx).stdout;
expect(next).toContain(`gstack auto-update: update held (hook-does-not-parse: ${HOOK}:${shimLines}:`);
expect(next).toContain('your current hooks keep running');
expect(next).toContain(`This is a gstack bug; report ${HOOK}:${shimLines}: `);
expect(next).toContain('https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-does-not-parse');
});
test('a broken module the incoming TypeScript entry imports holds the update', async () => {
const fx = makeFixture();
const oldHead = git(fx.install, 'rev-parse', 'HEAD');
release(fx, '1.1.0', () => fs.writeFileSync(path.join(fx.seed, path.dirname(HOOK), 'note.ts'), 'export const note = ;\n'));
runHook(fx);
const log = await waitFor(fx, /Z HELD /);
expect(log).toContain(`HELD hook-does-not-parse: ${path.dirname(HOOK)}/note.ts:1:`);
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(oldHead);
expect(runRegisteredHook(fx).stdout).toContain('old hook ran');
});
test('a parseable incoming revision fast-forwards and runs setup (control)', async () => {
const fx = makeFixture();
release(fx, '1.1.0', () => fs.writeFileSync(path.join(fx.seed, path.dirname(HOOK), 'note.ts'), "export const note = 'new hook ran';\n"));
runHook(fx);
await waitFor(fx, /Z UPDATED from=1\.0\.0 to=1\.1\.0/);
expect(fs.readFileSync(path.join(fx.install, 'VERSION'), 'utf8')).toBe('1.1.0\n');
expect(runRegisteredHook(fx).stdout).toContain('new hook ran');
});
test('the hook check runs after the Bun floor check and before the fast-forward', () => {
const src = fs.readFileSync(SCRIPT, 'utf8');
const bun = src.indexOf('HELD=$(gstack_bun_incoming_hold');
const hooks = src.indexOf('HELD=$(hook_incoming_hold');
const ff = src.indexOf('merge --ff-only');
expect(bun).toBeGreaterThan(-1);
expect(hooks).toBeGreaterThan(bun);
expect(ff).toBeGreaterThan(hooks);
});
});
// The incoming revision's own checker decides (#3088, #3100: an installed
// checker with a false positive held every later release). The installed one
// decides only when the incoming checker gives no verified, complete report.
describe.skipIf(process.platform === 'win32')('gstack-session-update: the incoming revision\'s checker decides', () => {
const FALSE_POSITIVE = `if [ "$1" = --list ]; then echo ${HOOK}; exit 0; fi\necho "fail ${HOOK} $1/${HOOK}.ts:1: bun could not bundle the TypeScript entry"; exit 1`;
const PASSES = `if [ "$1" = --list ]; then echo ${HOOK}; exit 0; fi\necho "ok ${HOOK}"; exit 0`;
const CRASHES = `if [ "$1" = --list ]; then echo ${HOOK}; exit 0; fi\necho "ok ${HOOK}"; kill -SEGV $$`;
/** The updater installed beside a stub bin/gstack-hook-check. */
function installedUpdater(fx: Fx, checker: string): string {
const bin = path.join(fx.base, 'installed-bin');
fs.mkdirSync(bin, { recursive: true });
for (const f of ['gstack-session-update', 'gstack-state-root.sh', 'gstack-egress-lib.sh', 'gstack-egress-receipt', 'gstack-bun-version.sh', 'gstack-path-spelling.sh']) {
fs.copyFileSync(path.join(ROOT, 'bin', f), path.join(bin, f));
}
fs.writeFileSync(path.join(bin, 'gstack-hook-check'), `#!/usr/bin/env bash\n${checker}\n`, { mode: 0o755 });
return path.join(bin, 'gstack-session-update');
}
/** Ship the real checker (or a stub) in the incoming revision. */
function incomingChecker(fx: Fx, stub?: string) {
if (stub === undefined) fs.copyFileSync(path.join(ROOT, 'bin', 'gstack-hook-check'), path.join(fx.seed, 'bin', 'gstack-hook-check'));
else fs.writeFileSync(path.join(fx.seed, 'bin', 'gstack-hook-check'), `#!/usr/bin/env bash\n${stub}\n`, { mode: 0o755 });
}
async function expectHeld(fx: Fx, script: string, reason: RegExp) {
const oldHead = git(fx.install, 'rev-parse', 'HEAD');
runHook(fx, script);
const log = await waitFor(fx, /Z HELD /);
expect(log).toMatch(reason);
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(oldHead);
expect(fs.readFileSync(path.join(fx.install, 'VERSION'), 'utf8')).toBe('1.0.0\n');
expect(fs.existsSync(fx.calls)).toBe(false);
expect(fs.readdirSync(fx.base).filter(f => f.startsWith('gstack-session-hooks-'))).toEqual([]);
return log;
}
test('an installed checker that always fails no longer holds a release whose own checker passes', async () => {
const fx = makeFixture();
const script = installedUpdater(fx, FALSE_POSITIVE);
release(fx, '1.1.0', () => {
incomingChecker(fx);
fs.writeFileSync(path.join(fx.seed, path.dirname(HOOK), 'note.ts'), "export const note = 'new hook ran';\n");
});
runHook(fx, script);
const log = await waitFor(fx, /Z UPDATED from=1\.0\.0 to=1\.1\.0/);
expect(log).not.toContain('HELD');
expect(log).toContain(`HOOK_CHECK_DISAGREE incoming=pass installed=${HOOK}.ts:1: bun could not bundle the TypeScript entry`);
expect(fs.readFileSync(path.join(fx.install, 'VERSION'), 'utf8')).toBe('1.1.0\n');
expect(runRegisteredHook(fx).stdout).toContain('new hook ran');
});
test('an invalid incoming hook holds even when the installed checker would pass it', async () => {
const fx = makeFixture();
const script = installedUpdater(fx, PASSES);
release(fx, '1.1.0', () => {
incomingChecker(fx);
fs.writeFileSync(path.join(fx.seed, path.dirname(HOOK), 'note.ts'), 'export const note = ;\n');
});
await expectHeld(fx, script, new RegExp(`HELD hook-does-not-parse: ${path.dirname(HOOK)}/note.ts:1: `));
expect(runRegisteredHook(fx).stdout).toContain('old hook ran');
});
test('a crashing incoming checker falls back to the installed one, which holds', async () => {
const fx = makeFixture();
const script = installedUpdater(fx, FALSE_POSITIVE);
release(fx, '1.1.0', () => incomingChecker(fx, CRASHES));
const log = await expectHeld(fx, script, new RegExp(`HELD hook-does-not-parse: ${HOOK}.ts:1: bun could not bundle`));
expect(log).toMatch(/HOOK_CHECK_FALLBACK incoming checker gave no verified report \(exited 139/);
});
test('an incoming checker with empty output falls back to the installed one, which holds', async () => {
const fx = makeFixture();
const script = installedUpdater(fx, FALSE_POSITIVE);
release(fx, '1.1.0', () => incomingChecker(fx, 'exit 0'));
const log = await expectHeld(fx, script, /HELD hook-does-not-parse: /);
expect(log).toContain('HOOK_CHECK_FALLBACK incoming checker gave no verified report (empty hook list)');
});
// Git Bash: the checker may report the archive directory as /c/…/x or
// C:/…/x while mktemp named it /tmp/x. A uname and cygpath shim stands in
// for MSYS; the held reason never carries the archive path.
test('the held reason drops the archive prefix in its native spelling', async () => {
const fx = makeFixture();
const shim = path.join(fx.base, 'msys-shim');
fs.mkdirSync(shim);
fs.writeFileSync(path.join(shim, 'uname'), '#!/usr/bin/env bash\necho MINGW64_NT-10.0\n', { mode: 0o755 });
fs.writeFileSync(path.join(shim, 'cygpath'), '#!/usr/bin/env bash\n[ "$1" = -am ] && { printf \'C:/msys64%s\\n\' "$2"; exit 0; }\nexit 1\n', { mode: 0o755 });
const script = installedUpdater(fx, PASSES);
release(fx, '1.1.0', () => incomingChecker(fx, `if [ "$1" = --list ]; then echo ${HOOK}; exit 0; fi\necho "fail ${HOOK} $(cygpath -am "$1")/${HOOK}.ts:3: Unexpected ;"; exit 1`));
const oldHead = git(fx.install, 'rev-parse', 'HEAD');
spawnSync('bash', [script], {
encoding: 'utf8',
env: { PATH: `${shim}:${path.dirname(process.execPath)}:${process.env.PATH}`, HOME: fx.home, GSTACK_DIR: fx.install, GSTACK_STATE_ROOT: fx.state, SETUP_CALLS: fx.calls, TMPDIR: fx.base },
timeout: 20_000,
});
const log = await waitFor(fx, /Z HELD /);
expect(log).toContain(`HELD hook-does-not-parse: ${HOOK}.ts:3: Unexpected ;`);
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(oldHead);
});
test('no verified report from either checker holds as hook-check-unavailable, with the recovery', async () => {
const fx = makeFixture();
const script = installedUpdater(fx, CRASHES);
release(fx, '1.1.0', () => incomingChecker(fx, `if [ "$1" = --list ]; then echo ${HOOK}; exit 0; fi\necho "all good"; exit 0`));
await expectHeld(fx, script, /HELD hook-check-unavailable: the installed bin\/gstack-hook-check gave no verified report either \(exited 139/);
const next = runHook(fx, script).stdout;
expect(next).toContain('gstack auto-update: update held (hook-check-unavailable: ');
expect(next).toContain(`Fix now: cd ${fx.install} && git pull --ff-only --autostash && ./setup`);
expect(next).toContain('https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-check-unavailable');
});
});