Files
gstack/test/gbrain-source-worktree-advance.test.ts
T
Garry TanandClaude Fable 5 40e4a53f74 fix(gbrain): brain worktree advances on the daily sync — no more silently stale brains (#2516)
The daily pull refreshed only ~/.gstack itself, never the detached worktree
at ~/.gstack-brain-worktree that gbrain actually indexes — so after setup the
brain served stale pages forever unless setup-gbrain/sync-gbrain happened to
run. brain-sync --once now advances the worktree once per 24h behind an
ATTEMPT stamp (.brain-worktree-last-advance — a persistently-failing advance
warns once a day, not at every skill boundary), inside the existing run lock
and before any ingest step touches the worktree.

The new gstack-gbrain-source-wireup --advance-only is built for the
unattended cadence: git-only (no gbrain prereqs), pins every operation to the
managed worktree (refuses paths that are not worktrees of the artifacts
repo), refuses dirty worktrees, and never runs the force-remove recovery — a
cron path must not be able to delete local changes. A static pin keeps the
force-remove out. docs/gbrain-sync.md stops overclaiming the old cadence.

Fixes #2516.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:32:19 -07:00

158 lines
6.5 KiB
TypeScript

/**
* #2516: the brain worktree gbrain indexes must advance on the daily sync —
* and the unattended advance path must be SAFE: refuse dirty worktrees,
* refuse anything that is not a worktree of the artifacts repo, and never
* force-remove. (Pre-fix, the worktree only moved when setup-gbrain /
* sync-gbrain / brain-restore ran, so brains silently served stale pages.)
*/
import { describe, test as _test, expect, beforeEach, afterEach } from 'bun:test';
const test = (name: string, fn: any) => _test(name, fn, 30000);
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawnSync } from 'child_process';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin');
let tmpHome: string;
function run(argv: string[], env: Record<string, string> = {}) {
const full = path.join(BIN, argv[0]);
const res = spawnSync(full, argv.slice(1), {
env: { ...process.env, HOME: tmpHome, GSTACK_HOME: tmpHome, ...env },
encoding: 'utf-8',
cwd: ROOT,
});
return { stdout: res.stdout || '', stderr: res.stderr || '', status: res.status ?? -1 };
}
function git(args: string[], cwd: string) {
const res = spawnSync('git', args, { cwd, encoding: 'utf-8' });
return { stdout: (res.stdout || '').trim(), status: res.status ?? -1 };
}
function commit(cwd: string, msg: string): string {
fs.appendFileSync(path.join(cwd, 'artifact.md'), `${msg}\n`);
git(['add', 'artifact.md'], cwd);
git(['commit', '-q', '-m', msg], cwd);
return git(['rev-parse', 'HEAD'], cwd).stdout;
}
const worktreePath = () => path.join(tmpHome, '.gstack-brain-worktree');
function makeArtifactsRepoWithWorktree(): { head: string } {
git(['init', '-q', '-b', 'main'], tmpHome);
git(['config', 'user.email', 't@t'], tmpHome);
git(['config', 'user.name', 't'], tmpHome);
const head = commit(tmpHome, 'seed');
git(['worktree', 'add', '--detach', worktreePath(), head], tmpHome);
return { head };
}
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'wtree-adv-home-'));
});
afterEach(() => {
fs.rmSync(tmpHome, { recursive: true, force: true });
});
describe('gstack-gbrain-source-wireup --advance-only (#2516)', () => {
test('advances a clean, behind worktree to the parent HEAD', () => {
makeArtifactsRepoWithWorktree();
const newHead = commit(tmpHome, 'second');
const r = run(['gstack-gbrain-source-wireup', '--advance-only']);
expect(r.status).toBe(0);
expect(r.stdout + r.stderr).toContain('advanced');
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(newHead);
});
test('up-to-date worktree is a no-op', () => {
const { head } = makeArtifactsRepoWithWorktree();
const r = run(['gstack-gbrain-source-wireup', '--advance-only']);
expect(r.status).toBe(0);
expect(r.stdout + r.stderr).toContain('up-to-date');
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(head);
});
test('REFUSES a dirty worktree — local changes are never advanced away', () => {
const { head } = makeArtifactsRepoWithWorktree();
commit(tmpHome, 'second');
fs.writeFileSync(path.join(worktreePath(), 'artifact.md'), 'local edit\n');
const r = run(['gstack-gbrain-source-wireup', '--advance-only']);
expect(r.status).toBe(0); // benign skip, not a hard failure
expect(r.stderr).toContain('local changes');
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(head); // untouched
expect(fs.readFileSync(path.join(worktreePath(), 'artifact.md'), 'utf-8')).toBe('local edit\n');
});
test('REFUSES a path that is not a worktree of the artifacts repo', () => {
makeArtifactsRepoWithWorktree();
// A standalone user repo masquerading as the brain worktree.
const userRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'wtree-adv-user-'));
try {
git(['init', '-q', '-b', 'main'], userRepo);
git(['config', 'user.email', 't@t'], userRepo);
git(['config', 'user.name', 't'], userRepo);
const userHead = commit(userRepo, 'user work');
const r = run(['gstack-gbrain-source-wireup', '--advance-only'], {
GSTACK_BRAIN_WORKTREE: userRepo,
});
expect(r.status).toBe(0);
expect(r.stderr).toContain('not a worktree of');
expect(git(['rev-parse', 'HEAD'], userRepo).stdout).toBe(userHead); // untouched
} finally {
fs.rmSync(userRepo, { recursive: true, force: true });
}
});
test('missing worktree is a benign skip', () => {
git(['init', '-q', '-b', 'main'], tmpHome);
git(['config', 'user.email', 't@t'], tmpHome);
git(['config', 'user.name', 't'], tmpHome);
commit(tmpHome, 'seed');
const r = run(['gstack-gbrain-source-wireup', '--advance-only']);
expect(r.status).toBe(0);
expect(r.stderr).toContain('no managed worktree');
});
test('never contains a force-remove on the advance-only path (static pin)', () => {
// The unattended path must not be able to delete local worktree changes:
// do_advance_only may not call safe_rm_worktree, `worktree remove`, or rm -rf.
const src = fs.readFileSync(path.join(BIN, 'gstack-gbrain-source-wireup'), 'utf-8');
const fn = src.slice(src.indexOf('do_advance_only()'), src.indexOf('do_uninstall()'));
expect(fn.length).toBeGreaterThan(100);
expect(fn).not.toContain('safe_rm_worktree');
expect(fn).not.toContain('worktree remove');
expect(fn).not.toContain('rm -rf');
});
});
describe('brain-sync --once daily advance wiring (#2516)', () => {
test('once advances the worktree behind a 24h attempt stamp', () => {
makeArtifactsRepoWithWorktree();
run(['gstack-config', 'set', 'artifacts_sync_mode', 'artifacts-only']);
const second = commit(tmpHome, 'second');
const r1 = run(['gstack-brain-sync', '--once']);
expect(r1.status).toBe(0);
const stamp = path.join(tmpHome, '.brain-worktree-last-advance');
expect(fs.existsSync(stamp)).toBe(true);
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(second);
// Within the 24h window: parent advances again, --once does NOT re-advance.
const third = commit(tmpHome, 'third');
const r2 = run(['gstack-brain-sync', '--once']);
expect(r2.status).toBe(0);
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(second);
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).not.toBe(third);
// Expire the stamp → the next --once advances again.
fs.writeFileSync(stamp, String(Math.floor(Date.now() / 1000) - 90000));
const r3 = run(['gstack-brain-sync', '--once']);
expect(r3.status).toBe(0);
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(third);
});
});