Files
gstack/test/brain-sync-removals.test.ts
T
Garry Tanandcapy-ai[bot] 08301328e9 v1.91.214.0 fix(brain-sync): --publish-removals cleans up its temp list and lock without 'list: unbound variable' (GSTA-87) (#3405)
* fix(brain-sync): --publish-removals trap expands its temp paths at install, so cleanup and lock release run after the function returns (GSTA-87)

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

* v1.91.214.0 stamp

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 16:09:15 +00:00

172 lines
8.4 KiB
TypeScript

/**
* gstack-brain-sync removals (artifacts_sync_removals): off by default, a
* synced file gone from disk stays published; on, the drain publishes its
* removal, and a drain that would remove more than 20 files at once publishes
* none of them until `--publish-removals --yes`. Temp GSTACK_HOME, local bare
* remote; no network.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawnSync } from 'child_process';
const ROOT = path.resolve(import.meta.dir, '..');
let home: string;
let remote: string;
function run(argv: string[], extraEnv: Record<string, string> = {}) {
const r = spawnSync(path.join(ROOT, 'bin', argv[0]), argv.slice(1), {
env: { ...process.env, HOME: home, GSTACK_HOME: home, ...extraEnv },
encoding: 'utf-8',
timeout: 60_000,
});
return { stdout: r.stdout || '', stderr: r.stderr || '', status: r.status ?? -1 };
}
const remoteFiles = () =>
spawnSync('git', ['--git-dir=' + remote, 'ls-tree', '-r', '--name-only', 'main'], { encoding: 'utf-8', timeout: 30_000 })
.stdout.split('\n').filter((p) => p.startsWith('projects/') || p.startsWith('transcripts/'));
const status = () => JSON.parse(fs.readFileSync(path.join(home, '.brain-sync-status.json'), 'utf-8'));
function write(p: string, body = '# doc\n') {
fs.mkdirSync(path.dirname(path.join(home, p)), { recursive: true });
fs.writeFileSync(path.join(home, p), body);
}
function syncAll(files: string[]) {
for (const f of files) write(f);
for (const f of files) run(['gstack-brain-enqueue', f]);
const r = run(['gstack-brain-sync', '--once']);
expect(r.status).toBe(0);
expect(remoteFiles()).toEqual(expect.arrayContaining(files));
}
const designs = (dir: string, n: number) => Array.from({ length: n }, (_, i) => `projects/${dir}/designs/d${i}.md`);
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-removals-home-'));
remote = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-removals-remote-'));
spawnSync('git', ['init', '--bare', '-q', '-b', 'main', remote], { timeout: 30_000 });
expect(run(['gstack-artifacts-init', '--remote', remote]).status).toBe(0);
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(remote, { recursive: true, force: true });
});
describe('artifacts_sync_removals', () => {
test('off by default: a deleted synced file stays published', () => {
expect(run(['gstack-config', 'get', 'artifacts_sync_removals']).stdout.trim()).toBe('off');
syncAll(['projects/p/designs/a.md', 'projects/p/designs/b.md']);
fs.rmSync(path.join(home, 'projects/p/designs/a.md'));
run(['gstack-brain-sync', '--once']);
expect(remoteFiles()).toContain('projects/p/designs/a.md');
});
test('on: a slug migration removes the old copies with the queue empty or not', () => {
run(['gstack-config', 'set', 'artifacts_sync_removals', 'on']);
syncAll(['projects/acme/designs/x.md', 'projects/acme/designs/y.md']);
fs.renameSync(path.join(home, 'projects/acme'), path.join(home, 'projects/owner-acme'));
run(['gstack-brain-sync', '--discover-new']);
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
expect(remoteFiles().sort()).toEqual(['projects/owner-acme/designs/x.md', 'projects/owner-acme/designs/y.md']);
expect(status().message).toContain('published 2 removal(s)');
fs.rmSync(path.join(home, 'projects/owner-acme/designs/y.md'));
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
expect(remoteFiles()).toEqual(['projects/owner-acme/designs/x.md']);
expect(status().status).toBe('ok');
});
test('on: tracked files outside the allowlist and transcript pages are never removed', () => {
run(['gstack-config', 'set', 'artifacts_sync_removals', 'on']);
syncAll(['projects/p/designs/a.md']);
write('projects/p/notes.txt');
write('transcripts/run-1/s1.md');
spawnSync('git', ['-C', home, 'add', '-f', 'projects/p/notes.txt', 'transcripts/run-1/s1.md'], { timeout: 30_000 });
spawnSync('git', ['-C', home, '-c', 'user.email=u@example.com', '-c', 'user.name=u', 'commit', '-qm', 'manual'], { timeout: 30_000 });
spawnSync('git', ['-C', home, 'push', '-q', 'origin', 'HEAD'], { timeout: 30_000 });
for (const p of ['projects/p/notes.txt', 'transcripts/run-1/s1.md', 'projects/p/designs/a.md']) fs.rmSync(path.join(home, p));
run(['gstack-brain-sync', '--once']);
expect(remoteFiles().sort()).toEqual(['projects/p/notes.txt', 'transcripts/run-1/s1.md']);
});
test('on: a decision log goes only together with its active snapshot', () => {
run(['gstack-config', 'set', 'artifacts_sync_removals', 'on']);
const LOG = 'projects/p/decisions.jsonl';
const SNAP = 'projects/p/decisions.active.json';
write(SNAP, JSON.stringify([{ id: 'a', kind: 'decide', note: '' }]));
write(LOG, JSON.stringify({ id: 'a', kind: 'decide', decision: 'd a', scope: 'repo', date: '2026-10-01', source: 'user' }) + '\n');
run(['gstack-brain-enqueue', LOG]);
run(['gstack-brain-sync', '--once']);
expect(remoteFiles()).toEqual(expect.arrayContaining([LOG, SNAP]));
fs.rmSync(path.join(home, LOG));
run(['gstack-brain-sync', '--once']);
expect(remoteFiles()).toEqual(expect.arrayContaining([LOG, SNAP]));
fs.rmSync(path.join(home, SNAP));
run(['gstack-brain-sync', '--once']);
expect(remoteFiles()).toEqual([]);
});
test('on: more than 20 removals at once publishes none until --publish-removals --yes', () => {
run(['gstack-config', 'set', 'artifacts_sync_removals', 'on']);
const files = designs('big', 21);
syncAll(files);
fs.rmSync(path.join(home, 'projects/big'), { recursive: true });
const drain = run(['gstack-brain-sync', '--once']);
expect(drain.stderr).toContain('holding 21 removal(s), more than 20 at once');
expect(remoteFiles().sort()).toEqual([...files].sort());
expect(status().message).toContain('none of the removals were published');
const listed = run(['gstack-brain-sync', '--publish-removals']);
expect(listed.stdout).toContain('21 synced file(s) are gone from disk:');
expect(listed.stdout).toContain(' projects/big/designs/d0.md');
expect(remoteFiles()).toHaveLength(21);
const published = run(['gstack-brain-sync', '--publish-removals', '--yes']);
expect(published.status).toBe(0);
expect(published.stdout).toContain('published 21 removal(s)');
expect(remoteFiles()).toEqual([]);
});
test('--publish-removals --yes works with the setting off and only touches removals', () => {
syncAll(['projects/p/designs/a.md', 'projects/p/designs/b.md']);
fs.rmSync(path.join(home, 'projects/p/designs/a.md'));
write('projects/p/designs/b.md', '# changed, not yet synced\n');
const r = run(['gstack-brain-sync', '--publish-removals', '--yes']);
expect(r.status).toBe(0);
expect(remoteFiles()).toEqual(['projects/p/designs/b.md']);
const remoteB = spawnSync('git', ['--git-dir=' + remote, 'show', 'main:projects/p/designs/b.md'], { encoding: 'utf-8', timeout: 30_000 });
expect(remoteB.stdout).toBe('# doc\n');
expect(run(['gstack-brain-sync', '--publish-removals', '--yes']).stdout).toContain('no removals to publish');
});
test('--publish-removals cleans up its temp files and lock on every exit path', () => {
syncAll(['projects/p/designs/a.md', 'projects/p/designs/b.md']);
fs.rmSync(path.join(home, 'projects/p/designs/a.md'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-removals-tmp-'));
try {
for (const argv of [['--publish-removals'], ['--publish-removals', '--yes'], ['--publish-removals', '--yes']]) {
const r = run(['gstack-brain-sync', ...argv], { TMPDIR: tmp });
expect(r.stderr).not.toContain('unbound variable');
expect(r.status).toBe(0);
expect(fs.readdirSync(tmp)).toEqual([]);
expect(fs.existsSync(path.join(home, '.brain-sync.lock.d'))).toBe(false);
}
expect(remoteFiles()).toEqual(['projects/p/designs/b.md']);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('config rejects values other than on and off', () => {
const r = run(['gstack-config', 'set', 'artifacts_sync_removals', 'yes']);
expect(r.stderr).toContain("artifacts_sync_removals 'yes' not recognized");
expect(run(['gstack-config', 'get', 'artifacts_sync_removals']).stdout.trim()).toBe('off');
});
});