mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 23:14:33 +01:00
* test(brain-sync): a rejected push's retry merge must not wedge on local edits (#3704) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(brain-sync): set local edits aside around the retry merge and say why it failed (#3704) After a rejected push, git refused the fetch-and-merge whenever a file it would update had uncommitted local edits, and brain-sync discarded git's error, so every later run hit the same refusal and stayed push_failed. The merge now stashes tracked edits first and restores them after; a failed merge is aborted; a conflicting restore keeps the merged version in the working tree and names the stash and the restore command in the status. push_failed carries git's merge reason and the real push error line. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(brain-sync): set aside only the paths the retry merge changes, read them literally, and pop only our own stash entry (#3704) Pre-landing review fixes: a pattern-like conflicting file name no longer resets unrelated local edits, a stash push that saves nothing no longer pops an older entry, and a file that never checks out clean no longer strands every other local edit in the stash. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(brain-sync): describe the retry merge, the kept stash and the push_failed reason (#3704) 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>
1869 lines
93 KiB
TypeScript
1869 lines
93 KiB
TypeScript
/**
|
||
* gbrain-sync integration tests.
|
||
*
|
||
* Covers the core cross-machine memory sync feature end-to-end:
|
||
* - bin/gstack-config gbrain keys (validation, isolation)
|
||
* - bin/gstack-brain-enqueue (atomicity, skip list, no-op gates)
|
||
* - bin/gstack-jsonl-merge (3-way, ts-sort, hash-fallback)
|
||
* - bin/gstack-brain-sync --once (drain, commit, push, secret-scan, skip-file)
|
||
* - bin/gstack-artifacts-init + --restore round-trip
|
||
* - bin/gstack-brain-uninstall preserves user data
|
||
* - env isolation (GSTACK_HOME never bleeds into real ~/.gstack/config.yaml)
|
||
*
|
||
* Runs each test against a temp GSTACK_HOME and a local bare git repo as
|
||
* a fake remote. No live GitHub, no live GBrain.
|
||
*/
|
||
|
||
import { describe, test as _test, expect, beforeEach, afterEach } from 'bun:test';
|
||
|
||
// Boost timeout: brain-sync tests spawn git, network-ls-remote, and 10-way
|
||
// parallel processes — 5s default is too tight.
|
||
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';
|
||
import { canRevokeWrites } from './helpers/fs-caps';
|
||
|
||
const ROOT = path.resolve(import.meta.dir, '..');
|
||
const BIN = path.join(ROOT, 'bin');
|
||
|
||
let tmpHome: string;
|
||
let bareRemote: string;
|
||
|
||
function run(argv: string[], opts: { env?: Record<string, string>; input?: string } = {}) {
|
||
const bin = argv[0];
|
||
const full = bin.startsWith('/') ? bin : path.join(BIN, bin);
|
||
const res = spawnSync(full, argv.slice(1), {
|
||
// HOME is overridden too: gstack-artifacts-init writes
|
||
// $HOME/.gstack-artifacts-remote.txt (plain $HOME, not GSTACK_HOME), so
|
||
// without this every free-suite run clobbers the operator's real
|
||
// artifacts-remote pointer. Keep it inside tmpHome, which afterEach removes.
|
||
env: { ...process.env, HOME: tmpHome, GSTACK_HOME: tmpHome, ...(opts.env || {}) },
|
||
encoding: 'utf-8',
|
||
input: opts.input,
|
||
cwd: ROOT,
|
||
timeout: 30_000,
|
||
});
|
||
return { stdout: res.stdout || '', stderr: res.stderr || '', status: res.status ?? -1 };
|
||
}
|
||
|
||
function git(args: string[], cwd?: string) {
|
||
const res = spawnSync('git', args, { cwd: cwd || tmpHome, encoding: 'utf-8', timeout: 30_000 });
|
||
return { stdout: res.stdout || '', stderr: res.stderr || '', status: res.status ?? -1 };
|
||
}
|
||
|
||
// ---- spool helpers (maildir-style queue: one FILE per record) ----
|
||
// Writers create <epoch>-<pid>-<uniq>.json under .brain-queue.d/ via tmp +
|
||
// atomic rename; the drain deletes exactly the files it snapshotted. The
|
||
// legacy single-file .brain-queue.jsonl exists only as a migration source.
|
||
const spoolDir = () => path.join(tmpHome, '.brain-queue.d');
|
||
const spoolFiles = () =>
|
||
fs.existsSync(spoolDir())
|
||
? fs.readdirSync(spoolDir()).filter((f) => f.endsWith('.json')).sort()
|
||
: [];
|
||
const spoolText = () =>
|
||
spoolFiles()
|
||
.map((f) => fs.readFileSync(path.join(spoolDir(), f), 'utf-8'))
|
||
.join('');
|
||
let spoolSeq = 0;
|
||
function seedSpool(record: string): string {
|
||
fs.mkdirSync(spoolDir(), { recursive: true });
|
||
spoolSeq += 1;
|
||
const name = `${Math.floor(Date.now() / 1000)}-${process.pid}-t${spoolSeq}.json`;
|
||
fs.writeFileSync(path.join(spoolDir(), name), record.endsWith('\n') ? record : record + '\n');
|
||
return name;
|
||
}
|
||
|
||
beforeEach(() => {
|
||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-home-'));
|
||
bareRemote = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-remote-'));
|
||
spawnSync('git', ['init', '--bare', '-q', '-b', 'main', bareRemote], { timeout: 30_000 });
|
||
});
|
||
|
||
afterEach(() => {
|
||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||
fs.rmSync(bareRemote, { recursive: true, force: true });
|
||
// Clean up any remote-helper file init may have written. run() now pins
|
||
// HOME to tmpHome so these land inside the removed temp dir, but scrub the
|
||
// real home too as defense in depth — and cover BOTH the legacy brain-remote
|
||
// name and the current artifacts-remote name (init writes the latter).
|
||
for (const name of ['.gstack-brain-remote.txt', '.gstack-artifacts-remote.txt']) {
|
||
const remoteFile = path.join(os.homedir(), name);
|
||
// Only remove if it points at OUR bare remote (don't clobber a real user file).
|
||
try {
|
||
const contents = fs.readFileSync(remoteFile, 'utf-8').trim();
|
||
if (contents === bareRemote) fs.unlinkSync(remoteFile);
|
||
} catch {}
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------
|
||
// Config key validation + env isolation
|
||
// ---------------------------------------------------------------
|
||
describe('gstack-config gbrain keys', () => {
|
||
test('default artifacts_sync_mode is off', () => {
|
||
const r = run(['gstack-config', 'get', 'artifacts_sync_mode']);
|
||
expect(r.status).toBe(0);
|
||
expect(r.stdout.trim()).toBe('off');
|
||
});
|
||
|
||
test('default artifacts_sync_mode_prompted is false', () => {
|
||
const r = run(['gstack-config', 'get', 'artifacts_sync_mode_prompted']);
|
||
expect(r.stdout.trim()).toBe('false');
|
||
});
|
||
|
||
test('accepts full / artifacts-only / off', () => {
|
||
for (const val of ['full', 'artifacts-only', 'off']) {
|
||
const set = run(['gstack-config', 'set', 'artifacts_sync_mode', val]);
|
||
expect(set.status).toBe(0);
|
||
const get = run(['gstack-config', 'get', 'artifacts_sync_mode']);
|
||
expect(get.stdout.trim()).toBe(val);
|
||
}
|
||
});
|
||
|
||
test('invalid artifacts_sync_mode value warns + defaults', () => {
|
||
const r = run(['gstack-config', 'set', 'artifacts_sync_mode', 'bogus']);
|
||
expect(r.stderr).toContain('not recognized');
|
||
const get = run(['gstack-config', 'get', 'artifacts_sync_mode']);
|
||
expect(get.stdout.trim()).toBe('off');
|
||
});
|
||
|
||
test('GSTACK_HOME overrides real config dir', () => {
|
||
// Real ~/.gstack/config.yaml must not change, regardless of what it
|
||
// already contains on the developer's machine.
|
||
const realConfig = path.join(os.homedir(), '.gstack', 'config.yaml');
|
||
const before = fs.existsSync(realConfig) ? fs.readFileSync(realConfig, 'utf-8') : null;
|
||
|
||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||
|
||
// The override actually took effect — temp config got the new value.
|
||
const tempConfig = fs.readFileSync(path.join(tmpHome, 'config.yaml'), 'utf-8');
|
||
expect(tempConfig).toContain('artifacts_sync_mode: full');
|
||
|
||
// Real ~/.gstack/config.yaml must not be touched.
|
||
const after = fs.existsSync(realConfig) ? fs.readFileSync(realConfig, 'utf-8') : null;
|
||
expect(after).toBe(before);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------
|
||
// Enqueue behavior
|
||
// ---------------------------------------------------------------
|
||
describe('gstack-brain-enqueue', () => {
|
||
test('no-op when feature not initialized', () => {
|
||
const r = run(['gstack-brain-enqueue', 'projects/foo/learnings.jsonl']);
|
||
expect(r.status).toBe(0);
|
||
expect(fs.existsSync(spoolDir())).toBe(false);
|
||
expect(fs.existsSync(path.join(tmpHome, '.brain-queue.jsonl'))).toBe(false);
|
||
});
|
||
|
||
test('no-op when mode is off (even if .git exists)', () => {
|
||
fs.mkdirSync(path.join(tmpHome, '.git'), { recursive: true });
|
||
const r = run(['gstack-brain-enqueue', 'projects/foo/learnings.jsonl']);
|
||
expect(r.status).toBe(0);
|
||
expect(fs.existsSync(spoolDir())).toBe(false);
|
||
});
|
||
|
||
test('enqueues one spool file when mode is full and .git exists', () => {
|
||
fs.mkdirSync(path.join(tmpHome, '.git'), { recursive: true });
|
||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||
run(['gstack-brain-enqueue', 'projects/foo/learnings.jsonl']);
|
||
const files = spoolFiles();
|
||
expect(files.length).toBe(1);
|
||
// Sortable maildir name: <epoch>-<pid>-<uniq>.json.
|
||
expect(files[0]).toMatch(/^\d+-\d+-\d+\.json$/);
|
||
const obj = JSON.parse(fs.readFileSync(path.join(spoolDir(), files[0]), 'utf-8').trim());
|
||
expect(obj.file).toBe('projects/foo/learnings.jsonl');
|
||
expect(obj.ts).toBeTruthy();
|
||
// No tmp-file droppings left behind.
|
||
expect(fs.readdirSync(spoolDir()).filter((f) => f.startsWith('.tmp-')).length).toBe(0);
|
||
});
|
||
|
||
test('skip list honored', () => {
|
||
fs.mkdirSync(path.join(tmpHome, '.git'), { recursive: true });
|
||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||
fs.writeFileSync(path.join(tmpHome, '.brain-skip.txt'), 'projects/foo/secret.jsonl\n');
|
||
run(['gstack-brain-enqueue', 'projects/foo/secret.jsonl']);
|
||
run(['gstack-brain-enqueue', 'projects/foo/ok.jsonl']);
|
||
expect(spoolText()).not.toContain('secret.jsonl');
|
||
expect(spoolText()).toContain('ok.jsonl');
|
||
});
|
||
|
||
test('concurrent enqueues all land (one spool file per record)', async () => {
|
||
fs.mkdirSync(path.join(tmpHome, '.git'), { recursive: true });
|
||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||
const procs = [];
|
||
for (let i = 0; i < 10; i++) {
|
||
procs.push(new Promise<void>((resolve) => {
|
||
const r = spawnSync(path.join(BIN, 'gstack-brain-enqueue'), [`file-${i}.jsonl`], {
|
||
env: { ...process.env, GSTACK_HOME: tmpHome },
|
||
encoding: 'utf-8',
|
||
timeout: 30_000,
|
||
});
|
||
resolve();
|
||
}));
|
||
}
|
||
await Promise.all(procs);
|
||
expect(spoolFiles().length).toBe(10);
|
||
for (let i = 0; i < 10; i++) {
|
||
expect(spoolText()).toContain(`file-${i}.jsonl`);
|
||
}
|
||
});
|
||
|
||
test('no args does not crash', () => {
|
||
const r = run(['gstack-brain-enqueue']);
|
||
expect(r.status).toBe(0);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------
|
||
// JSONL merge driver
|
||
// ---------------------------------------------------------------
|
||
describe('gstack-jsonl-merge', () => {
|
||
test('3-way merge dedups + sorts by ts', () => {
|
||
const base = path.join(tmpHome, 'base.jsonl');
|
||
const ours = path.join(tmpHome, 'ours.jsonl');
|
||
const theirs = path.join(tmpHome, 'theirs.jsonl');
|
||
fs.writeFileSync(base, '');
|
||
fs.writeFileSync(ours, '{"x":1,"ts":"2026-01-01T10:00:00Z"}\n{"x":2,"ts":"2026-01-01T11:00:00Z"}\n');
|
||
fs.writeFileSync(theirs, '{"x":3,"ts":"2026-01-01T09:00:00Z"}\n{"x":2,"ts":"2026-01-01T11:00:00Z"}\n');
|
||
const r = run([path.join(BIN, 'gstack-jsonl-merge'), base, ours, theirs]);
|
||
expect(r.status).toBe(0);
|
||
const lines = fs.readFileSync(ours, 'utf-8').trim().split('\n');
|
||
expect(lines.length).toBe(3);
|
||
expect(lines[0]).toContain('"x":3'); // earliest ts
|
||
expect(lines[2]).toContain('"x":2'); // latest ts
|
||
});
|
||
|
||
test('falls back to hash order for lines without ts', () => {
|
||
const base = path.join(tmpHome, 'base.jsonl');
|
||
const ours = path.join(tmpHome, 'ours.jsonl');
|
||
const theirs = path.join(tmpHome, 'theirs.jsonl');
|
||
fs.writeFileSync(base, '');
|
||
fs.writeFileSync(ours, '{"a":1}\n{"a":2}\n');
|
||
fs.writeFileSync(theirs, '{"a":3}\n{"a":2}\n');
|
||
run([path.join(BIN, 'gstack-jsonl-merge'), base, ours, theirs]);
|
||
const lines = fs.readFileSync(ours, 'utf-8').trim().split('\n');
|
||
expect(lines.length).toBe(3);
|
||
// Order is deterministic (sha256 of each line).
|
||
const again = spawnSync(path.join(BIN, 'gstack-jsonl-merge'), [base, ours, theirs], { timeout: 30_000 });
|
||
// (re-running doesn't change the order since same input → same output)
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------
|
||
// Init + sync + restore round-trip
|
||
// ---------------------------------------------------------------
|
||
describe('init + sync + restore round-trip', () => {
|
||
test('init creates canonical files + registers drivers', () => {
|
||
const r = run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||
expect(r.status).toBe(0);
|
||
expect(fs.existsSync(path.join(tmpHome, '.git'))).toBe(true);
|
||
expect(fs.existsSync(path.join(tmpHome, '.gitignore'))).toBe(true);
|
||
expect(fs.existsSync(path.join(tmpHome, '.brain-allowlist'))).toBe(true);
|
||
expect(fs.existsSync(path.join(tmpHome, '.brain-privacy-map.json'))).toBe(true);
|
||
expect(fs.existsSync(path.join(tmpHome, '.gitattributes'))).toBe(true);
|
||
expect(fs.existsSync(path.join(tmpHome, '.git/hooks/pre-commit'))).toBe(true);
|
||
// Merge driver registered in local git config.
|
||
const cfg = git(['config', '--get', 'merge.jsonl-append.driver']);
|
||
expect(cfg.stdout).toContain('gstack-jsonl-merge');
|
||
});
|
||
|
||
test('the pre-commit hook init and restore write catches a JSON auth header with a Bearer prefix', () => {
|
||
for (const bin of ['gstack-artifacts-init', 'gstack-brain-restore']) {
|
||
const src = fs.readFileSync(path.join(BIN, bin), 'utf-8');
|
||
expect(src).toContain('(Bearer |Basic |Token )?[A-Za-z0-9_./+=-]{16,}');
|
||
}
|
||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
const token = ['Bearer ', 'abcd', 'efgh', 'ijkl', 'mnop', 'qrst'].join('');
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/headers.json'), `{"authorization": "${token}"}\n`);
|
||
git(['add', '-f', 'projects/p/headers.json']);
|
||
const r = git(['commit', '-q', '-m', 'manual commit']);
|
||
expect(r.status).not.toBe(0);
|
||
expect(git(['log', '--oneline']).stdout).not.toContain('manual commit');
|
||
});
|
||
|
||
test('refuses init on different remote', () => {
|
||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||
const otherRemote = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-other-'));
|
||
spawnSync('git', ['init', '--bare', '-q', '-b', 'main', otherRemote], { timeout: 30_000 });
|
||
const r = run(['gstack-artifacts-init', '--remote', otherRemote]);
|
||
expect(r.status).not.toBe(0);
|
||
expect(r.stderr).toContain('already a git repo pointing at');
|
||
fs.rmSync(otherRemote, { recursive: true, force: true });
|
||
});
|
||
|
||
test('full sync: init → enqueue → --once → commit pushed', () => {
|
||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'),
|
||
'{"skill":"x","insight":"y","ts":"2026-04-22T10:00:00Z"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
const r = run(['gstack-brain-sync', '--once']);
|
||
expect(r.status).toBe(0);
|
||
// Check the remote got the commit.
|
||
const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8', timeout: 30_000 });
|
||
expect(log.stdout).toMatch(/sync: 1 file/);
|
||
});
|
||
|
||
test('staged transcript pages stay queued without transcript consent; other classes still push', () => {
|
||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'),
|
||
'{"skill":"x","insight":"y","ts":"2026-04-22T10:00:00Z"}\n');
|
||
const page = 'transcripts/run-1-1/session.md';
|
||
fs.mkdirSync(path.join(tmpHome, 'transcripts', 'run-1-1'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, page), '# Session\n\nhello\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
run(['gstack-brain-enqueue', page]);
|
||
const remoteFiles = () =>
|
||
spawnSync('git', ['--git-dir=' + bareRemote, 'ls-tree', '-r', '--name-only', 'main'], { encoding: 'utf-8', timeout: 30_000 }).stdout;
|
||
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
expect(remoteFiles()).toContain('projects/p/learnings.jsonl');
|
||
expect(remoteFiles()).not.toContain(page);
|
||
expect(spoolText()).toContain(page);
|
||
expect(fs.existsSync(path.join(tmpHome, page))).toBe(true);
|
||
|
||
// A stored off or legacy value is not consent either.
|
||
for (const value of ['off', 'A']) {
|
||
fs.appendFileSync(path.join(tmpHome, 'config.yaml'), `transcript_ingest_mode: ${value}\n`);
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
expect(remoteFiles()).not.toContain(page);
|
||
}
|
||
|
||
expect(run(['gstack-config', 'set', 'transcript_ingest_mode', 'recent']).status).toBe(0);
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
expect(remoteFiles()).toContain(page);
|
||
expect(spoolText()).not.toContain(page);
|
||
});
|
||
|
||
test('restore round-trip: writes on machine A visible on machine B', () => {
|
||
// Machine A.
|
||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'myproj'), { recursive: true });
|
||
const aLearning = '{"skill":"x","insight":"machine A wisdom","ts":"2026-04-22T10:00:00Z"}\n';
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/myproj/learnings.jsonl'), aLearning);
|
||
run(['gstack-brain-enqueue', 'projects/myproj/learnings.jsonl']);
|
||
run(['gstack-brain-sync', '--once']);
|
||
|
||
// Machine B (new temp home).
|
||
const machineB = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-machineB-'));
|
||
const r = run(['gstack-brain-restore', bareRemote], {
|
||
env: { GSTACK_HOME: machineB },
|
||
});
|
||
expect(r.status).toBe(0);
|
||
const restored = fs.readFileSync(path.join(machineB, 'projects/myproj/learnings.jsonl'), 'utf-8');
|
||
expect(restored).toContain('machine A wisdom');
|
||
// Merge drivers re-registered on B.
|
||
const cfg = spawnSync('git', ['-C', machineB, 'config', '--get', 'merge.jsonl-append.driver'], { encoding: 'utf-8', timeout: 30_000 });
|
||
expect(cfg.stdout).toContain('gstack-jsonl-merge');
|
||
fs.rmSync(machineB, { recursive: true, force: true });
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------
|
||
// Secret scan: all regex families block
|
||
// ---------------------------------------------------------------
|
||
describe('gstack-brain-sync secret scan', () => {
|
||
const SECRETS: [string, string][] = [
|
||
['aws-access-key', 'AKIAABCDEFGHIJKLMNOP'],
|
||
['github-token-ghp', 'ghp_abcdefghij1234567890abcdef1234567890'],
|
||
['github-token-github-pat', 'github_pat_11ABCDEFG1234567890_abcdef'],
|
||
['openai-key', 'sk-abcdefghij1234567890abcdef1234567890'],
|
||
['pem-block', '-----BEGIN PRIVATE KEY-----'],
|
||
['jwt', 'eyJ0eXAiOiJKV1QiLCJh.eyJzdWIiOiIxMjM0NTY3.SflKxwRJSMeKKF30oGTbU'],
|
||
['bearer-json', '"authorization":"Bearer abcdef1234567890abcdef1234567890"'],
|
||
];
|
||
|
||
for (const [name, content] of SECRETS) {
|
||
test(`blocks ${name}`, () => {
|
||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'),
|
||
`{"leaked":"${content}"}\n`);
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
const r = run(['gstack-brain-sync', '--once']);
|
||
expect(r.status).toBe(0); // exits clean even when blocked
|
||
// No new commit should have been created.
|
||
const log = git(['log', '--oneline']);
|
||
expect(log.stdout.split('\n').filter(Boolean).length).toBeLessThanOrEqual(3);
|
||
// The flagged file is held back on its own (status held, not blocked):
|
||
// named with its scanner rule, never the matched text, and still queued.
|
||
const raw = fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8');
|
||
const status = JSON.parse(raw);
|
||
expect(status.status).toBe('held');
|
||
expect(status.held_count).toBe(1);
|
||
expect(status.held.map((h: any) => h.path)).toEqual(['projects/p/learnings.jsonl']);
|
||
expect(raw).not.toContain(content.slice(0, 12));
|
||
expect(spoolText()).toContain('projects/p/learnings.jsonl');
|
||
});
|
||
}
|
||
|
||
test('--skip-file unblocks specific file', () => {
|
||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
const leakPath = 'projects/p/leaked.jsonl';
|
||
fs.writeFileSync(path.join(tmpHome, leakPath),
|
||
'{"gh":"ghp_abcdefghij1234567890abcdef1234567890"}\n');
|
||
run(['gstack-brain-enqueue', leakPath]);
|
||
run(['gstack-brain-sync', '--once']); // blocked
|
||
run(['gstack-brain-sync', '--skip-file', leakPath]);
|
||
// Any future enqueue of this path should no-op.
|
||
run(['gstack-brain-enqueue', leakPath]);
|
||
const skip = fs.readFileSync(path.join(tmpHome, '.brain-skip.txt'), 'utf-8');
|
||
expect(skip).toContain(leakPath);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------
|
||
// Per-file hold: a flagged file is held back on its own (with its coupled
|
||
// group) and stays queued, the rest syncs, and every failure that used to be
|
||
// silent lands in the status file. Contributor regression tests from
|
||
// PR #3055 (@v639dragoon) are adapted here to the rewritten drain.
|
||
// ---------------------------------------------------------------
|
||
describe('gstack-brain-sync per-file hold', () => {
|
||
const FLAGGED = 'projects/p/learnings.jsonl';
|
||
const CLEAN = 'projects/p/ceo-plans/clean.md';
|
||
const LOG = 'projects/p/decisions.jsonl';
|
||
const SNAP = 'projects/p/decisions.active.json';
|
||
const TOKEN = ['ghp', 'abcdefghij1234567890abcdef1234567890'].join('_');
|
||
const SELF = path.join(BIN, 'gstack-brain-sync');
|
||
const statusRaw = () => fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8');
|
||
const statusJson = () => JSON.parse(statusRaw());
|
||
const remoteFiles = () =>
|
||
spawnSync('git', ['--git-dir=' + bareRemote, 'ls-tree', '-r', '--name-only', 'main'], { encoding: 'utf-8', timeout: 30_000 }).stdout;
|
||
const remoteLog = () =>
|
||
spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8', timeout: 30_000 }).stdout;
|
||
const remoteShow = (p: string) =>
|
||
spawnSync('git', ['--git-dir=' + bareRemote, 'show', `main:${p}`], { encoding: 'utf-8', timeout: 30_000 });
|
||
const staged = () => git(['diff', '--cached', '--name-only']).stdout.trim();
|
||
const drain = (env: Record<string, string> = {}) => run(['gstack-brain-sync', '--once'], { env });
|
||
|
||
function init(mode = 'full') {
|
||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||
run(['gstack-config', 'set', 'artifacts_sync_mode', mode]);
|
||
fs.mkdirSync(path.join(tmpHome, 'projects/p/ceo-plans'), { recursive: true });
|
||
}
|
||
function write(p: string, body: string, enqueue = true) {
|
||
fs.mkdirSync(path.dirname(path.join(tmpHome, p)), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, p), body);
|
||
if (enqueue) run(['gstack-brain-enqueue', p]);
|
||
}
|
||
const decide = (id: string) => JSON.stringify({ id, kind: 'decide', decision: `d ${id}`, scope: 'repo', date: `2026-10-0${id.length}`, source: 'user' });
|
||
function writeDecisions(ids: string[], snapIds = ids, snapExtra = '') {
|
||
write(SNAP, JSON.stringify(snapIds.map((id) => ({ id, kind: 'decide', note: snapExtra }))), false);
|
||
write(LOG, ids.map(decide).join('\n') + '\n');
|
||
}
|
||
/** A PATH shim in front of the real git: logs every call, optionally
|
||
* injects one fault. Fault injection reaches the drain's fail-closed paths,
|
||
* which a correct git never takes. */
|
||
function gitShim(fault = ''): { env: Record<string, string>; calls: () => string[] } {
|
||
const dir = fs.mkdtempSync(path.join(tmpHome, 'shim-'));
|
||
const real = spawnSync('sh', ['-c', 'command -v git'], { encoding: 'utf-8', timeout: 30_000 }).stdout.trim();
|
||
const log = path.join(dir, 'calls.log');
|
||
fs.writeFileSync(path.join(dir, 'git'), `#!/bin/sh\nprintf '%s\\n' "$*" >> '${log}'\n${fault}\nexec '${real}' "$@"\n`, { mode: 0o755 });
|
||
return {
|
||
env: { PATH: `${dir}${path.delimiter}${process.env.PATH}` },
|
||
calls: () => (fs.existsSync(log) ? fs.readFileSync(log, 'utf-8').split('\n').filter(Boolean) : []),
|
||
};
|
||
}
|
||
|
||
test('one flagged file is held back; the clean file still syncs and status is held', () => {
|
||
init();
|
||
write(FLAGGED, `{"gh":"${TOKEN}"}\n`);
|
||
write(CLEAN, '# a plan\n');
|
||
expect(drain().status).toBe(0);
|
||
expect(remoteFiles()).toContain(CLEAN);
|
||
expect(remoteFiles()).not.toContain(FLAGGED);
|
||
expect(remoteLog()).toMatch(/sync: 1 file/);
|
||
expect(staged()).toBe('');
|
||
expect(spoolText()).toContain(FLAGGED);
|
||
expect(spoolText()).not.toContain(CLEAN);
|
||
const s = statusJson();
|
||
expect(s.status).toBe('held');
|
||
expect(s.held_count).toBe(1);
|
||
expect(s.drainable).toBe(0);
|
||
expect(s.last_push_at).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/);
|
||
expect(s.last_drain_at).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/);
|
||
expect(s.held).toHaveLength(1);
|
||
expect(s.held[0].path).toBe(FLAGGED);
|
||
expect(s.held[0].rule).toBe('github-token');
|
||
expect(s.held[0].dependents).toEqual([]);
|
||
// Both fixes, with the absolute helper path; skip is permanent and reversible.
|
||
expect(s.held[0].fixes[0]).toContain(`edit ${FLAGGED}`);
|
||
expect(s.held[0].fixes[1]).toContain(`${SELF} --skip-file ${FLAGGED}`);
|
||
expect(s.held[0].fixes[1]).toContain('permanently');
|
||
expect(s.held[0].fixes[1]).toContain(`${SELF} --unskip-file ${FLAGGED}`);
|
||
// The matched text appears nowhere in the status.
|
||
expect(statusRaw()).not.toContain('ghp_');
|
||
// --status prints the held array.
|
||
expect(run(['gstack-brain-sync', '--status']).stdout).toContain(`"path": "${FLAGGED}"`);
|
||
});
|
||
|
||
test('a held file is re-scanned every drain and clears once its content is edited', () => {
|
||
init();
|
||
write(FLAGGED, `{"gh":"${TOKEN}"}\n`);
|
||
drain();
|
||
drain();
|
||
expect(statusJson().status).toBe('held');
|
||
expect(spoolFiles()).toHaveLength(1);
|
||
write(FLAGGED, '{"gh":"rotated and removed"}\n');
|
||
drain();
|
||
const s = statusJson();
|
||
expect(s.status).toBe('ok');
|
||
expect(s.held).toEqual([]);
|
||
expect(s.held_count).toBe(0);
|
||
expect(remoteFiles()).toContain(FLAGGED);
|
||
expect(spoolFiles()).toEqual([]);
|
||
});
|
||
|
||
test('--skip-file clears a hold permanently and --unskip-file resumes syncing', () => {
|
||
init();
|
||
write(FLAGGED, `{"gh":"${TOKEN}"}\n`);
|
||
drain();
|
||
expect(run(['gstack-brain-sync', '--skip-file', FLAGGED]).stdout).toContain(`--unskip-file ${FLAGGED}`);
|
||
drain();
|
||
expect(statusJson().status).toBe('idle');
|
||
expect(statusJson().held_count).toBe(0);
|
||
expect(spoolFiles()).toEqual([]);
|
||
expect(remoteFiles()).not.toContain(FLAGGED);
|
||
|
||
write(FLAGGED, '{"gh":"clean now"}\n', false);
|
||
const r = run(['gstack-brain-sync', '--unskip-file', FLAGGED]);
|
||
expect(r.stdout).toContain(`removed from skip list: ${FLAGGED}`);
|
||
expect(fs.readFileSync(path.join(tmpHome, '.brain-skip.txt'), 'utf-8')).not.toContain(FLAGGED);
|
||
expect(spoolText()).toContain(FLAGGED);
|
||
drain();
|
||
expect(statusJson().status).toBe('ok');
|
||
expect(remoteFiles()).toContain(FLAGGED);
|
||
expect(run(['gstack-brain-sync', '--unskip-file', FLAGGED]).stdout).toContain('not in skip list');
|
||
});
|
||
|
||
test('undecodable bytes next to a secret cannot pass the scan as clean', () => {
|
||
init();
|
||
fs.writeFileSync(path.join(tmpHome, FLAGGED), Buffer.concat([Buffer.from('{"bin":"'), Buffer.from([0xff, 0xfe, 0xc3]), Buffer.from(`","gh":"${TOKEN}"}\n`)]));
|
||
run(['gstack-brain-enqueue', FLAGGED]);
|
||
write(CLEAN, '# a plan\n');
|
||
drain();
|
||
expect(remoteFiles()).not.toContain(FLAGGED);
|
||
expect(remoteFiles()).toContain(CLEAN);
|
||
expect(statusJson().status).toBe('held');
|
||
});
|
||
|
||
test('a re-scan that still flags after the hold falls back to blocked: nothing committed, queue kept', () => {
|
||
init();
|
||
write(FLAGGED, `{"gh":"${TOKEN}"}\n`);
|
||
write(CLEAN, '# a plan\n');
|
||
const before = git(['rev-list', '--count', 'HEAD']).stdout.trim();
|
||
// git "succeeds" at unstaging the held file without doing it.
|
||
const shim = gitShim(`case "$*" in *"reset -q HEAD --pathspec-from-file=-"*) cat >/dev/null; exit 0 ;; esac`);
|
||
expect(drain(shim.env).status).toBe(0);
|
||
const s = statusJson();
|
||
expect(s.status).toBe('blocked');
|
||
expect(s.message).toContain('re-scan');
|
||
expect(statusRaw()).not.toContain('ghp_');
|
||
expect(git(['rev-list', '--count', 'HEAD']).stdout.trim()).toBe(before);
|
||
expect(staged()).toBe('');
|
||
expect(spoolText()).toContain(FLAGGED);
|
||
expect(spoolText()).toContain(CLEAN);
|
||
expect(remoteFiles()).not.toContain(CLEAN);
|
||
});
|
||
|
||
test('findings the scanner cannot attribute to files block the drain (fail closed)', () => {
|
||
init();
|
||
write(CLEAN, '# a plan\n');
|
||
const shim = gitShim(`case "$*" in *"--name-only"*) '${spawnSync('sh', ['-c', 'command -v git'], { encoding: 'utf-8', timeout: 30_000 }).stdout.trim()}' "$@"; printf 'extra\\0'; exit 0 ;; esac`);
|
||
drain(shim.env);
|
||
expect(statusJson().status).toBe('blocked');
|
||
expect(staged()).toBe('');
|
||
expect(remoteFiles()).not.toContain(CLEAN);
|
||
expect(spoolText()).toContain(CLEAN);
|
||
});
|
||
|
||
_test('1,000 queued paths are attributed in one scanner pass and staged in one add', () => {
|
||
init();
|
||
for (let i = 0; i < 1000; i++) {
|
||
const flagged = i % 400 === 7;
|
||
fs.writeFileSync(path.join(tmpHome, `projects/p/ceo-plans/plan-${i}.md`), flagged ? `token ${TOKEN}\n` : `# plan ${i}\n`);
|
||
seedSpool(`{"file":"projects/p/ceo-plans/plan-${i}.md","ts":"t"}`);
|
||
}
|
||
const shim = gitShim();
|
||
expect(drain(shim.env).status).toBe(0);
|
||
const s = statusJson();
|
||
expect(s.status).toBe('held');
|
||
expect(s.held.map((h: any) => h.path).sort()).toEqual(
|
||
['projects/p/ceo-plans/plan-407.md', 'projects/p/ceo-plans/plan-7.md', 'projects/p/ceo-plans/plan-807.md'],
|
||
);
|
||
expect(remoteLog()).toMatch(/sync: 997 file/);
|
||
const calls = shim.calls();
|
||
expect(calls.filter((c) => / add /.test(` ${c} `)).length).toBe(1);
|
||
// attribution diff + name list + post-hold re-scan diff + empty-diff check
|
||
expect(calls.filter((c) => c.includes('diff --cached')).length).toBeLessThanOrEqual(4);
|
||
expect(spoolFiles()).toHaveLength(3);
|
||
}, 120_000);
|
||
|
||
test('successful add without an index entry keeps the queue and refuses publication', () => {
|
||
init();
|
||
write(CLEAN, '# queued plan\n');
|
||
const before = git(['rev-parse', 'HEAD']).stdout;
|
||
const shim = gitShim('case " $* " in *" add "*) exit 0;; esac');
|
||
expect(drain(shim.env).status).toBe(0);
|
||
expect(statusJson().status).toBe('error');
|
||
expect(statusJson().message).toContain('staged artifact validation refused');
|
||
expect(spoolText()).toContain(CLEAN);
|
||
expect(git(['rev-parse', 'HEAD']).stdout).toBe(before);
|
||
expect(remoteFiles()).not.toContain(CLEAN);
|
||
});
|
||
|
||
test('assume-unchanged cannot silently acknowledge stale indexed content', () => {
|
||
init();
|
||
write(CLEAN, '# first version\n');
|
||
drain();
|
||
expect(git(['update-index', '--assume-unchanged', CLEAN]).status).toBe(0);
|
||
write(CLEAN, '# second version\n');
|
||
expect(drain().status).toBe(0);
|
||
expect(statusJson().status).toBe('error');
|
||
expect(spoolText()).toContain(CLEAN);
|
||
expect(remoteShow(CLEAN).stdout).toBe('# first version\n');
|
||
});
|
||
|
||
test('unrelated staged entries are preserved and never included in a sync commit', () => {
|
||
init();
|
||
write(CLEAN, '# queued plan\n');
|
||
const extra = 'projects/p/ceo-plans/manual.md';
|
||
write(extra, '# manually staged\n', false);
|
||
expect(git(['add', '-f', '--', extra]).status).toBe(0);
|
||
const result = drain();
|
||
expect(result.status).toBe(0);
|
||
expect(statusJson().status).toBe('error');
|
||
expect(spoolText()).toContain(CLEAN);
|
||
expect(staged()).toContain(extra);
|
||
expect(staged()).toContain(CLEAN);
|
||
expect(remoteFiles()).not.toContain(extra);
|
||
expect(remoteFiles()).not.toContain(CLEAN);
|
||
});
|
||
|
||
test('staged content verification respects Git clean conversion', () => {
|
||
init();
|
||
fs.writeFileSync(path.join(tmpHome, '.gitattributes'), '*.md text eol=lf\n');
|
||
write(CLEAN, '# CRLF plan\r\n');
|
||
expect(drain().status).toBe(0);
|
||
expect(statusJson().status).toBe('ok');
|
||
expect(remoteShow(CLEAN).stdout).toBe('# CRLF plan\n');
|
||
expect(spoolFiles()).toHaveLength(0);
|
||
});
|
||
|
||
function seedIndexedPaths(paths: string[]) {
|
||
const oid = spawnSync('git', ['-C', tmpHome, 'hash-object', '-w', '--stdin'], {
|
||
input: '# historical artifact\n', encoding: 'utf-8', timeout: 30_000,
|
||
});
|
||
expect(oid.status).toBe(0);
|
||
for (const p of paths) {
|
||
expect(git(['update-index', '--add', '--cacheinfo', `100644,${oid.stdout.trim()},${p}`]).status).toBe(0);
|
||
}
|
||
expect(git(['-c', 'user.name=fixture', '-c', 'user.email=fixture@example.com', 'commit', '-qm', 'historical paths']).status).toBe(0);
|
||
expect(git(['push', '-q', 'origin', 'HEAD']).status).toBe(0);
|
||
}
|
||
|
||
for (const [oldPath, newPath] of [
|
||
['projects/P/ceo-plans/old.md', CLEAN],
|
||
['projects/p/ceo-plans/Foo/old.md', 'projects/p/ceo-plans/foo/new.md'],
|
||
['projects/Straße/ceo-plans/old.md', 'projects/STRASSE/ceo-plans/new.md'],
|
||
]) {
|
||
test(`a new casefold prefix alias is held, never staged: ${oldPath}`, () => {
|
||
init();
|
||
// Create the historical tree directly so this works on either a case-
|
||
// sensitive or insensitive host, without materializing conflicting dirs.
|
||
seedIndexedPaths([oldPath]);
|
||
expect(git(['config', 'core.ignorecase', 'false']).status).toBe(0);
|
||
write(newPath, '# queued plan\n');
|
||
const before = git(['rev-parse', 'HEAD']).stdout;
|
||
expect(drain().status).toBe(0);
|
||
const s = statusJson();
|
||
expect(s.status).toBe('collision');
|
||
expect(s.case_held).toBe(1);
|
||
expect(spoolText()).toContain(newPath);
|
||
expect(git(['rev-parse', 'HEAD']).stdout).toBe(before);
|
||
expect(remoteFiles()).not.toContain(newPath);
|
||
expect(staged()).toBe('');
|
||
});
|
||
}
|
||
|
||
test('an unchanged historical collision does not block an unrelated artifact', () => {
|
||
init();
|
||
seedIndexedPaths(['projects/OLD/ceo-plans/a.md', 'projects/old/ceo-plans/b.md']);
|
||
write(CLEAN, '# independent plan\n');
|
||
expect(drain().status).toBe(0);
|
||
expect(statusJson().status).toBe('ok');
|
||
expect(statusJson().case_held).toBe(0);
|
||
expect(statusJson().case_names).toBe('OLD,old');
|
||
expect(remoteFiles()).toContain(CLEAN);
|
||
expect(spoolFiles()).toHaveLength(0);
|
||
});
|
||
|
||
// #3220: two spellings of one project are both tracked (a case-sensitive
|
||
// producer committed both). A write to either is held instead of staged, so
|
||
// a case-insensitive checkout cannot record it under the other spelling and
|
||
// report ok; every other file still syncs.
|
||
const WS_UPPER = 'projects/Example_Workspace/learnings.jsonl';
|
||
const WS_LOWER = 'projects/Example_workspace/learnings.jsonl';
|
||
test('#3220: a write into a tracked case-colliding project is held; the rest syncs and nothing reports success', () => {
|
||
init();
|
||
seedIndexedPaths([WS_UPPER, WS_LOWER]);
|
||
write(WS_LOWER, '{"key":"c","insight":"new on mac"}\n');
|
||
write(CLEAN, '# independent plan\n');
|
||
const result = drain();
|
||
expect(result.status).toBe(0);
|
||
expect(result.stderr).toContain('differs only by letter case (Example_Workspace,Example_workspace)');
|
||
expect(remoteFiles()).toContain(CLEAN);
|
||
expect(remoteShow(WS_LOWER).stdout).toBe('# historical artifact\n');
|
||
expect(staged()).toBe('');
|
||
expect(spoolText()).toContain(WS_LOWER);
|
||
expect(spoolText()).not.toContain(CLEAN);
|
||
const s = statusJson();
|
||
expect(s.status).toBe('collision');
|
||
expect(s.case_held).toBe(1);
|
||
expect(s.case_names).toBe('Example_Workspace,Example_workspace');
|
||
expect(s.message).toContain(WS_LOWER);
|
||
expect(s.message).toContain('docs/troubleshooting.md#artifact-case-collision');
|
||
expect(run(['gstack-brain-sync', '--attention']).stdout.trim()).toBe(
|
||
`ARTIFACTS_SYNC: attention: case-collision held=1 names=Example_Workspace,Example_workspace. Artifact paths differ only by letter case, which a Mac or Windows disk stores as one file; sync holds 1 queued file(s) under them and syncs everything else. Repair: https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#artifact-case-collision. See the paths: ${SELF} --status`,
|
||
);
|
||
// Held, not dropped: the next drain holds it again and still never says ok.
|
||
expect(drain().status).toBe(0);
|
||
expect(statusJson().status).toBe('collision');
|
||
expect(spoolText()).toContain(WS_LOWER);
|
||
});
|
||
|
||
test('publish-removals refuses to push a retained commit that adds a case alias', () => {
|
||
init();
|
||
const victim = 'projects/other/ceo-plans/remove.md';
|
||
seedIndexedPaths(['projects/P/ceo-plans/old.md', victim]);
|
||
write('projects/P/ceo-plans/old.md', '# historical artifact\n', false);
|
||
const alias = 'projects/p/ceo-plans/local.md';
|
||
write(alias, '# unsafe local alias\n', false);
|
||
expect(git(['add', '-f', '--', alias]).status).toBe(0);
|
||
expect(git(['-c', 'user.name=fixture', '-c', 'user.email=fixture@example.com', 'commit', '-qm', 'retained local alias']).status).toBe(0);
|
||
expect(drain().status).toBe(0);
|
||
expect(statusJson().status).toBe('error');
|
||
expect(run(['gstack-brain-sync', '--publish-removals', '--yes']).status).toBe(1);
|
||
expect(remoteFiles()).not.toContain(alias);
|
||
expect(remoteFiles()).toContain(victim);
|
||
});
|
||
|
||
test('the cached outgoing verdict is re-checked when the remote baseline moves', () => {
|
||
init();
|
||
const initial = git(['rev-parse', 'HEAD']).stdout.trim();
|
||
seedIndexedPaths(['projects/MyApp/ceo-plans/a.md', 'projects/myapp/ceo-plans/b.md']);
|
||
expect(drain().status).toBe(0);
|
||
expect(statusJson().status).not.toBe('error');
|
||
expect(git(['--git-dir=' + bareRemote, 'update-ref', 'refs/heads/main', initial]).status).toBe(0);
|
||
expect(git(['fetch', '-q', 'origin']).status).toBe(0);
|
||
expect(drain().status).toBe(0);
|
||
expect(statusJson().status).toBe('error');
|
||
expect(statusJson().case_names).toBe('MyApp,myapp');
|
||
});
|
||
|
||
test('a tracked spelling keeps syncing while only the new case alias is held', () => {
|
||
init();
|
||
seedIndexedPaths(['projects/MyApp/ceo-plans/a.md']);
|
||
write('projects/MyApp/ceo-plans/a.md', '# updated\n');
|
||
write('projects/myapp/ceo-plans/b.md', '# other project\n');
|
||
expect(drain().status).toBe(0);
|
||
expect(remoteShow('projects/MyApp/ceo-plans/a.md').stdout).toBe('# updated\n');
|
||
expect(remoteFiles()).not.toContain('projects/myapp/ceo-plans/b.md');
|
||
const s = statusJson();
|
||
expect(s.status).toBe('collision');
|
||
expect(s.case_held).toBe(1);
|
||
expect(s.case_names).toBe('MyApp,myapp');
|
||
expect(spoolText()).toContain('projects/myapp/ceo-plans/b.md');
|
||
});
|
||
|
||
test('without a case collision the status carries no case fields and skill start no case line', () => {
|
||
init();
|
||
write(CLEAN, '# a plan\n');
|
||
expect(drain().status).toBe(0);
|
||
const s = statusJson();
|
||
expect(s.status).toBe('ok');
|
||
expect(s.case_held).toBe(0);
|
||
expect(s.case_names).toBe('');
|
||
expect(run(['gstack-brain-sync', '--attention']).stdout).toBe('');
|
||
});
|
||
|
||
test('a retry merge introducing casefold aliases stays local and keeps the queue', () => {
|
||
init();
|
||
const peer = fs.mkdtempSync(path.join(tmpHome, 'peer-'));
|
||
expect(git(['clone', '-q', bareRemote, peer]).status).toBe(0);
|
||
const peerPath = 'projects/P/ceo-plans/remote.md';
|
||
fs.mkdirSync(path.dirname(path.join(peer, peerPath)), { recursive: true });
|
||
fs.writeFileSync(path.join(peer, peerPath), '# remote plan\n');
|
||
expect(git(['add', '-f', '--', peerPath], peer).status).toBe(0);
|
||
expect(git(['-c', 'user.name=fixture', '-c', 'user.email=fixture@example.com', 'commit', '-qm', 'remote plan'], peer).status).toBe(0);
|
||
expect(git(['push', '-q', 'origin', 'HEAD'], peer).status).toBe(0);
|
||
expect(git(['config', 'core.ignorecase', 'false']).status).toBe(0);
|
||
// The retry merge commits with the ambient identity; a CI runner has none.
|
||
expect(git(['config', 'user.name', 'fixture']).status).toBe(0);
|
||
expect(git(['config', 'user.email', 'fixture@example.com']).status).toBe(0);
|
||
write(CLEAN, '# local plan\n');
|
||
expect(drain().status).toBe(0);
|
||
expect(statusJson().status).toBe('error');
|
||
expect(statusJson().case_names).toBe('P,p');
|
||
expect(spoolText()).toContain(CLEAN);
|
||
expect(remoteFiles()).toContain(peerPath);
|
||
expect(remoteFiles()).not.toContain(CLEAN);
|
||
// A following empty/ordinary retry must not auto-push the retained merge.
|
||
expect(drain().status).toBe(0);
|
||
expect(statusJson().status).toBe('error');
|
||
expect(remoteFiles()).not.toContain(CLEAN);
|
||
});
|
||
|
||
describe('a rejected push merges the remote without wedging (#3704)', () => {
|
||
const SHARED = 'developer-profile.json';
|
||
const OTHER = 'projects/p/ceo-plans/other.md';
|
||
function peerCommit(p: string, body: string) {
|
||
const peer = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-peer-'));
|
||
try {
|
||
expect(git(['clone', '-q', bareRemote, peer]).status).toBe(0);
|
||
fs.mkdirSync(path.dirname(path.join(peer, p)), { recursive: true });
|
||
fs.writeFileSync(path.join(peer, p), body);
|
||
expect(git(['add', '-f', '--', p], peer).status).toBe(0);
|
||
expect(git(['-c', 'user.name=peer', '-c', 'user.email=peer@example.com', 'commit', '-qm', `peer ${p}`], peer).status).toBe(0);
|
||
expect(git(['push', '-q', 'origin', 'HEAD'], peer).status).toBe(0);
|
||
} finally {
|
||
fs.rmSync(peer, { recursive: true, force: true });
|
||
}
|
||
}
|
||
function setup() {
|
||
init();
|
||
write(SHARED, '# shared v1\n');
|
||
write(OTHER, '# other v1\n');
|
||
expect(drain().status).toBe(0);
|
||
expect(statusJson().status).toBe('ok');
|
||
// The retry merge commits with the ambient identity; a CI runner has none.
|
||
expect(git(['config', 'user.name', 'fixture']).status).toBe(0);
|
||
expect(git(['config', 'user.email', 'fixture@example.com']).status).toBe(0);
|
||
}
|
||
const unmerged = () => git(['ls-files', '--unmerged']).stdout.trim();
|
||
const mergeHead = () => fs.existsSync(path.join(tmpHome, '.git', 'MERGE_HEAD'));
|
||
|
||
test('a dirty tracked file the remote did not touch rides through the merge', () => {
|
||
setup();
|
||
peerCommit(SHARED, '# shared from the peer\n');
|
||
write(OTHER, '# other, local edit not queued\n', false);
|
||
write(CLEAN, '# a plan\n');
|
||
expect(drain().status).toBe(0);
|
||
expect(statusJson().status).toBe('ok');
|
||
expect(remoteFiles()).toContain(CLEAN);
|
||
expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n');
|
||
expect(fs.readFileSync(path.join(tmpHome, OTHER), 'utf-8')).toBe('# other, local edit not queued\n');
|
||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared from the peer\n');
|
||
});
|
||
|
||
/** The restore command the status names, run as printed. */
|
||
function recover(message: string) {
|
||
const m = /get them back: (git -C \S+ --literal-pathspecs restore --source=([0-9a-f]{40}) -- .+?)\)/.exec(message);
|
||
expect(m).not.toBeNull();
|
||
const r = spawnSync('bash', ['-c', m![1]], { encoding: 'utf-8', timeout: 30_000 });
|
||
expect(r.status).toBe(0);
|
||
return m![2];
|
||
}
|
||
|
||
test('a dirty tracked file the remote also changed no longer leaves the push failed', () => {
|
||
setup();
|
||
peerCommit(SHARED, '# shared from the peer\n');
|
||
write(SHARED, '# shared, local edit not queued\n', false);
|
||
write(CLEAN, '# a plan\n');
|
||
expect(drain().status).toBe(0);
|
||
const s = statusJson();
|
||
expect(s.status).toBe('ok');
|
||
expect(remoteFiles()).toContain(CLEAN);
|
||
expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n');
|
||
// The merged version stays in the working tree: no conflict markers,
|
||
// nothing staged, and nothing stale for a later sync to publish.
|
||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared from the peer\n');
|
||
expect(unmerged()).toBe('');
|
||
expect(staged()).toBe('');
|
||
expect(s.message).toContain(`local edits to ${SHARED} conflicted`);
|
||
const sha = /saved in stash ([0-9a-f]{40})/.exec(s.message)?.[1];
|
||
expect(sha).toBeTruthy();
|
||
// A second sync run (same file re-queued, plus another) keeps the peer's
|
||
// edit on the remote and the stashed local edit intact.
|
||
run(['gstack-brain-enqueue', SHARED]);
|
||
write(OTHER, '# other v2\n');
|
||
expect(drain().status).toBe(0);
|
||
expect(statusJson().status).toBe('ok');
|
||
expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n');
|
||
expect(git(['show', `${sha}:${SHARED}`]).stdout).toBe('# shared, local edit not queued\n');
|
||
// The printed command brings the local edit back.
|
||
expect(recover(s.message)).toBe(sha!);
|
||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
|
||
});
|
||
|
||
test('a local deletion of a file the remote changed keeps the remote file and stashes the deletion', () => {
|
||
setup();
|
||
peerCommit(SHARED, '# shared from the peer\n');
|
||
fs.rmSync(path.join(tmpHome, SHARED));
|
||
write(CLEAN, '# a plan\n');
|
||
expect(drain().status).toBe(0);
|
||
const s = statusJson();
|
||
expect(s.status).toBe('ok');
|
||
expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n');
|
||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared from the peer\n');
|
||
expect(unmerged()).toBe('');
|
||
expect(s.message).toContain(`local edits to ${SHARED} conflicted`);
|
||
recover(s.message);
|
||
expect(fs.existsSync(path.join(tmpHome, SHARED))).toBe(false);
|
||
});
|
||
|
||
test('a local edit of a file the remote deleted keeps the deletion and stashes the edit', () => {
|
||
setup();
|
||
const peer = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-peer-'));
|
||
try {
|
||
expect(git(['clone', '-q', bareRemote, peer]).status).toBe(0);
|
||
expect(git(['rm', '-q', '--', SHARED], peer).status).toBe(0);
|
||
expect(git(['-c', 'user.name=peer', '-c', 'user.email=peer@example.com', 'commit', '-qm', 'peer rm'], peer).status).toBe(0);
|
||
expect(git(['push', '-q', 'origin', 'HEAD'], peer).status).toBe(0);
|
||
} finally {
|
||
fs.rmSync(peer, { recursive: true, force: true });
|
||
}
|
||
write(SHARED, '# shared, local edit not queued\n', false);
|
||
write(CLEAN, '# a plan\n');
|
||
expect(drain().status).toBe(0);
|
||
const s = statusJson();
|
||
expect(s.status).toBe('ok');
|
||
expect(remoteFiles()).not.toContain(SHARED);
|
||
expect(fs.existsSync(path.join(tmpHome, SHARED))).toBe(false);
|
||
expect(unmerged()).toBe('');
|
||
recover(s.message);
|
||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
|
||
});
|
||
|
||
test('a conflicting file whose name looks like a pattern resets only itself', () => {
|
||
setup();
|
||
const GLOB = '[d]eveloper-profile.json';
|
||
peerCommit(GLOB, '# glob v1\n');
|
||
expect(git(['fetch', '-q', 'origin']).status).toBe(0);
|
||
expect(git(['merge', '-q', '--ff-only', 'origin/main']).status).toBe(0);
|
||
peerCommit(GLOB, '# glob from the peer\n');
|
||
write(GLOB, '# glob, local edit not queued\n', false);
|
||
write(SHARED, '# shared, local edit not queued\n', false);
|
||
write(CLEAN, '# a plan\n');
|
||
expect(drain().status).toBe(0);
|
||
const s = statusJson();
|
||
expect(s.status).toBe('ok');
|
||
expect(fs.readFileSync(path.join(tmpHome, GLOB), 'utf-8')).toBe('# glob from the peer\n');
|
||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
|
||
recover(s.message);
|
||
expect(fs.readFileSync(path.join(tmpHome, GLOB), 'utf-8')).toBe('# glob, local edit not queued\n');
|
||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
|
||
});
|
||
|
||
test('a file that never checks out clean does not strand the other local edits', () => {
|
||
setup();
|
||
const CRLF = 'projects/p/ceo-plans/crlf.md';
|
||
write(CRLF, '# crlf\r\nline\r\n');
|
||
expect(drain().status).toBe(0);
|
||
fs.mkdirSync(path.join(tmpHome, '.git', 'info'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, '.git', 'info', 'attributes'), '*.md text eol=lf\n');
|
||
peerCommit(SHARED, '# shared from the peer\n');
|
||
write(OTHER, '# other, local edit not queued\n', false);
|
||
write(CLEAN, '# a plan\n');
|
||
expect(drain().status).toBe(0);
|
||
expect(statusJson().status).toBe('ok');
|
||
expect(remoteFiles()).toContain(CLEAN);
|
||
expect(fs.readFileSync(path.join(tmpHome, OTHER), 'utf-8')).toBe('# other, local edit not queued\n');
|
||
expect(git(['stash', 'list']).stdout.trim()).toBe('');
|
||
});
|
||
|
||
test('a stash push that saves nothing never pops an older stash entry', () => {
|
||
setup();
|
||
write(OTHER, '# other, older stashed work\n', false);
|
||
expect(git(['stash', 'push', '-q', '-m', 'older work']).status).toBe(0);
|
||
peerCommit(SHARED, '# shared from the peer\n');
|
||
write(SHARED, '# shared, local edit not queued\n', false);
|
||
write(CLEAN, '# a plan\n');
|
||
const shim = gitShim('case " $* " in *" stash push "*) exit 0;; esac');
|
||
expect(drain(shim.env).status).toBe(0);
|
||
expect(statusJson().status).toBe('push_failed');
|
||
expect(git(['stash', 'list']).stdout).toContain('older work');
|
||
expect(fs.readFileSync(path.join(tmpHome, OTHER), 'utf-8')).toBe('# other v1\n');
|
||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
|
||
});
|
||
|
||
test('a conflicting merge is aborted and git\'s reason reaches the status', () => {
|
||
setup();
|
||
peerCommit(SHARED, '# shared from the peer\n');
|
||
write(SHARED, '# shared, local commit\n');
|
||
expect(drain().status).toBe(0);
|
||
const s = statusJson();
|
||
expect(s.status).toBe('push_failed');
|
||
expect(s.message).toContain(`Merge conflict in ${SHARED}`);
|
||
expect(mergeHead()).toBe(false);
|
||
expect(unmerged()).toBe('');
|
||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local commit\n');
|
||
// The next drain is not blocked by a half-finished merge.
|
||
write(OTHER, '# other v2\n');
|
||
expect(drain().status).toBe(0);
|
||
expect(statusJson().message).not.toContain('unmerged');
|
||
expect(statusJson().message).toContain(`Merge conflict in ${SHARED}`);
|
||
});
|
||
});
|
||
|
||
test('a git add failure is reported, never swallowed', () => {
|
||
if (!canRevokeWrites()) return; // chmod is advisory here (win32, root)
|
||
init();
|
||
write(CLEAN, '# a plan\n');
|
||
const unreadable = 'projects/p/ceo-plans/unreadable.md';
|
||
write(unreadable, '# cannot be read\n');
|
||
fs.chmodSync(path.join(tmpHome, unreadable), 0o000);
|
||
try {
|
||
expect(drain().status).toBe(0);
|
||
const s = statusJson();
|
||
expect(s.status).toBe('ok');
|
||
expect(s.message).toContain('git add failed for 1 path(s)');
|
||
expect(s.drainable).toBe(1);
|
||
expect(remoteLog()).toMatch(/sync: 1 file/);
|
||
expect(remoteFiles()).toContain(CLEAN);
|
||
expect(spoolText()).toContain(unreadable);
|
||
drain();
|
||
const s2 = statusJson();
|
||
expect(s2.status).toBe('error');
|
||
expect(s2.message).toContain('git add failed for all 1 path(s)');
|
||
expect(spoolText()).toContain(unreadable);
|
||
} finally {
|
||
fs.chmodSync(path.join(tmpHome, unreadable), 0o600);
|
||
}
|
||
});
|
||
|
||
test('duplicate records for a privacy-held path collapse to the newest one', () => {
|
||
init('artifacts-only');
|
||
write('projects/p/timeline.jsonl', '{"skill":"x","event":"started"}\n', false);
|
||
seedSpool('{"file":"projects/p/timeline.jsonl","ts":"a"}');
|
||
seedSpool('{"file":"projects/p/timeline.jsonl","ts":"b"}');
|
||
const newest = seedSpool('{"file":"projects/p/timeline.jsonl","ts":"c"}');
|
||
expect(drain().status).toBe(0);
|
||
expect(spoolFiles()).toEqual([newest]);
|
||
expect(statusJson().message).toContain('privacy-held retained');
|
||
expect(statusJson().drainable).toBe(0);
|
||
});
|
||
|
||
_test('--status counts a spool too deep for a shell glob', () => {
|
||
init();
|
||
fs.mkdirSync(spoolDir(), { recursive: true });
|
||
// 12,000 names of ~200 bytes overflow ARG_MAX on Linux (2 MiB) and macOS alike.
|
||
const N = 12_000;
|
||
const pad = 'd'.repeat(180);
|
||
for (let i = 0; i < N; i++) {
|
||
fs.writeFileSync(path.join(spoolDir(), `1700000000-1-${pad}${String(i).padStart(6, '0')}.json`), '{"file":"x"}\n');
|
||
}
|
||
expect(run(['gstack-brain-sync', '--status']).stdout).toContain(`"queue_depth":${N}`);
|
||
}, 60_000);
|
||
|
||
describe('git index lock and the shared drain lock', () => {
|
||
const indexLock = () => path.join(tmpHome, '.git', 'index.lock');
|
||
const age = (p: string, ms: number) => { const t = new Date(Date.now() - ms); fs.utimesSync(p, t, t); };
|
||
|
||
test('a fresh index lock is left alone: status error, queue kept', () => {
|
||
init();
|
||
write(CLEAN, '# a plan\n');
|
||
fs.writeFileSync(indexLock(), '');
|
||
expect(drain().status).toBe(0);
|
||
const s = statusJson();
|
||
expect(s.status).toBe('error');
|
||
expect(s.message).toContain('index lock');
|
||
expect(s.drainable).toBe(1);
|
||
expect(fs.existsSync(indexLock())).toBe(true);
|
||
expect(spoolText()).toContain(CLEAN);
|
||
expect(remoteFiles()).not.toContain(CLEAN);
|
||
});
|
||
|
||
test('an index lock older than 10 minutes is cleared under the drain lock and the queue syncs', () => {
|
||
init();
|
||
write(CLEAN, '# a plan\n');
|
||
fs.writeFileSync(indexLock(), '');
|
||
age(indexLock(), 11 * 60_000);
|
||
expect(drain().status).toBe(0);
|
||
expect(fs.existsSync(indexLock())).toBe(false);
|
||
expect(statusJson().status).toBe('ok');
|
||
expect(statusJson().message).toContain('stale git index lock');
|
||
expect(remoteFiles()).toContain(CLEAN);
|
||
});
|
||
|
||
test('an old index lock while another writer holds the drain lock is left alone', () => {
|
||
init();
|
||
write(CLEAN, '# a plan\n');
|
||
const lockDir = path.join(tmpHome, '.brain-sync.lock.d');
|
||
fs.mkdirSync(lockDir);
|
||
fs.writeFileSync(path.join(lockDir, 'pid'), `${process.pid}\n`);
|
||
fs.writeFileSync(indexLock(), '');
|
||
age(indexLock(), 60 * 60_000);
|
||
const statusBefore = fs.existsSync(path.join(tmpHome, '.brain-sync-status.json')) ? statusRaw() : null;
|
||
expect(drain().status).toBe(0);
|
||
expect(fs.existsSync(indexLock())).toBe(true);
|
||
expect(fs.existsSync(path.join(tmpHome, '.brain-sync-status.json')) ? statusRaw() : null).toBe(statusBefore);
|
||
expect(spoolText()).toContain(CLEAN);
|
||
expect(fs.readFileSync(path.join(lockDir, 'pid'), 'utf-8').trim()).toBe(String(process.pid));
|
||
});
|
||
|
||
_test('a concurrent writer: init waits for the drain lock, and gives up after the wait', async () => {
|
||
init();
|
||
const holder = Bun.spawn(['bash', '-c', `. '${path.join(BIN, 'gstack-brain-lock.sh')}'; gstack_brain_lock_acquire "$1" try || exit 9; echo held; sleep 3; gstack_brain_lock_release`, '_', tmpHome], { stdout: 'pipe' });
|
||
const reader = holder.stdout.getReader();
|
||
expect(new TextDecoder().decode((await reader.read()).value)).toContain('held');
|
||
// A drain skips while the lock is held; init with no wait budget refuses.
|
||
write(CLEAN, '# a plan\n');
|
||
expect(drain().status).toBe(0);
|
||
expect(spoolText()).toContain(CLEAN);
|
||
const refused = run(['gstack-artifacts-init', '--remote', bareRemote], { env: { GSTACK_BRAIN_LOCK_WAIT: '0' } });
|
||
expect(refused.status).toBe(1);
|
||
expect(refused.stderr).toContain('another gstack sync');
|
||
// With a wait budget, init runs once the holder releases.
|
||
const waited = Bun.spawn([path.join(BIN, 'gstack-artifacts-init'), '--remote', bareRemote], {
|
||
env: { ...process.env, HOME: tmpHome, GSTACK_HOME: tmpHome, GSTACK_BRAIN_LOCK_WAIT: '20' }, stdout: 'pipe', stderr: 'pipe',
|
||
});
|
||
expect(await waited.exited).toBe(0);
|
||
expect(await holder.exited).toBe(0);
|
||
expect(fs.existsSync(path.join(tmpHome, '.brain-sync.lock.d'))).toBe(false);
|
||
expect(drain().status).toBe(0);
|
||
expect(remoteFiles()).toContain(CLEAN);
|
||
}, 60_000);
|
||
});
|
||
|
||
describe('commit failures keep the queue', () => {
|
||
for (const [name, hook] of [['a rejecting pre-commit hook', 'pre-commit'], ['a commit failure after staging (commit-msg)', 'commit-msg']]) {
|
||
test(name, () => {
|
||
init();
|
||
write(CLEAN, '# a plan\n');
|
||
const hookPath = path.join(tmpHome, '.git', 'hooks', hook);
|
||
const original = fs.existsSync(hookPath) ? fs.readFileSync(hookPath) : null;
|
||
fs.writeFileSync(hookPath, '#!/bin/sh\necho "refusing this commit" >&2\nexit 1\n', { mode: 0o755 });
|
||
const before = git(['rev-list', '--count', 'HEAD']).stdout.trim();
|
||
expect(drain().status).toBe(0);
|
||
const s = statusJson();
|
||
expect(s.status).toBe('error');
|
||
expect(s.message).toContain('git commit failed');
|
||
expect(s.drainable).toBe(1);
|
||
expect(git(['rev-list', '--count', 'HEAD']).stdout.trim()).toBe(before);
|
||
expect(staged()).toBe('');
|
||
expect(spoolText()).toContain(CLEAN);
|
||
if (original) fs.writeFileSync(hookPath, original); else fs.rmSync(hookPath);
|
||
drain();
|
||
expect(statusJson().status).toBe('ok');
|
||
expect(remoteFiles()).toContain(CLEAN);
|
||
});
|
||
}
|
||
});
|
||
|
||
test('a push that keeps failing stays push_failed on the empty-queue runs after it', () => {
|
||
init();
|
||
write(CLEAN, '# a plan\n');
|
||
drain();
|
||
const hook = path.join(bareRemote, 'hooks', 'pre-receive');
|
||
fs.writeFileSync(hook, '#!/bin/sh\necho "403 forbidden" >&2\nexit 1\n', { mode: 0o755 });
|
||
write(CLEAN, '# a plan, revised\n');
|
||
drain();
|
||
expect(statusJson().status).toBe('push_failed');
|
||
// The queue is empty now and the detector retry is throttled: still failed.
|
||
drain();
|
||
expect(spoolFiles()).toEqual([]);
|
||
expect(statusJson().status).toBe('push_failed');
|
||
expect(statusJson().message).toContain('not pushed yet');
|
||
});
|
||
|
||
describe('coupled groups (decision log and its active snapshot)', () => {
|
||
test('a clean update publishes the group in one commit', () => {
|
||
init();
|
||
writeDecisions(['a']);
|
||
drain();
|
||
writeDecisions(['a', 'bb']);
|
||
drain();
|
||
expect(statusJson().status).toBe('ok');
|
||
const last = spawnSync('git', ['--git-dir=' + bareRemote, 'show', '--name-only', '--format=', 'main'], { encoding: 'utf-8', timeout: 30_000 }).stdout;
|
||
expect(last).toContain(LOG);
|
||
expect(last).toContain(SNAP);
|
||
expect(remoteShow(SNAP).stdout).toContain('"bb"');
|
||
});
|
||
|
||
test('a generation whose snapshot does not match its log waits, then publishes whole', () => {
|
||
init();
|
||
writeDecisions(['a']);
|
||
drain();
|
||
// The log gained a decision; the snapshot is not rebuilt yet.
|
||
writeDecisions(['a', 'bb'], ['a']);
|
||
drain();
|
||
const s = statusJson();
|
||
expect(s.status).toBe('idle');
|
||
expect(s.drainable).toBe(2);
|
||
expect(s.message).toContain('consistent generation');
|
||
expect(remoteShow(LOG).stdout).not.toContain('"bb"');
|
||
expect(spoolText()).toContain(LOG);
|
||
writeDecisions(['a', 'bb']);
|
||
drain();
|
||
expect(statusJson().status).toBe('ok');
|
||
expect(remoteShow(LOG).stdout).toContain('"bb"');
|
||
expect(remoteShow(SNAP).stdout).toContain('"bb"');
|
||
});
|
||
|
||
test('a held snapshot holds its log; a second machine restores a consistent generation', async () => {
|
||
init();
|
||
writeDecisions(['a']);
|
||
drain();
|
||
writeDecisions(['a', 'bb'], ['a', 'bb'], TOKEN);
|
||
drain();
|
||
const s = statusJson();
|
||
expect(s.status).toBe('held');
|
||
expect(s.held).toHaveLength(1);
|
||
expect(s.held[0].path).toBe(SNAP);
|
||
expect(s.held[0].dependents).toEqual([LOG]);
|
||
expect(s.held_count).toBe(2);
|
||
expect(s.held[0].fixes[1]).toContain('the files coupled with it');
|
||
expect(remoteShow(LOG).stdout).not.toContain('"bb"');
|
||
expect(spoolText()).toContain(LOG);
|
||
|
||
const machineB = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-machineB-'));
|
||
try {
|
||
expect(run(['gstack-brain-restore', bareRemote], { env: { GSTACK_HOME: machineB } }).status).toBe(0);
|
||
const { decisionPaths, readSnapshot, readEvents, computeActive } = await import('../lib/gstack-decision');
|
||
const paths = decisionPaths('p', machineB);
|
||
const fromSnapshot = readSnapshot(paths).map((d) => d.id).sort();
|
||
const fromLog = computeActive(readEvents(paths)).map((d) => d.id).sort();
|
||
expect(fromSnapshot).toEqual(['a']);
|
||
expect(fromLog).toEqual(fromSnapshot);
|
||
} finally {
|
||
fs.rmSync(machineB, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('skipping the blocking path stops the whole group; unskipping publishes it whole', () => {
|
||
init();
|
||
writeDecisions(['a']);
|
||
drain();
|
||
writeDecisions(['a', 'bb'], ['a', 'bb'], TOKEN);
|
||
drain();
|
||
expect(statusJson().status).toBe('held');
|
||
run(['gstack-brain-sync', '--skip-file', SNAP]);
|
||
drain();
|
||
expect(statusJson().held_count).toBe(0);
|
||
expect(spoolFiles()).toEqual([]);
|
||
expect(remoteShow(LOG).stdout).not.toContain('"bb"');
|
||
// Another decision while the group is skipped still never publishes the log alone.
|
||
writeDecisions(['a', 'bb', 'ccc'], ['a', 'bb', 'ccc'], TOKEN);
|
||
drain();
|
||
expect(remoteShow(LOG).stdout).not.toContain('"ccc"');
|
||
|
||
writeDecisions(['a', 'bb', 'ccc'], ['a', 'bb', 'ccc'], 'clean');
|
||
run(['gstack-brain-sync', '--unskip-file', SNAP]);
|
||
drain();
|
||
expect(statusJson().status).toBe('ok');
|
||
const last = spawnSync('git', ['--git-dir=' + bareRemote, 'show', '--name-only', '--format=', 'main'], { encoding: 'utf-8', timeout: 30_000 }).stdout;
|
||
expect(last).toContain(LOG);
|
||
expect(last).toContain(SNAP);
|
||
expect(remoteShow(LOG).stdout).toContain('"ccc"');
|
||
expect(remoteShow(SNAP).stdout).toContain('"ccc"');
|
||
});
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------
|
||
// Egress receipt gate: receipt-before-commit, queue intact on refusal
|
||
// ---------------------------------------------------------------
|
||
describe('gstack-brain-sync egress receipt gate', () => {
|
||
test('refused receipt leaves the queue intact, makes no commit, and next run retries', () => {
|
||
if (!canRevokeWrites()) return; // chmod is advisory here (win32, root, DAC-override containers)
|
||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'),
|
||
'{"skill":"x","insight":"y","ts":"2026-04-22T10:00:00Z"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
const commitsBefore = git(['rev-list', '--count', 'HEAD']).stdout.trim();
|
||
|
||
// Make the receipt unwritable: security dir exists but is read-only.
|
||
// (artifacts-init may have created it already — mkdirSync's mode is a
|
||
// no-op on an existing dir, so chmod explicitly.)
|
||
fs.mkdirSync(path.join(tmpHome, 'security'), { recursive: true });
|
||
fs.chmodSync(path.join(tmpHome, 'security'), 0o500);
|
||
try {
|
||
const refused = run(['gstack-brain-sync', '--once']);
|
||
expect(refused.status).toBe(1);
|
||
// DX contract: problem + cause + fix, plain language.
|
||
expect(refused.stderr).toContain('NOT sent');
|
||
expect(refused.stderr).toContain('EGRESS_RECEIPT_FAILED');
|
||
expect(refused.stderr).toContain('Fix: chmod -R u+w');
|
||
expect(refused.stderr).toContain('ATTEMPTS to send off-machine');
|
||
// Spool intact (receipt is written BEFORE finalize consumes records).
|
||
expect(spoolText()).toContain('projects/p/learnings.jsonl');
|
||
// No local commit was created.
|
||
expect(git(['rev-list', '--count', 'HEAD']).stdout.trim()).toBe(commitsBefore);
|
||
// Nothing reached the remote.
|
||
const remoteLog = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8', timeout: 30_000 });
|
||
expect(remoteLog.stdout).not.toMatch(/sync: 1 file/);
|
||
const status = JSON.parse(fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8'));
|
||
expect(status.status).toBe('push_failed');
|
||
expect(status.message).toContain('EGRESS_RECEIPT_FAILED');
|
||
} finally {
|
||
fs.chmodSync(path.join(tmpHome, 'security'), 0o700);
|
||
}
|
||
|
||
// Next run (ledger writable again) drains the intact queue and pushes.
|
||
const retry = run(['gstack-brain-sync', '--once']);
|
||
expect(retry.status).toBe(0);
|
||
const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8', timeout: 30_000 });
|
||
expect(log.stdout).toMatch(/sync: 1 file/);
|
||
});
|
||
|
||
test('successful push writes a git-class receipt before the send', () => {
|
||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'),
|
||
'{"skill":"x","insight":"y","ts":"2026-04-22T10:00:00Z"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
const r = run(['gstack-brain-sync', '--once']);
|
||
expect(r.status).toBe(0);
|
||
const ledger = fs.readFileSync(path.join(tmpHome, 'security', 'egress.jsonl'), 'utf-8');
|
||
const records = ledger.trim().split('\n').map((l) => JSON.parse(l));
|
||
const pushReceipt = records.find((rec) => rec.sink === 'brain-sync' && rec.payload_class === 'curated-memory-git-push');
|
||
expect(pushReceipt).toBeTruthy();
|
||
expect(pushReceipt.sha256).toBeNull(); // git owns the bytes
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------
|
||
// Uninstall preserves user data
|
||
// ---------------------------------------------------------------
|
||
describe('gstack-brain-uninstall', () => {
|
||
test('removes sync config but preserves learnings/project data', () => {
|
||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'user-data'), { recursive: true });
|
||
const preservedContent = '{"keep":"me","ts":"2026-04-22T12:00:00Z"}\n';
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/user-data/learnings.jsonl'), preservedContent);
|
||
const r = run(['gstack-brain-uninstall', '--yes']);
|
||
expect(r.status).toBe(0);
|
||
expect(fs.existsSync(path.join(tmpHome, '.git'))).toBe(false);
|
||
expect(fs.existsSync(path.join(tmpHome, '.gitignore'))).toBe(false);
|
||
expect(fs.existsSync(path.join(tmpHome, '.brain-allowlist'))).toBe(false);
|
||
expect(fs.existsSync(path.join(tmpHome, 'consumers.json'))).toBe(false);
|
||
// Project data preserved.
|
||
const preserved = fs.readFileSync(path.join(tmpHome, 'projects/user-data/learnings.jsonl'), 'utf-8');
|
||
expect(preserved).toBe(preservedContent);
|
||
// Config key reset.
|
||
const mode = run(['gstack-config', 'get', 'artifacts_sync_mode']);
|
||
expect(mode.stdout.trim()).toBe('off');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------
|
||
// --discover-new: cursor-based change detection
|
||
// ---------------------------------------------------------------
|
||
describe('gstack-brain-sync --discover-new', () => {
|
||
test('enqueues new allowlisted files as spool records; idempotent on re-run', () => {
|
||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
|
||
run(['gstack-brain-sync', '--discover-new']);
|
||
expect(spoolText()).toContain('retros/week-1.md');
|
||
// Clear the spool, run again — idempotent (no new records).
|
||
for (const f of spoolFiles()) fs.unlinkSync(path.join(spoolDir(), f));
|
||
run(['gstack-brain-sync', '--discover-new']);
|
||
expect(spoolFiles().length).toBe(0);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------
|
||
// Enqueue tmp janitor: a writer killed between its tmp write and the
|
||
// atomic rename orphans a .tmp-* file forever (it never becomes a
|
||
// record, nothing else touches it). The drain reaps ones older than
|
||
// 1 hour, inside its lock; fresh ones (in-flight enqueues) survive.
|
||
// ---------------------------------------------------------------
|
||
describe('enqueue tmp janitor', () => {
|
||
test('an orphaned .tmp-* older than 1h is reaped on --once; a fresh one survives', () => {
|
||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||
fs.mkdirSync(spoolDir(), { recursive: true });
|
||
|
||
const oldTmp = path.join(spoolDir(), '.tmp-99999-x1');
|
||
fs.writeFileSync(oldTmp, '{"file":"projects/p/learnings.jsonl"}\n');
|
||
const past = new Date(Date.now() - 2 * 3600 * 1000);
|
||
fs.utimesSync(oldTmp, past, past);
|
||
|
||
const freshTmp = path.join(spoolDir(), '.tmp-99999-x2');
|
||
fs.writeFileSync(freshTmp, '{"file":"projects/p/learnings.jsonl"}\n');
|
||
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
expect(fs.existsSync(oldTmp)).toBe(false); // orphan reaped
|
||
expect(fs.existsSync(freshTmp)).toBe(true); // in-flight write untouched
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------
|
||
// #2549 queue integrity: classified drops, privacy retention,
|
||
// surgical rewrite, unpushed-commit detector
|
||
// ---------------------------------------------------------------
|
||
describe('#2549 queue integrity', () => {
|
||
function initWithMode(mode: string) {
|
||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||
run(['gstack-config', 'set', 'artifacts_sync_mode', mode]);
|
||
}
|
||
const statusJson = () => JSON.parse(fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8'));
|
||
|
||
test('privacy-held entries are RETAINED and classified, not wiped as "no allowlisted changes"', () => {
|
||
// timeline.jsonl is class=behavioral; artifacts-only mode holds it.
|
||
initWithMode('artifacts-only');
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/timeline.jsonl'), '{"skill":"x","event":"started"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/timeline.jsonl']);
|
||
const r = run(['gstack-brain-sync', '--once']);
|
||
expect(r.status).toBe(0);
|
||
// The exact #2549 repro: the old code truncated the queue here and said
|
||
// "no allowlisted changes in queue". The record must survive, and the
|
||
// status must attribute the hold honestly.
|
||
expect(spoolText()).toContain('projects/p/timeline.jsonl');
|
||
const s = statusJson();
|
||
expect(s.status).toBe('idle');
|
||
expect(s.message).toContain('privacy-held retained');
|
||
expect(s.message).not.toContain('no allowlisted changes');
|
||
});
|
||
|
||
test('unmatched and missing entries drop WITH counts and a 0600 drops sidecar', () => {
|
||
initWithMode('full');
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
// Unmatched: no allowlist glob covers .txt scratch files.
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/scratch.txt'), 'x\n');
|
||
seedSpool('{"file":"projects/p/scratch.txt"}');
|
||
// Missing: allowlisted name that does not exist on disk.
|
||
seedSpool('{"file":"projects/p/learnings.jsonl"}');
|
||
const r = run(['gstack-brain-sync', '--once']);
|
||
expect(r.status).toBe(0);
|
||
expect(spoolText()).not.toContain('scratch.txt');
|
||
expect(spoolText()).not.toContain('learnings.jsonl');
|
||
const s = statusJson();
|
||
expect(s.message).toContain('1 unmatched dropped');
|
||
expect(s.message).toContain('1 missing dropped');
|
||
const drops = path.join(tmpHome, '.brain-sync-drops.json');
|
||
expect(fs.existsSync(drops)).toBe(true);
|
||
if (process.platform !== 'win32') {
|
||
expect(fs.statSync(drops).mode & 0o777).toBe(0o600);
|
||
}
|
||
const detail = JSON.parse(fs.readFileSync(drops, 'utf-8'));
|
||
expect(detail.dropped.unmatched).toContain('projects/p/scratch.txt');
|
||
expect(detail.dropped.missing).toContain('projects/p/learnings.jsonl');
|
||
});
|
||
|
||
test('an unparseable legacy queue line migrates as-is and is quarantined, never destroyed', () => {
|
||
// The line lands in the legacy single-file queue (pre-spool writer);
|
||
// migration converts it verbatim to a spool record, and the drain moves
|
||
// what it cannot parse into quarantine (never deletes it, and never
|
||
// leaves it re-warning at every boundary).
|
||
initWithMode('full');
|
||
fs.appendFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'not json at all\n');
|
||
const r = run(['gstack-brain-sync', '--once']);
|
||
expect(r.status).toBe(0);
|
||
const qDir = path.join(spoolDir(), 'quarantine');
|
||
expect(fs.existsSync(qDir)).toBe(true);
|
||
const qFiles = fs.readdirSync(qDir);
|
||
expect(qFiles.length).toBe(1);
|
||
expect(fs.readFileSync(path.join(qDir, qFiles[0]), 'utf-8')).toContain('not json at all');
|
||
});
|
||
|
||
test('finalize: a synced record leaves the spool while a held sibling survives the same drain', () => {
|
||
// Proves finalize is a per-record delete, not a truncation: two records
|
||
// drain in one --once, one stages+pushes, one is mode-held.
|
||
initWithMode('artifacts-only');
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","insight":"y","ts":"2026-01-01T00:00:00Z"}\n');
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/timeline.jsonl'), '{"skill":"x","event":"started"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
run(['gstack-brain-enqueue', 'projects/p/timeline.jsonl']);
|
||
const r = run(['gstack-brain-sync', '--once']);
|
||
expect(r.status).toBe(0);
|
||
expect(spoolText()).not.toContain('learnings.jsonl'); // synced, removed
|
||
expect(spoolText()).toContain('timeline.jsonl'); // held, retained
|
||
const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8', timeout: 30_000 });
|
||
expect(log.stdout).toMatch(/sync: 1 file/);
|
||
});
|
||
|
||
test('push failure retains the commit locally and the run-start detector re-pushes it', () => {
|
||
initWithMode('full');
|
||
// Establish origin/main so the detector has a remote ref to compare.
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"a","ts":"2026-01-01T00:00:00Z"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
|
||
// Reject the next push at the remote (pre-receive hook exits 1 with an
|
||
// auth-shaped message so the auth branch is exercised too).
|
||
const hook = path.join(bareRemote, 'hooks', 'pre-receive');
|
||
fs.writeFileSync(hook, '#!/bin/sh\necho "403 forbidden" >&2\nexit 1\n');
|
||
fs.chmodSync(hook, 0o755);
|
||
|
||
fs.appendFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"b","ts":"2026-01-02T00:00:00Z"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
const fail = run(['gstack-brain-sync', '--once']);
|
||
expect(fail.status).toBe(0);
|
||
const s = statusJson();
|
||
expect(s.status).toBe('push_failed');
|
||
expect(s.message).toContain('commit retained locally');
|
||
// Drained record left the spool — it lives in the local commit now.
|
||
expect(spoolText()).not.toContain('learnings.jsonl');
|
||
// The commit exists locally, ahead of origin.
|
||
const ahead = git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim();
|
||
expect(Number(ahead)).toBeGreaterThan(0);
|
||
|
||
// Remote healthy again: an EMPTY-queue run must still deliver the
|
||
// stranded commit (the detector, not the drain, pushes it).
|
||
fs.rmSync(hook);
|
||
const retry = run(['gstack-brain-sync', '--once']);
|
||
expect(retry.status).toBe(0);
|
||
const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8', timeout: 30_000 });
|
||
expect(log.stdout).toMatch(/sync: 1 file/);
|
||
expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0');
|
||
});
|
||
|
||
test('receipt refusal at the detector skips the retry without wedging the drain', () => {
|
||
if (!canRevokeWrites()) return; // chmod is advisory here (win32, root, DAC-override containers)
|
||
initWithMode('full');
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"a","ts":"2026-01-01T00:00:00Z"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
|
||
// Strand a commit: reject pushes, drain once.
|
||
const hook = path.join(bareRemote, 'hooks', 'pre-receive');
|
||
fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n');
|
||
fs.chmodSync(hook, 0o755);
|
||
fs.appendFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"b","ts":"2026-01-02T00:00:00Z"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
fs.rmSync(hook);
|
||
|
||
// Break receipts. The detector's retry must be SKIPPED (no wedge), and
|
||
// the run must still exit 0 with nothing else to do.
|
||
fs.mkdirSync(path.join(tmpHome, 'security'), { recursive: true });
|
||
fs.chmodSync(path.join(tmpHome, 'security'), 0o500);
|
||
try {
|
||
const r = run(['gstack-brain-sync', '--once']);
|
||
expect(r.status).toBe(0);
|
||
// Commit still stranded (retry skipped, not attempted unreceipted).
|
||
expect(Number(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim())).toBeGreaterThan(0);
|
||
} finally {
|
||
fs.chmodSync(path.join(tmpHome, 'security'), 0o700);
|
||
}
|
||
|
||
// Receipts healthy: detector delivers. The refused attempt above stamped
|
||
// the 10-minute throttle (deliberately — refusals must not busy-loop the
|
||
// network at every skill boundary), so model the interval passing.
|
||
fs.writeFileSync(path.join(tmpHome, '.brain-last-push-attempt'), '0');
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0');
|
||
});
|
||
|
||
test('detector attempts are throttled to one per interval', () => {
|
||
initWithMode('full');
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"a","ts":"2026-01-01T00:00:00Z"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
|
||
// Strand a commit behind a rejecting remote.
|
||
const hook = path.join(bareRemote, 'hooks', 'pre-receive');
|
||
fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n');
|
||
fs.chmodSync(hook, 0o755);
|
||
fs.appendFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"b","ts":"2026-01-02T00:00:00Z"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
fs.rmSync(hook);
|
||
|
||
// First empty-queue run: detector attempts (stamps the throttle), pushes.
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
const stamp1 = fs.readFileSync(path.join(tmpHome, '.brain-last-push-attempt'), 'utf-8');
|
||
expect(Number(stamp1)).toBeGreaterThan(0);
|
||
expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0');
|
||
|
||
// Strand another; an immediate second run must NOT attempt (stamp fresh).
|
||
fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n');
|
||
fs.chmodSync(hook, 0o755);
|
||
fs.appendFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"c","ts":"2026-01-03T00:00:00Z"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
fs.rmSync(hook);
|
||
const stampBefore = fs.readFileSync(path.join(tmpHome, '.brain-last-push-attempt'), 'utf-8');
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
// Throttled: stamp unchanged, commit still stranded.
|
||
expect(fs.readFileSync(path.join(tmpHome, '.brain-last-push-attempt'), 'utf-8')).toBe(stampBefore);
|
||
expect(Number(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim())).toBeGreaterThan(0);
|
||
|
||
// Interval passed: delivers.
|
||
fs.writeFileSync(path.join(tmpHome, '.brain-last-push-attempt'), '0');
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0');
|
||
});
|
||
|
||
test('an interleaved user commit disables the detector push (exclusive author gate)', () => {
|
||
initWithMode('full');
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"a","ts":"2026-01-01T00:00:00Z"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
|
||
// Strand a bot commit behind a rejecting remote.
|
||
const hook = path.join(bareRemote, 'hooks', 'pre-receive');
|
||
fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n');
|
||
fs.chmodSync(hook, 0o755);
|
||
fs.appendFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"b","ts":"2026-01-02T00:00:00Z"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
fs.rmSync(hook);
|
||
|
||
// A user manually commits in ~/.gstack on top of the stranded bot commit.
|
||
expect(git(['-c', 'user.name=Garry', '-c', 'user.email=garry@example.com',
|
||
'-c', 'commit.gpgsign=false',
|
||
'commit', '--allow-empty', '-m', 'manual note']).status).toBe(0);
|
||
|
||
// Interval passed, remote healthy, queue empty: the detector must STILL
|
||
// refuse — `push origin HEAD` would publish the user's commit uninvited.
|
||
fs.writeFileSync(path.join(tmpHome, '.brain-last-push-attempt'), '0');
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
expect(Number(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim())).toBe(2);
|
||
|
||
// A REAL drain still rides the user commit along, as before — the gate
|
||
// scopes only the detector's autonomous retry, not user-initiated syncs.
|
||
fs.appendFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"c","ts":"2026-01-03T00:00:00Z"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------
|
||
// C12 spool queue: per-record files kill the enqueue/drain race.
|
||
// One FILE per record under .brain-queue.d/ — writer and drainer never
|
||
// share an inode, so the lockless append-vs-rewrite race is structurally
|
||
// gone. Crash semantics are at-least-once (unfinalized records re-drain).
|
||
// ---------------------------------------------------------------
|
||
describe('C12 spool queue', () => {
|
||
function initWithMode(mode: string) {
|
||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||
run(['gstack-config', 'set', 'artifacts_sync_mode', mode]);
|
||
}
|
||
const remoteLog = () =>
|
||
spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8', timeout: 30_000 }).stdout;
|
||
|
||
test('two rapid enqueues of different paths create two spool files; one drain syncs both', () => {
|
||
initWithMode('full');
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
||
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
run(['gstack-brain-enqueue', 'retros/week-1.md']);
|
||
expect(spoolFiles().length).toBe(2);
|
||
const r = run(['gstack-brain-sync', '--once']);
|
||
expect(r.status).toBe(0);
|
||
expect(spoolFiles().length).toBe(0);
|
||
expect(remoteLog()).toMatch(/sync: 2 file/);
|
||
});
|
||
|
||
test('a record created after a drain survives untouched and drains on the NEXT --once', () => {
|
||
// Structural form of the concurrent-append test: finalize deletes only
|
||
// snapshot-manifest files, so a record the drain never listed cannot be
|
||
// touched — whether it lands mid-drain or after.
|
||
initWithMode('full');
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"a","ts":"2026-01-01T00:00:00Z"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
expect(spoolFiles().length).toBe(0);
|
||
// New record arrives (a writer that raced the previous drain).
|
||
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
|
||
run(['gstack-brain-enqueue', 'retros/week-1.md']);
|
||
const [pending] = spoolFiles();
|
||
expect(pending).toBeTruthy();
|
||
const pendingContent = fs.readFileSync(path.join(spoolDir(), pending), 'utf-8');
|
||
expect(pendingContent).toContain('retros/week-1.md');
|
||
// Untouched by the completed drain; the NEXT drain delivers it.
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
expect(spoolFiles().length).toBe(0);
|
||
expect(remoteLog()).toMatch(/sync: 1 file/);
|
||
});
|
||
|
||
test('at-least-once: a drain that fails before finalize leaves every spool file for the next run', () => {
|
||
if (!canRevokeWrites()) return; // chmod is advisory here (win32, root, DAC-override containers)
|
||
initWithMode('full');
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"a","ts":"2026-01-01T00:00:00Z"}\n');
|
||
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
run(['gstack-brain-enqueue', 'retros/week-1.md']);
|
||
const seeded = spoolFiles();
|
||
expect(seeded.length).toBe(2);
|
||
|
||
// Break the egress-receipt ledger: the drain fails AFTER staging but
|
||
// BEFORE any commit or finalize — simulating a crash mid-drain.
|
||
fs.mkdirSync(path.join(tmpHome, 'security'), { recursive: true });
|
||
fs.chmodSync(path.join(tmpHome, 'security'), 0o500);
|
||
try {
|
||
const refused = run(['gstack-brain-sync', '--once']);
|
||
expect(refused.status).toBe(1);
|
||
// The exact same spool files are still present — nothing consumed.
|
||
expect(spoolFiles()).toEqual(seeded);
|
||
} finally {
|
||
fs.chmodSync(path.join(tmpHome, 'security'), 0o700);
|
||
}
|
||
|
||
// Next run re-drains the surviving records.
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
expect(spoolFiles().length).toBe(0);
|
||
expect(remoteLog()).toMatch(/sync: 2 file/);
|
||
});
|
||
|
||
test('legacy migration: .brain-queue.jsonl lines convert to spool records, nothing lost', () => {
|
||
// Pre-spool writers appended to the single-file queue. Three lines: two
|
||
// stageable artifacts, one behavioral (mode-held under artifacts-only).
|
||
initWithMode('artifacts-only');
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
||
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/timeline.jsonl'), '{"skill":"x","event":"started"}\n');
|
||
fs.writeFileSync(path.join(tmpHome, '.brain-queue.jsonl'),
|
||
'{"file":"projects/p/learnings.jsonl","ts":"2026-01-01T00:00:00Z"}\n' +
|
||
'{"file":"retros/week-1.md","ts":"2026-01-01T00:00:01Z"}\n' +
|
||
'{"file":"projects/p/timeline.jsonl","ts":"2026-01-01T00:00:02Z"}\n');
|
||
const r = run(['gstack-brain-sync', '--once']);
|
||
expect(r.status).toBe(0);
|
||
// Legacy file consumed; no .migrating remnant.
|
||
expect(fs.existsSync(path.join(tmpHome, '.brain-queue.jsonl'))).toBe(false);
|
||
expect(fs.existsSync(path.join(tmpHome, '.brain-queue.jsonl.migrating'))).toBe(false);
|
||
// Both artifacts synced; the behavioral record survives as a spool file.
|
||
expect(remoteLog()).toMatch(/sync: 2 file/);
|
||
expect(spoolText()).toContain('projects/p/timeline.jsonl');
|
||
expect(spoolText()).not.toContain('learnings.jsonl');
|
||
});
|
||
|
||
test('an unparseable spool record is quarantined with a warning; the drain continues', () => {
|
||
initWithMode('full');
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
const badFile = seedSpool('this is not json');
|
||
const r = run(['gstack-brain-sync', '--once']);
|
||
expect(r.status).toBe(0);
|
||
expect(r.stderr).toContain('unparseable');
|
||
// The good sibling synced; the unreadable record was never destroyed —
|
||
// it moved to quarantine so it stops re-warning at every boundary.
|
||
expect(remoteLog()).toMatch(/sync: 1 file/);
|
||
expect(spoolFiles()).toEqual([]);
|
||
const qPath = path.join(spoolDir(), 'quarantine', badFile);
|
||
expect(fs.existsSync(qPath)).toBe(true);
|
||
expect(fs.readFileSync(qPath, 'utf-8')).toContain('this is not json');
|
||
});
|
||
|
||
test('--status queue_depth counts spool records plus unmigrated legacy lines', () => {
|
||
initWithMode('full');
|
||
seedSpool('{"file":"projects/p/a.jsonl","ts":"t"}');
|
||
seedSpool('{"file":"projects/p/b.jsonl","ts":"t"}');
|
||
fs.writeFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '{"file":"projects/p/c.jsonl","ts":"t"}\n');
|
||
const r = run(['gstack-brain-sync', '--status']);
|
||
expect(r.status).toBe(0);
|
||
const supplemental = JSON.parse(r.stdout.trim().split('\n').pop()!);
|
||
expect(supplemental.queue_depth).toBe(3);
|
||
});
|
||
|
||
test('G1: a malformed pulled privacy map (["bad"]) holds the queue — warns, deletes NOTHING, next run re-drains', () => {
|
||
// Remotely triggerable kill vector: the privacy map arrives via the
|
||
// artifacts-repo pull. A non-dict entry used to raise mid-classification
|
||
// AFTER the snapshot manifest was written, and the old finalize polarity
|
||
// ("delete unless retained") then unlinked EVERY snapshotted record.
|
||
initWithMode('full');
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
const seeded = spoolFiles();
|
||
expect(seeded.length).toBe(1);
|
||
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '["bad"]');
|
||
|
||
const r = run(['gstack-brain-sync', '--once']);
|
||
expect(r.status).toBe(0);
|
||
expect(r.stderr).toContain('privacy map');
|
||
// Zero records deleted; nothing pushed.
|
||
expect(spoolFiles()).toEqual(seeded);
|
||
expect(remoteLog()).not.toMatch(/sync:/);
|
||
|
||
// Fix the map: the surviving queue re-drains and syncs.
|
||
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '[]');
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
expect(spoolFiles().length).toBe(0);
|
||
expect(remoteLog()).toMatch(/sync: 1 file/);
|
||
});
|
||
|
||
test('G1: a classifier that dies AFTER the snapshot write consumes nothing (call-site exit check + explicit-delete finalize)', () => {
|
||
// A dict entry with a non-string pattern passes the shape filter but
|
||
// raises inside fnmatch DURING classification — the post-manifest crash
|
||
// window (same shape as ENOSPC/OOM mid-run). The call site must see the
|
||
// nonzero exit, warn, skip finalize, and leave everything queued.
|
||
initWithMode('full');
|
||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||
const seeded = spoolFiles();
|
||
expect(seeded.length).toBe(1);
|
||
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '[{"pattern": 123}]');
|
||
|
||
const r = run(['gstack-brain-sync', '--once']);
|
||
expect(r.status).toBe(0);
|
||
expect(r.stderr).toContain('classification failed');
|
||
expect(spoolFiles()).toEqual(seeded); // zero records deleted
|
||
expect(remoteLog()).not.toMatch(/sync:/);
|
||
const status = JSON.parse(fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8'));
|
||
expect(status.status).toBe('error');
|
||
expect(status.message).toContain('queue preserved');
|
||
|
||
// Fix the map: the surviving queue re-drains and syncs.
|
||
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '[]');
|
||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||
expect(spoolFiles().length).toBe(0);
|
||
expect(remoteLog()).toMatch(/sync: 1 file/);
|
||
});
|
||
|
||
test('G1: finalize is explicit-delete-only and the fast path is .migrating-aware (static pins)', () => {
|
||
const src = fs.readFileSync(path.join(BIN, 'gstack-brain-sync'), 'utf-8');
|
||
// The compute call site checks the python exit status before finalizing.
|
||
expect(src).toMatch(/if ! compute_paths_to_stage /);
|
||
// finalize_queue takes the staged-paths file and deletes only staged ∪ dropped.
|
||
expect(src).toContain('deletable = staged | dropped');
|
||
expect(src).toContain('if p not in deletable:');
|
||
// The empty fast path also treats a leftover .migrating file as non-idle.
|
||
expect(src).toMatch(/spool_has_records && \[ ! -s "\$QUEUE" \] && \[ ! -s "\$QUEUE\.migrating" \]/);
|
||
});
|
||
|
||
test('--drop-queue keeps the --yes gate and counts spool + legacy entries', () => {
|
||
initWithMode('full');
|
||
seedSpool('{"file":"projects/p/a.jsonl","ts":"t"}');
|
||
seedSpool('{"file":"projects/p/b.jsonl","ts":"t"}');
|
||
fs.writeFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '{"file":"projects/p/c.jsonl","ts":"t"}\n');
|
||
const refused = run(['gstack-brain-sync', '--drop-queue']);
|
||
expect(refused.status).toBe(1);
|
||
expect(refused.stderr).toContain('--yes');
|
||
expect(spoolFiles().length).toBe(2);
|
||
const dropped = run(['gstack-brain-sync', '--drop-queue', '--yes']);
|
||
expect(dropped.status).toBe(0);
|
||
expect(dropped.stdout).toContain('dropped 3 queue entries');
|
||
expect(spoolFiles().length).toBe(0);
|
||
expect(fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8')).toBe('');
|
||
const again = run(['gstack-brain-sync', '--drop-queue', '--yes']);
|
||
expect(again.stdout).toContain('queue already empty');
|
||
});
|
||
});
|