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

127 lines
5.2 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import { isSyncable, isCodeFilePath, slugifyCodePath, pathToSlug } from '../src/core/sync.ts';
describe('isCodeFilePath', () => {
test('recognizes common code extensions', () => {
expect(isCodeFilePath('src/foo.ts')).toBe(true);
expect(isCodeFilePath('app/foo.tsx')).toBe(true);
expect(isCodeFilePath('lib/bar.js')).toBe(true);
expect(isCodeFilePath('Foo.jsx')).toBe(true);
expect(isCodeFilePath('bundle.mjs')).toBe(true);
expect(isCodeFilePath('legacy.cjs')).toBe(true);
expect(isCodeFilePath('script.py')).toBe(true);
expect(isCodeFilePath('class.rb')).toBe(true);
expect(isCodeFilePath('main.go')).toBe(true);
});
test('rejects non-code extensions', () => {
expect(isCodeFilePath('notes.md')).toBe(false);
expect(isCodeFilePath('photo.jpg')).toBe(false);
expect(isCodeFilePath('README')).toBe(false);
// v0.20.0 Cathedral II Layer 2 widens the classifier to include
// config formats (.json, .yaml, .toml) and web formats (.css, .html,
// .vue) because the chunker supports them — they were dropped by the
// 9-extension v0.19.0 allowlist. `.json` is NO LONGER rejected.
expect(isCodeFilePath('image.svg')).toBe(false);
expect(isCodeFilePath('archive.zip')).toBe(false);
});
test('is case-insensitive', () => {
expect(isCodeFilePath('Foo.TS')).toBe(true);
expect(isCodeFilePath('Main.GO')).toBe(true);
});
});
describe('isSyncable with strategy', () => {
test('default strategy (markdown) behaves as before — only .md/.mdx', () => {
expect(isSyncable('people/alice.md')).toBe(true);
expect(isSyncable('docs/guide.mdx')).toBe(true);
expect(isSyncable('src/foo.ts')).toBe(false);
});
test('strategy=code allows ts/tsx/js/py/rb/go, rejects markdown', () => {
expect(isSyncable('src/foo.ts', { strategy: 'code' })).toBe(true);
expect(isSyncable('src/foo.py', { strategy: 'code' })).toBe(true);
expect(isSyncable('src/foo.go', { strategy: 'code' })).toBe(true);
expect(isSyncable('notes.md', { strategy: 'code' })).toBe(false);
});
test('strategy=auto accepts both markdown and code', () => {
expect(isSyncable('notes.md', { strategy: 'auto' })).toBe(true);
expect(isSyncable('src/foo.ts', { strategy: 'auto' })).toBe(true);
});
test('existing skip rules apply across all strategies', () => {
// Hidden directories are always skipped
expect(isSyncable('.git/config.js', { strategy: 'code' })).toBe(false);
// README.md is skipped under markdown
expect(isSyncable('README.md', { strategy: 'markdown' })).toBe(false);
// ops/ is ordinary content — NOT skipped (#2404)
expect(isSyncable('ops/migrate.py', { strategy: 'code' })).toBe(true);
// vendored trees always skipped
expect(isSyncable('vendor/pkg/migrate.py', { strategy: 'code' })).toBe(false);
// .raw/ sidecar always skipped
expect(isSyncable('dir/.raw/code.ts', { strategy: 'code' })).toBe(false);
});
test('include globs whitelist specific patterns', () => {
expect(isSyncable('src/foo.ts', { strategy: 'code', include: ['src/**/*.ts'] })).toBe(true);
expect(isSyncable('lib/bar.ts', { strategy: 'code', include: ['src/**/*.ts'] })).toBe(false);
expect(isSyncable('src/foo.py', { strategy: 'code', include: ['src/**/*.ts'] })).toBe(false);
});
test('exclude globs blacklist specific patterns', () => {
expect(isSyncable('src/foo.ts', { strategy: 'code', exclude: ['**/*.test.ts'] })).toBe(true);
expect(isSyncable('test/foo.test.ts', { strategy: 'code', exclude: ['**/*.test.ts'] })).toBe(false);
});
test('include + exclude compose (include first, then exclude)', () => {
expect(
isSyncable('src/foo.ts', {
strategy: 'code',
include: ['src/**/*.ts'],
exclude: ['**/*.test.ts'],
}),
).toBe(true);
expect(
isSyncable('src/foo.test.ts', {
strategy: 'code',
include: ['src/**/*.ts'],
exclude: ['**/*.test.ts'],
}),
).toBe(false);
});
});
describe('slugifyCodePath', () => {
test('flattens path with hyphens and replaces dots', () => {
expect(slugifyCodePath('src/core/chunkers/code.ts')).toBe('src-core-chunkers-code-ts');
expect(slugifyCodePath('app/models/user.rb')).toBe('app-models-user-rb');
expect(slugifyCodePath('lib/foo/bar/baz.go')).toBe('lib-foo-bar-baz-go');
});
test('drops leading ./', () => {
expect(slugifyCodePath('./src/foo.ts')).toBe('src-foo-ts');
});
test('lowercases', () => {
expect(slugifyCodePath('Src/Foo.TS')).toBe('src-foo-ts');
});
});
describe('pathToSlug with pageKind', () => {
test('pageKind=markdown (default) uses slugifyPath', () => {
expect(pathToSlug('people/alice-smith.md')).toBe('people/alice-smith');
expect(pathToSlug('docs/guide.md', undefined, { pageKind: 'markdown' })).toBe('docs/guide');
});
test('pageKind=code uses slugifyCodePath (flattened)', () => {
expect(pathToSlug('src/core/sync.ts', undefined, { pageKind: 'code' })).toBe('src-core-sync-ts');
});
test('repoPrefix is prepended (markdown and code)', () => {
expect(pathToSlug('guides/foo.md', 'gbrain')).toBe('gbrain/guides/foo');
expect(pathToSlug('src/foo.ts', 'gbrain', { pageKind: 'code' })).toBe('gbrain/src-foo-ts');
});
});