mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +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>
143 lines
5.9 KiB
TypeScript
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);
|
|
});
|