Files
gbrain/test/sync-reconcile-db-only.serial.test.ts
T
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

143 lines
5.9 KiB
TypeScript

/**
* #2426 (bug 3) — `sync --full` delete-reconcile preserves DB-only pages.
*
* Bug class: the full-sync reconcile soft-deleted ANY file-backed page whose
* `source_path` was absent from the working tree — including pages whose
* markdown was NEVER committed to git (write-through that never made it to
* the remote, then a fresh clone). "Absent from git" is the SYMPTOM of the
* missing write-through commit, not evidence the content is disposable; one
* production pass soft-deleted thousands of genuine pages this way.
*
* Fix: the reconcile partitions stale pages by git history — a path that ever
* appeared as an ADD was genuinely deleted (reconcile as before); a path with
* NO history is DB-only write-through: keep the page and re-export its
* markdown to the working tree so it's file-backed again.
*
* Builds on the #2828 mass-delete valve (this guard covers the below-valve
* cases the ratio check can't see).
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, existsSync } 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';
import { listEverCommittedPaths } from '../src/commands/sync.ts';
let engine: PGLiteEngine;
let repoPath: string;
function gitInit(repo: string): void {
execSync('git init', { cwd: repo, stdio: 'pipe' });
execSync('git config user.email "t@t.t"', { cwd: repo, stdio: 'pipe' });
execSync('git config user.name "T"', { cwd: repo, stdio: 'pipe' });
}
describe('listEverCommittedPaths (#2426)', () => {
test('returns every path ever added, including later-deleted ones; null for non-git dirs', () => {
const repo = mkdtempSync(join(tmpdir(), 'gbrain-ecp-'));
try {
gitInit(repo);
writeFileSync(join(repo, 'kept.md'), 'kept\n');
writeFileSync(join(repo, 'gone.md'), 'gone\n');
execSync('git add -A && git commit -m add', { cwd: repo, stdio: 'pipe' });
execSync('git rm -q gone.md && git commit -m rm', { cwd: repo, stdio: 'pipe' });
const set = listEverCommittedPaths(repo);
expect(set).not.toBeNull();
expect(set!.has('kept.md')).toBe(true);
expect(set!.has('gone.md')).toBe(true); // deleted, but WAS committed
expect(set!.has('never-committed.md')).toBe(false);
const plain = mkdtempSync(join(tmpdir(), 'gbrain-ecp-plain-'));
try {
expect(listEverCommittedPaths(plain)).toBeNull();
} finally {
rmSync(plain, { recursive: true, force: true });
}
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
});
describe('#2426 — full-sync reconcile keeps never-committed (DB-only) pages', () => {
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-dbonly-'));
gitInit(repoPath);
mkdirSync(join(repoPath, 'topics'), { recursive: true });
writeFileSync(join(repoPath, 'topics/keep.md'), [
'---', 'type: concept', 'title: Keep', '---', '', 'still here',
].join('\n'));
writeFileSync(join(repoPath, 'topics/gone.md'), [
'---', 'type: concept', 'title: Gone', '---', '', 'will be git-rm-ed',
].join('\n'));
execSync('git add -A && git commit -m initial', { cwd: repoPath, stdio: 'pipe' });
});
afterEach(() => {
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
});
test('genuinely-deleted pages reconcile; never-committed pages are kept and re-exported', async () => {
const { performSync } = await import('../src/commands/sync.ts');
// Full sync #1: both file-backed pages land.
const first = await performSync(engine, {
repoPath, full: true, sourceId: 'default', noPull: true, noEmbed: true,
});
expect(['first_sync', 'synced']).toContain(first.status);
expect(await engine.getPage('topics/keep')).not.toBeNull();
expect(await engine.getPage('topics/gone')).not.toBeNull();
// A DB-only write-through casualty: the page row exists with a
// source_path, but its file was never committed and is absent from the
// clone (e.g. write-through was never pushed, then the repo was re-cloned).
await engine.putPage('memories/lost', {
type: 'concept',
title: 'Lost write-through',
compiled_truth: 'Years of content that must not be reconciled away.',
timeline: '',
frontmatter: { type: 'concept' },
});
await engine.executeRaw(
`UPDATE pages SET source_path = $1 WHERE slug = $2 AND source_id = $3`,
['memories/lost.md', 'memories/lost', 'default'],
);
// A genuine deletion: topics/gone.md removed via git.
execSync('git rm -q topics/gone.md && git commit -m "rm gone"', { cwd: repoPath, stdio: 'pipe' });
await engine.setConfig('sync.repo_path', repoPath);
// Full sync #2 runs the delete-reconcile.
const second = await performSync(engine, {
repoPath, full: true, sourceId: 'default', noPull: true, noEmbed: true,
});
expect(['first_sync', 'synced']).toContain(second.status);
// The genuinely-deleted page is reconciled away…
expect(await engine.getPage('topics/gone')).toBeNull();
// …the still-present page survives…
expect(await engine.getPage('topics/keep')).not.toBeNull();
// …and the DB-only page is PRESERVED (pre-fix: soft-deleted here)…
const lost = await engine.getPage('memories/lost');
expect(lost).not.toBeNull();
expect(lost?.compiled_truth).toContain('must not be reconciled');
// …and re-exported to the working tree so it is file-backed again.
expect(existsSync(join(repoPath, 'memories/lost.md'))).toBe(true);
}, 120_000);
});