mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
Three verified-open defects, one family: sync silently destroying or diverging on content it should preserve. #2404 (P0) — 'ops' was hardcoded in PRUNE_DIR_NAMES (a v0.2.0-era carve-out), so any path with an ops segment was 'pruned-dir': committed ops/*.md never imported, and modified ops/* files hit the unsyncableModified delete loop (whose #1433 guard only spared 'metafile'), silently deleting put-created pages like the bundled daily-task-manager's canonical ops/tasks on every sync. Fix: remove 'ops' from the prune list (ordinary user content; the vendor/generated entries stay), and harden the delete loop to also skip 'pruned-dir' — a page under a pruned dir can only exist via a deliberate put_page. #2426 (P0) — write-through content stayed DB-only and was deleted by sync --full. All three compounding bugs fixed: 1. writePageThrough now best-effort commits the artifact (path-limited git commit) on durability-hardened repos, so the post-commit hook can push it; result carries committed?: boolean. 2. scripts/brain-commit-push.sh stages+commits BEFORE any pull — the old fetch+pull-rebase-first order aborted on any dirty tree, so the helper could never commit a MODIFIED page; brain_push's rebase-on-reject already handles an advanced remote. 3. The full-sync delete-reconcile partitions stale pages by git history (listEverCommittedPaths): never-committed source_paths are DB-only write-through — pages are KEPT and re-exported to the working tree instead of soft-deleted. Builds on the #2828 mass-delete valve (covers the below-valve cases). #2607 — the sync --full git ls-files fast path bypassed pruneDir, so a full pass imported (and resurrected soft-deleted) pages under dot-dirs and vendored trees that incremental sync excludes. Fix: isCollectibleForWalker applies the same segment-level pruneDir gate as classifySync, so full and incremental enumeration agree. One regression test per defect (all verified failing against master src): test/sync-ops-pages.serial.test.ts, test/write-through-commit.serial.test.ts, the #2426 helper-order test in test/brain-durability-hook.serial.test.ts, test/sync-reconcile-db-only.serial.test.ts, test/import-git-fastpath-prune.test.ts. Fixes #2404 Fixes #2426 Fixes #2607 Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
154 lines
7.6 KiB
TypeScript
154 lines
7.6 KiB
TypeScript
/**
|
|
* End-to-end durability hook + helper (v0.42.44): the generated bash actually
|
|
* pushes. Real git, local bare remote. Validates the D13 guarantee (helper),
|
|
* the D9 self-contained local hook, and the D7 "one push-retry template" claim
|
|
* (the hook works even with the committed helper deleted).
|
|
*/
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { execFileSync } from 'child_process';
|
|
import { hardenBrainRepo } from '../src/core/brain-repo-durability.ts';
|
|
|
|
function git(cwd: string, ...args: string[]): string {
|
|
return execFileSync('git', ['-C', cwd, '-c', 'protocol.file.allow=always', ...args], {
|
|
stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8',
|
|
}).trim();
|
|
}
|
|
function originHead(bare: string): string {
|
|
return git(bare, 'rev-parse', 'refs/heads/main');
|
|
}
|
|
async function waitForOrigin(bare: string, expectSha: string, ms = 8000): Promise<boolean> {
|
|
const deadline = Date.now() + ms;
|
|
while (Date.now() < deadline) {
|
|
try { if (originHead(bare) === expectSha) return true; } catch { /* */ }
|
|
await new Promise(r => setTimeout(r, 150));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
let root: string, work: string, bare: string;
|
|
let oldHome: string | undefined, oldGbrainHome: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
root = mkdtempSync(join(tmpdir(), 'bdh-'));
|
|
oldHome = process.env.HOME; oldGbrainHome = process.env.GBRAIN_HOME;
|
|
process.env.HOME = mkdtempSync(join(root, 'home-'));
|
|
process.env.GBRAIN_HOME = join(process.env.HOME, '.gbrain');
|
|
process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT = '1';
|
|
bare = mkdtempSync(join(root, 'origin-')) + '.git';
|
|
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', bare], { stdio: 'ignore' });
|
|
work = mkdtempSync(join(root, 'work-'));
|
|
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, work], { stdio: 'ignore' });
|
|
git(work, 'config', 'user.email', 't@t.t'); git(work, 'config', 'user.name', 'tester');
|
|
writeFileSync(join(work, 'README.md'), 'init\n');
|
|
git(work, 'add', 'README.md'); git(work, 'commit', '-qm', 'init'); git(work, 'push', '-q', 'origin', 'main');
|
|
git(work, 'remote', 'set-head', 'origin', 'main');
|
|
await hardenBrainRepo({ repoPath: work, sourceId: 'wiki', pat: 'ghp_x', installCron: false });
|
|
});
|
|
afterEach(() => {
|
|
if (oldHome === undefined) delete process.env.HOME; else process.env.HOME = oldHome;
|
|
if (oldGbrainHome === undefined) delete process.env.GBRAIN_HOME; else process.env.GBRAIN_HOME = oldGbrainHome;
|
|
delete process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT;
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('brain-commit-push.sh (D13 guarantee)', () => {
|
|
test('add → commit → push lands on origin', () => {
|
|
mkdirSync(join(work, 'people'), { recursive: true });
|
|
writeFileSync(join(work, 'people', 'alice.md'), '# alice\n');
|
|
// helper requires explicit path; stages people/alice.md
|
|
execFileSync('bash', [join(work, 'scripts', 'brain-commit-push.sh'), 'add alice', 'people/alice.md'], {
|
|
cwd: work, stdio: ['ignore', 'pipe', 'pipe'], env: process.env,
|
|
});
|
|
expect(originHead(bare)).toBe(git(work, 'rev-parse', 'HEAD'));
|
|
// origin actually has the file
|
|
const verify = mkdtempSync(join(root, 'verify-'));
|
|
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, verify], { stdio: 'ignore' });
|
|
expect(existsSync(join(verify, 'people', 'alice.md'))).toBe(true);
|
|
});
|
|
|
|
test('refuses success when the push cannot land (exit non-zero)', () => {
|
|
git(work, 'remote', 'set-url', 'origin', join(root, 'gone.git'));
|
|
writeFileSync(join(work, 'x.md'), 'x\n');
|
|
let code = 0;
|
|
try {
|
|
execFileSync('bash', [join(work, 'scripts', 'brain-commit-push.sh'), 'msg', 'x.md'], {
|
|
cwd: work, stdio: ['ignore', 'pipe', 'pipe'], env: process.env,
|
|
});
|
|
} catch (e: any) { code = e.status ?? 1; }
|
|
expect(code).not.toBe(0); // committed but push failed → loud failure
|
|
});
|
|
|
|
test('refuses a blind add (no explicit path)', () => {
|
|
let code = 0;
|
|
try {
|
|
execFileSync('bash', [join(work, 'scripts', 'brain-commit-push.sh'), 'msg'], {
|
|
cwd: work, stdio: ['ignore', 'pipe', 'pipe'], env: process.env,
|
|
});
|
|
} catch (e: any) { code = e.status ?? 1; }
|
|
expect(code).toBe(2);
|
|
});
|
|
|
|
test('#2426 — commits a MODIFIED tracked file even when the remote advanced (commit before pull)', () => {
|
|
// Pre-fix, the helper ran `git pull --rebase` BEFORE staging, so any dirty
|
|
// tree (a modified/enriched page — exactly the write-through case) aborted
|
|
// with 'cannot pull with rebase: You have unstaged changes' (exit 3). The
|
|
// helper could only ever commit untracked-NEW files, never modifications.
|
|
// Remove the post-commit hook so its background push can't race the
|
|
// helper's own push (macOS has no flock to serialize them) — this test
|
|
// targets the HELPER's ordering; hook behavior is covered below.
|
|
rmSync(join(work, '.git', 'hooks', 'post-commit'));
|
|
// Advance the remote from a second clone so a pull is genuinely needed.
|
|
const other = mkdtempSync(join(root, 'other-'));
|
|
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, other], { stdio: 'ignore' });
|
|
git(other, 'config', 'user.email', 'o@o.o'); git(other, 'config', 'user.name', 'other');
|
|
writeFileSync(join(other, 'remote.md'), 'from other\n');
|
|
git(other, 'add', 'remote.md'); git(other, 'commit', '-qm', 'remote change'); git(other, 'push', '-q', 'origin', 'main');
|
|
|
|
// Dirty MODIFICATION of a tracked file in the hardened clone (write-through shape).
|
|
writeFileSync(join(work, 'README.md'), 'modified by write-through\n');
|
|
execFileSync('bash', [join(work, 'scripts', 'brain-commit-push.sh'), 'wt: README', 'README.md'], {
|
|
cwd: work, stdio: ['ignore', 'pipe', 'pipe'], env: process.env,
|
|
});
|
|
|
|
// Both the remote's commit and ours are on origin/main.
|
|
const subjects = git(bare, 'log', '--format=%s', 'main');
|
|
expect(subjects).toContain('wt: README');
|
|
expect(subjects).toContain('remote change');
|
|
// Working tree is clean — the modification was committed, not stranded.
|
|
expect(git(work, 'status', '--porcelain', 'README.md')).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('post-commit hook (D9 local, D7 self-contained)', () => {
|
|
test('a direct commit auto-pushes in the background', async () => {
|
|
writeFileSync(join(work, 'note.md'), 'note\n');
|
|
git(work, 'add', 'note.md'); git(work, 'commit', '-qm', 'note'); // fires .git/hooks/post-commit
|
|
const head = git(work, 'rev-parse', 'HEAD');
|
|
expect(await waitForOrigin(bare, head)).toBe(true);
|
|
});
|
|
|
|
test('the hook works even with the committed helper deleted (self-contained)', async () => {
|
|
rmSync(join(work, 'scripts', 'brain-commit-push.sh'));
|
|
git(work, 'add', '-A'); git(work, 'commit', '-qm', 'remove helper');
|
|
const head = git(work, 'rev-parse', 'HEAD');
|
|
expect(await waitForOrigin(bare, head)).toBe(true);
|
|
});
|
|
|
|
test('logs a clear LOCAL-ONLY line when origin is unreachable', async () => {
|
|
git(work, 'remote', 'set-url', 'origin', join(root, 'gone2.git'));
|
|
writeFileSync(join(work, 'orphan.md'), 'o\n');
|
|
git(work, 'add', 'orphan.md'); git(work, 'commit', '-qm', 'orphan');
|
|
const log = join(process.env.GBRAIN_HOME!, 'brain-push.log');
|
|
const deadline = Date.now() + 8000;
|
|
let found = false;
|
|
while (Date.now() < deadline) {
|
|
if (existsSync(log) && readFileSync(log, 'utf-8').includes('NEEDS ATTENTION')) { found = true; break; }
|
|
await new Promise(r => setTimeout(r, 150));
|
|
}
|
|
expect(found).toBe(true);
|
|
});
|
|
});
|