Files
gstack/test/gstack-ff-update.test.ts
Garry Tanandcapy-ai[bot] f4536ed4d5 v1.91.222.0 test: daily flaky-fix pass: qa-only late clock, fragment-match verdict, install-admission timeout, ff-update reap, judge parse diagnostics (GSTA-91) (#3413)
* test: flaky-fix pass — fragment-match reads the agent's report, ff-update reap counts its own sleepers, install-admission gets a measured timeout, judge parse errors name the judge (GSTA-91)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(qa-only): late scenarios freeze the fixture clock during setup and register their real outer budget (GSTA-91)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(harness): record the llm-judge parse-message change as non-behavioral (GSTA-91)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-25 23:39:42 +00:00

444 lines
26 KiB
TypeScript

/**
* bin/gstack-ff-update moves a gstack install's tree: the session-start
* updater, /gstack-upgrade Step 4 and the held-install recovery commands call
* it. Edits that ride over the update stay in place; an edit that conflicts,
* a staged change or an unmerged file is shelved in refs/gstack/rescue/<oid>
* (first parent the pre-update HEAD, second the staged state) and the install
* lands clean on the target. Nothing is ever left as conflict markers, and a
* refusal changes nothing.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { execFileSync, spawn, spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
const ROOT = path.resolve(import.meta.dir, '..');
const FF = path.join(ROOT, 'bin', 'gstack-ff-update');
const UPDATER = path.join(ROOT, 'bin', 'gstack-session-update');
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 gitStatus(cwd: string, ...args: string[]) {
return spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 });
}
const FIXED_DATES = { GIT_AUTHOR_DATE: '2026-10-15T12:00:00Z', GIT_COMMITTER_DATE: '2026-10-15T12:00:00Z' };
/** A bare origin, a seed that publishes releases, and an install cloned from it. */
function makeFixture(dirName = 'install') {
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ff-')));
bases.push(base);
const origin = path.join(base, 'origin.git');
const seed = path.join(base, 'seed');
const install = path.join(base, dirName);
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 });
fs.mkdirSync(path.join(seed, 'qa', 'sections'), { recursive: true });
fs.writeFileSync(path.join(seed, 'VERSION'), '1.0.0\n');
fs.writeFileSync(path.join(seed, 'notes.txt'), 'top\nmiddle\nbottom\n');
fs.writeFileSync(path.join(seed, 'other.txt'), 'other\n');
fs.writeFileSync(path.join(seed, 'fix.txt'), 'bug\n');
fs.writeFileSync(path.join(seed, 'SKILL.md'), 'name: gstack\nbody\n');
fs.writeFileSync(path.join(seed, 'qa', 'SKILL.md'), 'name: qa\nbody\n');
fs.writeFileSync(path.join(seed, 'qa', 'sections', 'one.md'), 'one\n');
git(seed, 'init', '-q', '-b', 'main');
git(seed, 'add', '-A');
git(seed, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'seed');
git(seed, 'remote', 'add', 'origin', origin);
git(seed, 'push', '-q', 'origin', 'main');
execFileSync('git', ['clone', '-q', origin, install], { timeout: 30_000 });
return { base, origin, seed, install };
}
type Fx = ReturnType<typeof makeFixture>;
function release(fx: Fx, files: Record<string, string>, version = '1.1.0'): string {
for (const [f, body] of Object.entries(files)) fs.writeFileSync(path.join(fx.seed, f), body);
fs.writeFileSync(path.join(fx.seed, 'VERSION'), `${version}\n`);
git(fx.seed, 'add', '-A');
git(fx.seed, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', `release ${version}`);
git(fx.seed, 'push', '-q', 'origin', 'main');
git(fx.install, 'fetch', '-q');
return git(fx.seed, 'rev-parse', 'HEAD');
}
/** An environment with no user identity, no global or system git config. */
function isolatedEnv(base: string, extra: Record<string, string> = {}) {
const home = path.join(base, 'home');
fs.mkdirSync(home, { recursive: true });
return { PATH: process.env.PATH!, HOME: home, XDG_CONFIG_HOME: path.join(home, '.config'), GIT_CONFIG_NOSYSTEM: '1', TMPDIR: base, ...extra };
}
function ff(fx: Fx, rev = 'origin/main', extra: Record<string, string> = {}, bin = FF, cwd = fx.base) {
return spawnSync('bash', [bin, fx.install, rev], { cwd, encoding: 'utf8', env: isolatedEnv(fx.base, extra), timeout: 60_000 });
}
const read = (fx: Fx, f: string) => fs.readFileSync(path.join(fx.install, f), 'utf8');
const write = (fx: Fx, f: string, body: string) => fs.writeFileSync(path.join(fx.install, f), body);
const rescueRef = (out: string) => out.match(/^rescued [0-9a-f]+ [0-9a-f]+ (refs\/gstack\/rescue\/[0-9a-f]{40}) unmerged=([01])$/m);
function makeUnmerged(fx: Fx, file: string) {
const blob = (body: string) => execFileSync('git', ['hash-object', '-w', '--stdin'], { cwd: fx.install, input: body, encoding: 'utf8', timeout: 30_000 }).trim();
const stages = [blob('top\nmiddle\nbottom\n'), blob('top OURS\nmiddle\nbottom\n'), blob('top THEIRS\nmiddle\nbottom\n')];
git(fx.install, 'update-index', '--force-remove', file);
execFileSync('git', ['update-index', '--index-info'], { cwd: fx.install, timeout: 30_000, input: stages.map((s, i) => `100644 ${s} ${i + 1}\t${file}\n`).join('') });
write(fx, file, '<<<<<<< ours\ntop OURS\n=======\ntop THEIRS\n>>>>>>> theirs\n');
}
describe.skipIf(process.platform === 'win32')('gstack-ff-update: moving the tree', () => {
test('a clean install fast-forwards; the real index matches the target', () => {
const fx = makeFixture();
const target = release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' });
const old = git(fx.install, 'rev-parse', 'HEAD');
const r = ff(fx);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stdout).toBe(`ok ${old} ${target}\n`);
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target);
expect(git(fx.install, 'status', '--porcelain')).toBe('');
expect(ff(fx).stdout).toBe(`already ${target}\n`);
});
test('edits that merge cleanly ride over the update unstaged, and nothing is shelved', () => {
const fx = makeFixture();
write(fx, 'notes.txt', 'top\nmiddle\nbottom LOCAL\n');
write(fx, 'other.txt', 'other LOCAL\n');
release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' });
const r = ff(fx);
expect(r.stdout).toMatch(/^ok /);
expect(read(fx, 'notes.txt')).toBe('top UPSTREAM\nmiddle\nbottom LOCAL\n');
expect(read(fx, 'other.txt')).toBe('other LOCAL\n');
expect(git(fx.install, 'diff', '--cached', '--name-only')).toBe('');
expect(git(fx.install, 'diff', '--name-only').split('\n').sort()).toEqual(['notes.txt', 'other.txt']);
expect(git(fx.install, 'for-each-ref', 'refs/gstack/rescue')).toBe('');
});
test('a conflicting edit is shelved: no markers, the patch holds only the person\'s delta, and an upstream fix survives restoring it', () => {
const fx = makeFixture();
write(fx, 'notes.txt', 'top LOCAL\nmiddle\nbottom\n');
const target = release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n', 'fix.txt': 'fixed\n' });
const r = ff(fx);
expect(r.status).toBe(0);
const m = rescueRef(r.stdout);
expect(m, r.stdout).not.toBeNull();
expect(m![2]).toBe('0');
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target);
expect(git(fx.install, 'status', '--porcelain')).toBe('');
expect(read(fx, 'notes.txt')).toBe('top UPSTREAM\nmiddle\nbottom\n');
const ref = m![1];
expect(git(fx.install, 'diff', '--name-only', `${ref}^`, ref)).toBe('notes.txt');
// Restore the person's edit in a scratch checkout of the target, as the banner says.
const patch = execFileSync('git', ['diff', `${ref}^`, ref], { cwd: fx.install, encoding: 'utf8', timeout: 30_000 });
const scratch = path.join(fx.base, 'scratch');
execFileSync('git', ['clone', '-q', fx.origin, scratch], { timeout: 30_000 });
const applied = spawnSync('git', ['apply', '-3'], { cwd: scratch, input: patch, encoding: 'utf8', timeout: 30_000 });
expect(fs.readFileSync(path.join(scratch, 'fix.txt'), 'utf8')).toBe('fixed\n');
expect(applied.status === 0 || fs.readFileSync(path.join(scratch, 'notes.txt'), 'utf8').includes('top LOCAL')).toBe(true);
expect(fs.readFileSync(path.join(scratch, 'notes.txt'), 'utf8')).toContain('top LOCAL');
// stderr gives the inspect, export and delete commands.
expect(r.stderr).toContain(`show --stat '${ref}'`);
expect(r.stderr).toContain(`diff '${ref}^' '${ref}' > ~/gstack-local-edits.patch`);
expect(r.stderr).toContain(`update-ref -d '${ref}'`);
});
test('staged-only changes and differing staged and unstaged versions of one file are both kept', () => {
const fx = makeFixture();
write(fx, 'other.txt', 'other STAGED\n');
git(fx.install, 'add', 'other.txt');
write(fx, 'notes.txt', 'top STAGED\nmiddle\nbottom\n');
git(fx.install, 'add', 'notes.txt');
write(fx, 'notes.txt', 'top WORKTREE\nmiddle\nbottom\n');
const indexBefore = git(fx.install, 'ls-files', '-s');
release(fx, { 'fix.txt': 'fixed\n' });
const r = ff(fx);
const m = rescueRef(r.stdout);
expect(m, r.stdout + r.stderr).not.toBeNull();
const ref = m![1];
expect(git(fx.install, 'show', `${ref}:notes.txt`)).toBe('top WORKTREE\nmiddle\nbottom');
expect(git(fx.install, 'show', `${ref}^2:notes.txt`)).toBe('top STAGED\nmiddle\nbottom');
expect(git(fx.install, 'show', `${ref}^2:other.txt`)).toBe('other STAGED');
expect(git(fx.install, 'show', `${ref}:other.txt`)).toBe('other STAGED');
expect(git(fx.install, 'rev-parse', `${ref}^2^`)).toBe(git(fx.install, 'rev-parse', `${ref}^`));
expect(git(fx.install, 'status', '--porcelain')).toBe('');
expect(indexBefore).not.toBe(git(fx.install, 'ls-files', '-s'));
});
test('an already-unmerged install is rescued, labelled as needing manual resolution, and lands clean', () => {
const fx = makeFixture();
makeUnmerged(fx, 'notes.txt');
const target = release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' });
const r = ff(fx);
const m = rescueRef(r.stdout);
expect(m, r.stdout + r.stderr).not.toBeNull();
expect(m![2]).toBe('1');
expect(r.stderr).toContain('needs manual resolution');
expect(git(fx.install, 'log', '-1', '--format=%B', m![1])).toContain('Needs manual resolution');
expect(git(fx.install, 'show', `${m![1]}:notes.txt`)).toContain('<<<<<<< ours');
expect(git(fx.install, 'ls-files', '-u')).toBe('');
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target);
expect(read(fx, 'notes.txt')).toBe('top UPSTREAM\nmiddle\nbottom\n');
});
test('the regenerable footprint rides over when it merges and takes the target otherwise; it is never rescued', () => {
const fx = makeFixture();
write(fx, 'SKILL.md', 'name: gstack-prefixed\nbody\n');
write(fx, 'qa/SKILL.md', 'name: gstack-qa\nbody\n');
release(fx, { 'qa/SKILL.md': 'name: qa-renamed\nbody\n' });
const r = ff(fx);
expect(r.stdout).toMatch(/^ok /);
expect(read(fx, 'SKILL.md')).toBe('name: gstack-prefixed\nbody\n');
expect(read(fx, 'qa/SKILL.md')).toBe('name: qa-renamed\nbody\n');
expect(git(fx.install, 'for-each-ref', 'refs/gstack/rescue')).toBe('');
});
test('an untracked file in the way refuses with autostash-unrecoverable and changes nothing', () => {
const fx = makeFixture();
write(fx, 'notes.txt', 'top LOCAL\nmiddle\nbottom\n');
release(fx, { 'new.txt': 'upstream new\n' });
write(fx, 'new.txt', 'mine\n');
const head = git(fx.install, 'rev-parse', 'HEAD');
const r = ff(fx);
expect(r.status).toBe(3);
expect(r.stdout).toMatch(/^autostash-unrecoverable: git refused to update /);
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(head);
expect(read(fx, 'new.txt')).toBe('mine\n');
expect(read(fx, 'notes.txt')).toBe('top LOCAL\nmiddle\nbottom\n');
expect(git(fx.install, 'for-each-ref', 'refs/gstack/rescue')).toBe('');
expect(fs.existsSync(path.join(fx.install, '.git', 'index.lock'))).toBe(false);
});
test('an edit written between the capture and the move is refused and kept (barrier)', () => {
const fx = makeFixture();
write(fx, 'notes.txt', 'top LOCAL\nmiddle\nbottom\n');
release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' });
const head = git(fx.install, 'rev-parse', 'HEAD');
// The first commit-tree (the rescue's staged-state commit) runs after the
// capture and before the move: the shim writes a late edit right then.
const shimDir = path.join(fx.base, 'barrier');
fs.mkdirSync(shimDir);
const real = path.join(execFileSync('git', ['--exec-path'], { encoding: 'utf8', timeout: 30_000 }).trim(), 'git');
fs.writeFileSync(path.join(shimDir, 'git'), `#!/bin/bash\ncase " $* " in *" commit-tree "*) [ -e "${fx.base}/barrier.done" ] || { echo LATE >> "${fx.install}/other.txt"; : > "${fx.base}/barrier.done"; } ;; esac\nexec "${real}" "$@"\n`, { mode: 0o755 });
const r = ff(fx, 'origin/main', { PATH: `${shimDir}:${process.env.PATH}` });
expect(r.status, r.stdout).toBe(3);
expect(r.stdout).toContain('changed while the update ran');
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(head);
expect(read(fx, 'other.txt')).toBe('other\nLATE\n');
expect(read(fx, 'notes.txt')).toBe('top LOCAL\nmiddle\nbottom\n');
expect(git(fx.install, 'for-each-ref', 'refs/gstack/rescue')).toBe('');
});
test('rescue refs are compare-and-create: same-second recoveries, an identical rescue, and a pre-existing ref', () => {
const fx = makeFixture();
write(fx, 'notes.txt', 'top LOCAL\nmiddle\nbottom\n');
release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' });
const head = git(fx.install, 'rev-parse', 'HEAD');
const copies = ['a', 'b', 'c'].map((n) => { const d = path.join(fx.base, `copy-${n}`); fs.cpSync(fx.install, d, { recursive: true }); return d; });
// Same second, same content: the same oid, and a second run reuses the ref.
const first = ff({ ...fx, install: copies[0] }, 'origin/main', FIXED_DATES);
const ref = rescueRef(first.stdout)![1];
fs.writeFileSync(path.join(copies[1], 'notes.txt'), 'top OTHER\nmiddle\nbottom\n');
const second = ff({ ...fx, install: copies[1] }, 'origin/main', FIXED_DATES);
expect(rescueRef(second.stdout)![1]).not.toBe(ref);
// A pre-existing ref with this rescue's name and another commit: refused.
git(copies[2], 'update-ref', ref, head);
const third = ff({ ...fx, install: copies[2] }, 'origin/main', FIXED_DATES);
expect(third.status).toBe(3);
expect(third.stdout).toContain(`rescue ref ${ref} already exists and names another commit`);
expect(git(copies[2], 'rev-parse', ref)).toBe(head);
expect(git(copies[2], 'rev-parse', 'HEAD')).toBe(head);
// The same ref already naming this very rescue is accepted.
git(fx.install, 'fetch', '-q', copies[0], `${ref}:${ref}`);
const again = ff(fx, 'origin/main', FIXED_DATES);
expect(again.status, again.stdout).toBe(0);
expect(rescueRef(again.stdout)![1]).toBe(ref);
});
test('rescue commits carry gstack\'s own identity with no user config at all', () => {
const fx = makeFixture();
write(fx, 'notes.txt', 'top LOCAL\nmiddle\nbottom\n');
release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' });
// The real git binary first on PATH: a host wrapper that injects its own
// identity must not stand in for what the bin asks for.
const realDir = path.join(fx.base, 'real-git');
fs.mkdirSync(realDir);
fs.symlinkSync(path.join(execFileSync('git', ['--exec-path'], { encoding: 'utf8', timeout: 30_000 }).trim(), 'git'), path.join(realDir, 'git'));
const r = ff(fx, 'origin/main', { PATH: `${realDir}:${process.env.PATH}` });
const ref = rescueRef(r.stdout)![1];
expect(git(fx.install, 'log', '-1', '--format=%an <%ae>|%cn <%ce>', ref)).toBe('gstack rescue <gstack@localhost>|gstack rescue <gstack@localhost>');
});
test('refusals: not a fast-forward, an unresolvable target, and a missing checkout', () => {
const fx = makeFixture();
write(fx, 'VERSION', 'local\n');
git(fx.install, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qam', 'local');
release(fx, { 'notes.txt': 'x\n' });
const head = git(fx.install, 'rev-parse', 'HEAD');
const nf = ff(fx);
expect(nf.status).toBe(4);
expect(nf.stdout).toMatch(/^not-fast-forward: /);
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(head);
expect(ff(fx, 'no-such-rev').status).toBe(2);
expect(spawnSync('bash', [FF, path.join(fx.base, 'missing'), 'HEAD'], { encoding: 'utf8', timeout: 30_000 }).stdout).toMatch(/^not-a-checkout: /);
});
});
describe.skipIf(process.platform === 'win32')('gstack-ff-update: the install-scoped lock', () => {
test('lives in the absolute git common dir, shared by a linked worktree, never in the caller\'s project', () => {
const fx = makeFixture();
release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' });
const wt = path.join(fx.base, 'linked');
git(fx.install, 'worktree', 'add', '-q', '--detach', wt, 'HEAD');
const project = path.join(fx.base, 'project');
fs.mkdirSync(project);
// A live holder in the common dir blocks an update started from the linked worktree.
const holder = spawn('sleep', ['30']);
try {
const lock = path.join(fx.install, '.git', 'gstack-ff.lock');
fs.mkdirSync(lock);
fs.writeFileSync(path.join(lock, 'pid'), `${holder.pid}\n`);
const r = spawnSync('bash', [FF, wt, 'origin/main'], { cwd: project, encoding: 'utf8', env: isolatedEnv(fx.base, { GSTACK_FF_LOCK_WAIT: '0' }), timeout: 30_000 });
expect(r.status).toBe(75);
expect(r.stdout).toContain(`ff-busy: another gstack update is moving ${wt} (lock ${lock} held by pid ${holder.pid})`);
expect(fs.readdirSync(project)).toEqual([]);
expect(fs.existsSync(path.join(wt, 'gstack-ff.lock'))).toBe(false);
// A dead holder is reclaimed.
fs.writeFileSync(path.join(lock, 'pid'), '999999\n');
const ok = spawnSync('bash', [FF, wt, 'origin/main'], { cwd: project, encoding: 'utf8', env: isolatedEnv(fx.base, { GSTACK_FF_LOCK_WAIT: '0' }), timeout: 30_000 });
expect(ok.status, ok.stdout).toBe(0);
expect(fs.existsSync(lock)).toBe(false);
expect(fs.readdirSync(project)).toEqual([]);
} finally {
holder.kill();
}
});
test('a live holder that outlasts the TTL keeps its lock through the heartbeat', async () => {
const fx = makeFixture();
write(fx, 'notes.txt', 'top LOCAL\nmiddle\nbottom\n');
release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' });
const shimDir = path.join(fx.base, 'slow');
fs.mkdirSync(shimDir);
const real = path.join(execFileSync('git', ['--exec-path'], { encoding: 'utf8', timeout: 30_000 }).trim(), 'git');
fs.writeFileSync(path.join(shimDir, 'git'), `#!/bin/bash\ncase " $* " in *" commit-tree "*) : > "${fx.base}/slow.started"; sleep 4 ;; esac\nexec "${real}" "$@"\n`, { mode: 0o755 });
const lock = path.join(fx.install, '.git', 'gstack-ff.lock');
const holder = spawn('bash', [FF, fx.install, 'origin/main'], { env: isolatedEnv(fx.base, { PATH: `${shimDir}:${process.env.PATH}`, GSTACK_FF_HEARTBEAT_SECONDS: '1' }) });
const done = new Promise<number | null>((resolve) => holder.on('exit', resolve));
const deadline = Date.now() + 20_000;
while (!fs.existsSync(path.join(fx.base, 'slow.started')) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 50));
// The pidfile looks 40 minutes old; the holder's next beat freshens it.
const past = new Date(Date.now() - 40 * 60 * 1000);
fs.utimesSync(path.join(lock, 'pid'), past, past);
await new Promise((r) => setTimeout(r, 1600));
const contender = spawnSync('bash', [FF, fx.install, 'origin/main'], { encoding: 'utf8', env: isolatedEnv(fx.base, { GSTACK_FF_LOCK_WAIT: '0' }), timeout: 30_000 });
expect(contender.status, contender.stdout).toBe(75);
expect(await done).toBe(0);
expect(fs.existsSync(lock)).toBe(false);
expect(git(fx.install, 'ls-files', '-u')).toBe('');
}, 30_000);
test('the heartbeat and its sleeper are reaped when the bin exits', () => {
const fx = makeFixture();
release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' });
const beat = String(3000 + (process.pid % 997));
const sleepers = (delay = '') => execFileSync('bash', ['-c', `${delay}pgrep -f "^sleep ${beat}$" | wc -l`], { encoding: 'utf8', timeout: 30_000 }).trim();
const before = sleepers();
expect(before).toBe('0');
expect(ff(fx, 'origin/main', { GSTACK_FF_HEARTBEAT_SECONDS: beat }).status).toBe(0);
expect(sleepers('sleep 0.3; ')).toBe(before);
});
});
describe.skipIf(process.platform === 'win32')('gstack-ff-update: callers', () => {
test('/gstack-upgrade Step 4 racing a background updater (two state roots) leaves one clean result and no lost edit', async () => {
const fx = makeFixture();
fs.mkdirSync(path.join(fx.seed, 'bin'), { recursive: true });
fs.writeFileSync(path.join(fx.seed, 'bin', 'gstack-config'), '#!/usr/bin/env bash\n[ "$1" = get ] && [ "$2" = auto_upgrade ] && echo true\nexit 0\n', { mode: 0o755 });
fs.writeFileSync(path.join(fx.seed, 'bin', 'gstack-patch-names'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
fs.writeFileSync(path.join(fx.seed, 'setup'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
const target = release(fx, { 'notes.txt': 'top\nmiddle\nbottom\n' }, '1.0.1');
git(fx.install, 'merge', '-q', '--ff-only', 'origin/main');
write(fx, 'notes.txt', 'top LOCAL\nmiddle\nbottom\n');
const incoming = release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' });
expect(incoming).not.toBe(target);
const stateA = path.join(fx.base, 'state-a');
const stateB = path.join(fx.base, 'state-b');
fs.mkdirSync(stateA); fs.mkdirSync(stateB);
const env = (state: string) => ({ ...isolatedEnv(fx.base), PATH: `${path.dirname(process.execPath)}:${process.env.PATH}`, GSTACK_DIR: fx.install, GSTACK_STATE_ROOT: state });
const bg = spawnSync('bash', [UPDATER], { encoding: 'utf8', env: env(stateA), timeout: 30_000 });
expect(bg.status).toBe(0);
const step4 = spawnSync('bash', [FF, fx.install, 'origin/main'], { encoding: 'utf8', env: env(stateB), timeout: 60_000 });
const log = path.join(stateA, 'analytics', 'session-update.log');
const deadline = Date.now() + 30_000;
while (Date.now() < deadline && !/UPDATED|UP_TO_DATE|SKIP ff_busy|PULL_FAILED|HELD/.test(fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : '')) await new Promise((r) => setTimeout(r, 100));
while (fs.existsSync(path.join(stateA, '.setup-lock')) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 100));
expect([0, 75]).toContain(step4.status ?? -1);
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(incoming);
expect(git(fx.install, 'ls-files', '-u')).toBe('');
expect(read(fx, 'notes.txt')).toBe('top UPSTREAM\nmiddle\nbottom\n');
const refs = git(fx.install, 'for-each-ref', '--format=%(refname)', 'refs/gstack/rescue').split('\n').filter(Boolean);
expect(refs).toHaveLength(1);
expect(git(fx.install, 'show', `${refs[0]}:notes.txt`)).toBe('top LOCAL\nmiddle\nbottom');
}, 60_000);
test('/gstack-upgrade Step 4\'s git block leaves no conflict markers: FF_OK with the edit in a rescue ref', () => {
const fx = makeFixture();
for (const f of ['setup', 'bin/gstack-config']) {
fs.mkdirSync(path.join(fx.seed, path.dirname(f)), { recursive: true });
fs.writeFileSync(path.join(fx.seed, f), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
}
fs.copyFileSync(FF, path.join(fx.seed, 'bin', 'gstack-ff-update'));
const base = release(fx, {}, '1.0.1');
git(fx.install, 'merge', '-q', '--ff-only', base);
write(fx, 'notes.txt', 'top LOCAL\nmiddle\nbottom\n');
const target = release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' });
const tmpl = fs.readFileSync(path.join(ROOT, 'gstack-upgrade', 'SKILL.md.tmpl'), 'utf8');
const block = tmpl.slice(tmpl.indexOf('**For git installs**')).match(/```bash\n([\s\S]*?)\n```/)![1]!.replaceAll('{{SETUP_COMMAND}}', './setup');
const r = spawnSync('bash', ['-c', block], { cwd: fx.base, encoding: 'utf8', env: { ...isolatedEnv(fx.base), INSTALL_DIR: fx.install }, timeout: 60_000 });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stdout).toContain('FF_OK');
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target);
expect(git(fx.install, 'ls-files', '-u')).toBe('');
expect(read(fx, 'notes.txt')).toBe('top UPSTREAM\nmiddle\nbottom\n');
const ref = rescueRef(r.stdout)![1];
expect(git(fx.install, 'show', `${ref}:notes.txt`)).toBe('top LOCAL\nmiddle\nbottom');
});
test('the bin copied outside any checkout recovers an install that has no gstack-ff-update', () => {
const fx = makeFixture();
write(fx, 'notes.txt', 'top LOCAL\nmiddle\nbottom\n');
const target = release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' });
const outside = path.join(fx.base, 'outside');
fs.mkdirSync(outside);
fs.copyFileSync(FF, path.join(outside, 'gstack-ff-update'));
const r = spawnSync('bash', [path.join(outside, 'gstack-ff-update'), '.', 'origin/main'], { cwd: fx.install, encoding: 'utf8', env: isolatedEnv(fx.base), timeout: 60_000 });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(rescueRef(r.stdout)).not.toBeNull();
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target);
});
});
describe('gstack-ff-update: self-contained, with twins pinned', () => {
const ffSrc = fs.readFileSync(FF, 'utf8');
const updSrc = fs.readFileSync(UPDATER, 'utf8');
const fn = (src: string, name: string) => src.match(new RegExp(`\\n( *)${name}\\(\\) \\{\\n[\\s\\S]*?\\n\\1\\}\\n`))?.[0].replace(/^\n/, '').replace(/^ +/gm, '').replace(/^#.*\n/gm, '');
test('sources nothing', () => {
expect(ffSrc).not.toMatch(/^\s*(\.|source)\s/m);
});
test('ff_flatten is byte-identical to the updater\'s', () => {
expect(fn(ffSrc, 'ff_flatten')).toBeDefined();
expect(fn(ffSrc, 'ff_flatten')).toBe(fn(updSrc, 'ff_flatten'));
});
test('the lock keeps the updater\'s TTL check, atomic mv-aside reap and heartbeat reaping', () => {
expect(fn(ffSrc, 'lock_is_expired')).toBe(fn(updSrc, 'lock_is_expired'));
expect(fn(ffSrc, 'cleanup_heartbeat')).toBe(fn(updSrc, 'cleanup_heartbeat'));
expect(ffSrc).toContain('mv "$LOCK_DIR" "$LOCK_DIR.reap.$$" 2>/dev/null || return 1');
expect(updSrc).toContain('mv "$LOCK_DIR" "$LOCK_DIR.reap.$$" 2>/dev/null ||');
expect(ffSrc).toContain('[ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR" 2>/dev/null');
});
test.skipIf(process.platform === 'win32')('ff_flatten flattens newlines and strips control characters and escape sequences', () => {
const r = spawnSync('bash', ['-c', `eval "$(sed -n '/^ff_flatten() {/,/^}/p' "${FF}")"; ff_flatten "$(printf 'bad\\nstages=x \\033[31mred\\033[0m\\001end')" 200`], { encoding: 'utf8', timeout: 30_000 });
expect(r.stdout).toBe('bad stages=x redend');
});
});