Files
gbrain/test/sync-isSyncable-shape.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

66 lines
3.2 KiB
TypeScript

/**
* v0.41.13 (#1433) — isSyncable / unsyncableReason classifier shape.
*
* The two public APIs route through the same internal `classifySync`
* helper so they cannot drift. These tests pin the contract that
* `isSyncable` returns true iff `unsyncableReason` returns null, AND
* pin the canonical SYNC_SKIP_FILES list (since the cleanup-loop guard
* at commands/sync.ts:772 keys on `unsyncableReason === 'metafile'`).
*/
import { describe, test, expect } from 'bun:test';
import {
isSyncable,
unsyncableReason,
SYNC_SKIP_FILES,
type SyncableReason,
} from '../src/core/sync.ts';
describe('#1433 — isSyncable / unsyncableReason are duals of one classifier', () => {
const cases: Array<{ path: string; expected: SyncableReason | null; note: string }> = [
{ path: 'people/alice.md', expected: null, note: 'normal markdown page' },
{ path: 'docs/guide.mdx', expected: null, note: 'mdx accepted by markdown strategy' },
{ path: 'learning-and-strategy/log.md', expected: 'metafile', note: 'log.md anywhere is metafile' },
{ path: 'wiki/schema.md', expected: 'metafile', note: 'schema.md anywhere is metafile' },
{ path: 'index.md', expected: 'metafile', note: 'top-level index.md' },
{ path: 'README.md', expected: 'metafile', note: 'top-level README' },
{ path: 'docs/README.md', expected: 'metafile', note: 'nested README' },
{ path: 'RESOLVER.md', expected: 'metafile', note: 'top-level master routing config (closes #345)' },
{ path: 'brain/RESOLVER.md', expected: 'metafile', note: 'RESOLVER.md anywhere is metafile (closes #345)' },
{ path: 'people/alice.txt', expected: 'strategy', note: '.txt rejected by markdown strategy' },
{ path: 'ops/scratch/note.md', expected: null, note: 'ops/ is ordinary content, not pruned (#2404)' },
{ path: 'vendor/pkg/note.md', expected: 'pruned-dir', note: 'vendor/ is pruned' },
{ path: '.git/notes.md', expected: 'pruned-dir', note: 'hidden dir pruned' },
{ path: 'node_modules/foo/README.md', expected: 'pruned-dir', note: 'node_modules pruned' },
];
for (const c of cases) {
test(`${c.path}${c.expected ?? 'syncable'} (${c.note})`, () => {
const reason = unsyncableReason(c.path);
const sync = isSyncable(c.path);
expect(reason).toBe(c.expected);
expect(sync).toBe(c.expected === null);
});
}
test('include glob: path not matching include returns include-glob-miss', () => {
expect(unsyncableReason('docs/guide.md', { include: ['people/**'] })).toBe('include-glob-miss');
expect(isSyncable('docs/guide.md', { include: ['people/**'] })).toBe(false);
});
test('exclude glob: matching path returns exclude-glob-hit', () => {
expect(unsyncableReason('drafts/wip.md', { exclude: ['drafts/**'] })).toBe('exclude-glob-hit');
expect(isSyncable('drafts/wip.md', { exclude: ['drafts/**'] })).toBe(false);
});
test('SYNC_SKIP_FILES export contains the canonical structural metafiles', () => {
expect([...SYNC_SKIP_FILES]).toEqual(['schema.md', 'index.md', 'log.md', 'README.md', 'RESOLVER.md']);
});
test('isSyncable(p) === (unsyncableReason(p) === null) — duality holds for all canonical cases', () => {
for (const c of cases) {
expect(isSyncable(c.path)).toBe(unsyncableReason(c.path) === null);
}
});
});