Files
gbrain/test/sync-ops-pages.serial.test.ts
42375bded5 fix(sync): stop the sync data-loss family — ops/ prune, DB-only write-through, full-sync gate drift (#2404, #2426, #2607) (#2938)
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>
2026-07-17 14:41:25 -07:00

130 lines
5.8 KiB
TypeScript

/**
* #2404 — `ops/` is ordinary content: sync imports `ops/*` files and never
* deletes `ops/*` pages.
*
* Bug class: `'ops'` was hardcoded in PRUNE_DIR_NAMES (a v0.2.0-era carve-out),
* so `classifySync` treated ANY path with an `ops` segment as 'pruned-dir':
* - committed `ops/*.md` files were never imported (even by `sync --full`);
* - a modified `ops/*` file fell into the unsyncableModified delete loop,
* whose #1433 guard only skipped 'metafile' — so put-created `ops/*` pages
* (e.g. the bundled daily-task-manager's canonical `ops/tasks`) were
* silently deleted on every sync.
*
* Fix: remove `'ops'` from PRUNE_DIR_NAMES, and harden the delete loop to also
* skip 'pruned-dir' classifications (a page under a genuinely-pruned dir can
* only exist via a deliberate put_page — never delete it on a file edit).
*
* Modeled on test/sync-metafile-skip.serial.test.ts (the #1433 iron rule).
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
import { execSync } from 'child_process';
import { tmpdir } from 'os';
import { join } from 'path';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
let engine: PGLiteEngine;
let repoPath: string;
function gitInit(repo: string): void {
execSync('git init', { cwd: repo, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: repo, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: repo, stdio: 'pipe' });
}
describe('#2404 — ops/ pages sync like any other content', () => {
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
}, 60_000);
afterAll(async () => {
if (engine) await engine.disconnect();
}, 60_000);
beforeEach(async () => {
await resetPgliteState(engine);
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-ops-'));
gitInit(repoPath);
mkdirSync(join(repoPath, 'topics'), { recursive: true });
writeFileSync(join(repoPath, 'topics/foo.md'), [
'---', 'type: concept', 'title: Foo', '---', '', 'Baseline content.',
].join('\n'));
execSync('git add -A && git commit -m "initial"', { cwd: repoPath, stdio: 'pipe' });
});
afterEach(() => {
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
});
test('a committed ops/*.md file is imported by sync', async () => {
const { performSync } = await import('../src/commands/sync.ts');
mkdirSync(join(repoPath, 'ops'), { recursive: true });
writeFileSync(join(repoPath, 'ops/tasks.md'), [
'---', 'type: concept', 'title: Tasks', '---', '', 'Open tasks live here.',
].join('\n'));
execSync('git add -A && git commit -m "add ops/tasks"', { cwd: repoPath, stdio: 'pipe' });
const result = await performSync(engine, { repoPath, full: true, noPull: true, noEmbed: true });
expect(['first_sync', 'synced']).toContain(result.status);
// Pre-fix: ops/* was 'pruned-dir' → imported=0 for it, even on --full.
const page = await engine.getPage('ops/tasks');
expect(page).not.toBeNull();
expect(page?.compiled_truth).toContain('Open tasks');
}, 60_000);
test('an edited ops/*.md updates its page instead of deleting it', async () => {
const { performSync } = await import('../src/commands/sync.ts');
mkdirSync(join(repoPath, 'ops'), { recursive: true });
writeFileSync(join(repoPath, 'ops/tasks.md'), [
'---', 'type: concept', 'title: Tasks', '---', '', 'v1',
].join('\n'));
execSync('git add -A && git commit -m "add ops/tasks"', { cwd: repoPath, stdio: 'pipe' });
await performSync(engine, { repoPath, full: true, noPull: true, noEmbed: true });
writeFileSync(join(repoPath, 'ops/tasks.md'), [
'---', 'type: concept', 'title: Tasks', '---', '', 'v2 with a new task',
].join('\n'));
execSync('git add -A && git commit -m "edit ops/tasks"', { cwd: repoPath, stdio: 'pipe' });
// Pre-fix: this incremental sync hit the unsyncableModified delete loop
// ("Deleted un-syncable page: ops/tasks" — the autopilot kill-loop).
const second = await performSync(engine, { repoPath, noPull: true, noEmbed: true });
expect(['synced', 'up_to_date', 'first_sync']).toContain(second.status);
const page = await engine.getPage('ops/tasks');
expect(page).not.toBeNull();
expect(page?.compiled_truth).toContain('v2');
}, 60_000);
test('hardening: a put-created page under a STILL-pruned dir survives a file edit (pruned-dir delete guard)', async () => {
const { performSync } = await import('../src/commands/sync.ts');
// node_modules stays in PRUNE_DIR_NAMES. Commit a file there, then seed a
// same-path page via putPage — the deliberate-put precondition.
mkdirSync(join(repoPath, 'node_modules/pkg'), { recursive: true });
writeFileSync(join(repoPath, 'node_modules/pkg/notes.md'), 'v1\n');
execSync('git add -A -f && git commit -m "vendor file"', { cwd: repoPath, stdio: 'pipe' });
await performSync(engine, { repoPath, full: true, noPull: true, noEmbed: true });
await engine.putPage('node_modules/pkg/notes', {
type: 'concept',
title: 'Deliberate put page',
compiled_truth: 'Created via put_page; must survive sync.',
timeline: '',
frontmatter: { type: 'concept' },
});
writeFileSync(join(repoPath, 'node_modules/pkg/notes.md'), 'v2\n');
execSync('git add -A -f && git commit -m "edit vendor file"', { cwd: repoPath, stdio: 'pipe' });
await performSync(engine, { repoPath, noPull: true, noEmbed: true });
// Pre-fix: reason 'pruned-dir' was not guarded → page deleted.
const survivor = await engine.getPage('node_modules/pkg/notes');
expect(survivor).not.toBeNull();
}, 60_000);
});