mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 23:14:33 +01:00
* 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>
172 lines
8.4 KiB
TypeScript
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');
|
|
});
|
|
});
|