Files
gbrain/test/sources.test.ts
f28613178a feat(sources): persist + honor --include / --exclude globs across sync and lint (#2156)
Takeover of PR #2157, rebased onto current master.

- gbrain sources add --include/--exclude (repeatable) persist into
  sources.config.include_globs / exclude_globs; every subsequent sync
  of the source honors them.
- gbrain sync gains --include (allow-list counterpart to the existing
  #753/#774 --exclude), merged with the source row's persisted globs.
  Include/exclude are matched scope-relative in both the incremental
  and full-sync paths (import.ts gains the include filter); exclusion
  stays conservative (never deletes previously-imported pages).
- gbrain lint gains --include/--exclude and auto-lifts the persisted
  source globs when the lint target matches a source's local_path.
- Migration v125 (renumbered from the PR's v117/v120): a
  sources.config_fingerprint column caches a SHA-256 of the
  walk-affecting config fields (strategy + globs) so changing globs on
  an already-synced source forces a full re-walk instead of "Already
  up to date" (git HEAD unchanged). NULL = never stamped; first
  post-upgrade sync stamps quietly.

Deviations from #2157 while rebasing: globs are NOT threaded through
isSyncable/unsyncableReason in the incremental path — the unsyncable
cleanup loop deletes pages for non-metafile classifications, which
would violate the documented conservative #1433 posture; instead
include mirrors master's existing scope-relative excluded() helper.
CLI --exclude/--include now merge (union) with persisted config globs
rather than being replaced by them.

Fixes #2156
Takeover of #2157

Co-authored-by: brettdavies <brettdavies@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:36:26 -07:00

432 lines
17 KiB
TypeScript

/**
* v0.18.0 Step 6 — sources CLI subcommand tests.
*
* Pure unit tests that exercise the subcommand dispatcher via a
* stub BrainEngine. No DB required — we just confirm the SQL
* shape, validation, and flag parsing.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, mkdirSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { runSources } from '../src/commands/sources.ts';
import type { BrainEngine } from '../src/core/engine.ts';
// ── Stub engine that records queries ───────────────────────
interface RecordedCall {
sql: string;
params: unknown[];
}
function makeStub(rowsByPattern: Record<string, unknown[]> = {}): {
engine: BrainEngine;
calls: RecordedCall[];
configSet: Array<{ key: string; value: string }>;
} {
const calls: RecordedCall[] = [];
const configSet: Array<{ key: string; value: string }> = [];
const executeRaw = async (sql: string, params?: unknown[]) => {
calls.push({ sql, params: params ?? [] });
// Match by substring so tests are robust against whitespace.
for (const [pattern, rows] of Object.entries(rowsByPattern)) {
if (sql.includes(pattern)) return rows as never;
}
return [] as never;
};
const setConfig = async (key: string, value: string) => {
configSet.push({ key, value });
};
// Minimal BrainEngine stub — only the methods sources.ts touches.
const engine = {
kind: 'pglite' as const,
executeRaw,
setConfig,
// Unused methods throw if called accidentally during these tests.
getConfig: async () => null,
} as unknown as BrainEngine;
return { engine, calls, configSet };
}
// ── add ─────────────────────────────────────────────────────
// Intercept process.exit so unit tests under bun:test don't actually
// exit. Each test that might trigger process.exit() wraps its call in
// `withExitCapture`. We only return when the function under test returns
// or throws; process.exit() is turned into a recoverable throw.
async function withExitCapture(fn: () => Promise<void>): Promise<number | null> {
const origExit = process.exit;
let captured: number | null = null;
process.exit = ((code?: number) => {
captured = code ?? 0;
throw new Error('__process_exit__');
}) as never;
try {
await fn();
} catch (e) {
if (!(e instanceof Error) || !e.message.includes('__process_exit__')) throw e;
} finally {
process.exit = origExit;
}
return captured;
}
describe('sources add', () => {
test('rejects invalid ids', async () => {
const { engine } = makeStub();
const code = await withExitCapture(() => runSources(engine, ['add']));
expect(code).toBe(2);
});
test('rejects uppercase / invalid chars in id', async () => {
const { engine } = makeStub();
await expect(runSources(engine, ['add', 'BadId', '--path', '/tmp/x'])).rejects.toThrow(/Invalid source id/);
});
test('rejects id longer than 32 chars', async () => {
const { engine } = makeStub();
const long = 'a'.repeat(33);
await expect(runSources(engine, ['add', long, '--path', '/tmp/x'])).rejects.toThrow(/Invalid source id/);
});
test('inserts a valid source with defaults (federated unset → isolated)', async () => {
const { engine, calls } = makeStub({
'SELECT id, name, local_path, last_commit, last_sync_at, config, created_at': [{
id: 'gstack',
name: 'gstack',
local_path: '/tmp/gstack',
last_commit: null,
last_sync_at: null,
config: '{}',
created_at: new Date(),
}],
});
await runSources(engine, ['add', 'gstack', '--path', '/tmp/gstack']);
const insert = calls.find(c => c.sql.includes('INSERT INTO sources'));
expect(insert).toBeDefined();
expect(insert!.params[0]).toBe('gstack');
expect(insert!.params[1]).toBe('gstack'); // name defaults to id
expect(insert!.params[2]).toBe('/tmp/gstack');
expect(insert!.params[3]).toBe('{}'); // federated unset → empty config
});
test('--federated sets config.federated = true', async () => {
const { engine, calls } = makeStub({
'SELECT id, name, local_path, last_commit, last_sync_at, config, created_at': [{
id: 'wiki',
name: 'wiki',
local_path: '/tmp/wiki',
last_commit: null,
last_sync_at: null,
config: '{"federated":true}',
created_at: new Date(),
}],
});
await runSources(engine, ['add', 'wiki', '--path', '/tmp/wiki', '--federated']);
const insert = calls.find(c => c.sql.includes('INSERT INTO sources'));
expect(insert!.params[3]).toBe('{"federated":true}');
});
test('--no-federated sets config.federated = false (isolation opt-in)', async () => {
const { engine, calls } = makeStub({
'SELECT id, name, local_path, last_commit, last_sync_at, config, created_at': [{
id: 'yc-media',
name: 'yc-media',
local_path: '/tmp/yc',
last_commit: null,
last_sync_at: null,
config: '{"federated":false}',
created_at: new Date(),
}],
});
await runSources(engine, ['add', 'yc-media', '--path', '/tmp/yc', '--no-federated']);
const insert = calls.find(c => c.sql.includes('INSERT INTO sources'));
expect(insert!.params[3]).toBe('{"federated":false}');
});
test('rejects overlapping paths (per eng review finding 4.1)', async () => {
const { engine } = makeStub({
'SELECT id, local_path FROM sources WHERE local_path': [
{ id: 'gstack', local_path: '/tmp/gstack' },
],
});
// New source at /tmp/gstack/plans is inside existing gstack at /tmp/gstack.
await expect(runSources(engine, ['add', 'plans', '--path', '/tmp/gstack/plans']))
.rejects.toThrow(/overlaps with existing source "gstack"/);
});
// Glob filters — TODO #3 from the brettdavies fork recon. Pre-fix, the
// `SyncableOptions` shape in `src/core/sync.ts` had been carrying
// `include` / `exclude` since v0.41.13, but commands/sync.ts:1454 never
// populated them and `sources add` had no flag to persist them — so users
// had no way to tell gbrain to skip `Templates/` in an Obsidian vault.
test('--exclude persists glob into sources.config.exclude_globs', async () => {
const { engine, calls } = makeStub({
'SELECT id, name, local_path, last_commit, last_sync_at, config, created_at': [{
id: 'vault',
name: 'vault',
local_path: '/tmp/vault',
last_commit: null,
last_sync_at: null,
config: '{"exclude_globs":["Templates/**"]}',
created_at: new Date(),
}],
});
await runSources(engine, ['add', 'vault', '--path', '/tmp/vault', '--exclude', 'Templates/**']);
const insert = calls.find(c => c.sql.includes('INSERT INTO sources'));
expect(insert!.params[3]).toBe('{"exclude_globs":["Templates/**"]}');
});
test('--include persists glob into sources.config.include_globs', async () => {
const { engine, calls } = makeStub({
'SELECT id, name, local_path, last_commit, last_sync_at, config, created_at': [{
id: 'wiki',
name: 'wiki',
local_path: '/tmp/wiki',
last_commit: null,
last_sync_at: null,
config: '{"include_globs":["people/**"]}',
created_at: new Date(),
}],
});
await runSources(engine, ['add', 'wiki', '--path', '/tmp/wiki', '--include', 'people/**']);
const insert = calls.find(c => c.sql.includes('INSERT INTO sources'));
expect(insert!.params[3]).toBe('{"include_globs":["people/**"]}');
});
test('--exclude is repeatable; preserves order', async () => {
const { engine, calls } = makeStub({
'SELECT id, name, local_path, last_commit, last_sync_at, config, created_at': [{
id: 'vault',
name: 'vault',
local_path: '/tmp/vault',
last_commit: null,
last_sync_at: null,
config: '{}',
created_at: new Date(),
}],
});
await runSources(engine, [
'add', 'vault', '--path', '/tmp/vault',
'--exclude', 'Templates/**',
'--exclude', '.smart-env/**',
'--exclude', 'Drafts/**',
]);
const insert = calls.find(c => c.sql.includes('INSERT INTO sources'));
expect(insert!.params[3]).toBe('{"exclude_globs":["Templates/**",".smart-env/**","Drafts/**"]}');
});
test('--include and --exclude compose in one command (federated source with both filter axes)', async () => {
const { engine, calls } = makeStub({
'SELECT id, name, local_path, last_commit, last_sync_at, config, created_at': [{
id: 'vault',
name: 'vault',
local_path: '/tmp/vault',
last_commit: null,
last_sync_at: null,
config: '{"federated":true,"include_globs":["people/**"],"exclude_globs":["Templates/**"]}',
created_at: new Date(),
}],
});
await runSources(engine, [
'add', 'vault', '--path', '/tmp/vault', '--federated',
'--include', 'people/**',
'--exclude', 'Templates/**',
]);
const insert = calls.find(c => c.sql.includes('INSERT INTO sources'));
expect(insert!.params[3]).toBe(
'{"federated":true,"include_globs":["people/**"],"exclude_globs":["Templates/**"]}',
);
});
test('omitted glob flags leave config untouched (no [] entries persisted)', async () => {
// Regression guard: empty glob arrays must NOT be written. Otherwise a
// brain that never opts into filtering grows {"include_globs": [],
// "exclude_globs": []} cruft in every source row, and the parseGlobList
// path would return undefined anyway (the cruft is purely noise).
const { engine, calls } = makeStub({
'SELECT id, name, local_path, last_commit, last_sync_at, config, created_at': [{
id: 'gstack',
name: 'gstack',
local_path: '/tmp/gstack',
last_commit: null,
last_sync_at: null,
config: '{}',
created_at: new Date(),
}],
});
await runSources(engine, ['add', 'gstack', '--path', '/tmp/gstack']);
const insert = calls.find(c => c.sql.includes('INSERT INTO sources'));
expect(insert!.params[3]).toBe('{}');
});
test('--exclude requires a glob argument', async () => {
const { engine } = makeStub();
const code = await withExitCapture(() => runSources(engine, [
'add', 'vault', '--path', '/tmp/vault', '--exclude',
]));
expect(code).toBe(2);
});
test('--include rejects a flag-like value (--include --path looks like a typo)', async () => {
const { engine } = makeStub();
const code = await withExitCapture(() => runSources(engine, [
'add', 'vault', '--path', '/tmp/vault', '--include', '--federated',
]));
expect(code).toBe(2);
});
});
// ── add — #2707 git-repo validation (CLI wiring) ───────────────
//
// Uses a REAL on-disk temp dir (unlike the fake-path tests above) so the
// core addSource git check actually runs; the stub engine still fakes the
// DB round-trip. Confirms --force parses through to opsAddSource.
describe('sources add — #2707 --force flag wiring', () => {
let plainDir: string;
beforeEach(() => {
plainDir = mkdtempSync(join(tmpdir(), 'gbrain-sources-cli-2707-'));
});
afterEach(() => {
rmSync(plainDir, { recursive: true, force: true });
});
test('rejects a real non-git --path directory by default', async () => {
const { engine } = makeStub();
await expect(runSources(engine, ['add', 'cli-plain', '--path', plainDir]))
.rejects.toThrow(/not a git repository/);
});
test('--force registers the same directory anyway', async () => {
const { engine, calls } = makeStub({
'SELECT id, name, local_path, last_commit, last_sync_at, config, created_at': [{
id: 'cli-forced',
name: 'cli-forced',
local_path: plainDir,
last_commit: null,
last_sync_at: null,
config: '{}',
created_at: new Date(),
}],
});
await runSources(engine, ['add', 'cli-forced', '--path', plainDir, '--force']);
const insert = calls.find(c => c.sql.includes('INSERT INTO sources'));
expect(insert).toBeDefined();
expect(insert!.params[2]).toBe(plainDir);
});
});
// ── list ────────────────────────────────────────────────────
describe('sources list', () => {
test('orders default source first, then alphabetical', async () => {
const { engine, calls } = makeStub({
'SELECT id, name, local_path, last_commit, last_sync_at, config, created_at': [
{ id: 'default', name: 'default', local_path: null, last_commit: null, last_sync_at: null, config: '{"federated":true}', created_at: new Date() },
],
'COUNT(*)::int AS n FROM pages': [{ n: 0 }],
});
await runSources(engine, ['list']);
const select = calls.find(c => c.sql.includes('ORDER BY (id = \'default\') DESC'));
expect(select).toBeDefined();
});
test('counts only visible pages', async () => {
const { engine, calls } = makeStub({
'SELECT id, name, local_path, last_commit, last_sync_at, config, created_at': [
{ id: 'default', name: 'default', local_path: null, last_commit: null, last_sync_at: null, config: '{"federated":true}', created_at: new Date() },
],
'COUNT(*)::int AS n FROM pages': [{ n: 1 }],
});
await runSources(engine, ['list']);
const count = calls.find(c => c.sql.includes('COUNT(*)::int AS n FROM pages'));
expect(count?.sql).toContain('deleted_at IS NULL');
});
});
// ── remove ──────────────────────────────────────────────────
describe('sources remove', () => {
test("refuses to remove the 'default' source", async () => {
const { engine } = makeStub();
const code = await withExitCapture(() => runSources(engine, ['remove', 'default', '--yes']));
expect(code).toBe(3);
});
test('refuses without --yes', async () => {
const { engine } = makeStub({
'SELECT id, name, local_path, last_commit, last_sync_at, config, created_at': [
{ id: 'gstack', name: 'gstack', local_path: '/tmp/g', last_commit: null, last_sync_at: null, config: '{}', created_at: new Date() },
],
'COUNT(*)::int AS n FROM pages': [{ n: 10 }],
});
const code = await withExitCapture(() => runSources(engine, ['remove', 'gstack']));
expect(code).toBe(5);
});
test('--dry-run reports but does not DELETE', async () => {
const { engine, calls } = makeStub({
'SELECT id, name, local_path, last_commit, last_sync_at, config, created_at': [
{ id: 'gstack', name: 'gstack', local_path: '/tmp/g', last_commit: null, last_sync_at: null, config: '{}', created_at: new Date() },
],
'COUNT(*)::int AS n FROM pages': [{ n: 10 }],
});
await runSources(engine, ['remove', 'gstack', '--dry-run']);
const del = calls.find(c => c.sql.startsWith('DELETE FROM sources'));
expect(del).toBeUndefined();
});
});
// ── default ─────────────────────────────────────────────────
describe('sources default', () => {
test("stores id in config key 'sources.default'", async () => {
const { engine, configSet } = makeStub({
'SELECT id, name, local_path, last_commit, last_sync_at, config, created_at': [
{ id: 'gstack', name: 'gstack', local_path: null, last_commit: null, last_sync_at: null, config: '{}', created_at: new Date() },
],
});
await runSources(engine, ['default', 'gstack']);
expect(configSet).toEqual([{ key: 'sources.default', value: 'gstack' }]);
});
});
// ── federate / unfederate ──────────────────────────────────
describe('sources federate / unfederate', () => {
test('federate sets config.federated = true', async () => {
const { engine, calls } = makeStub({
'SELECT id, name, local_path, last_commit, last_sync_at, config, created_at': [
{ id: 'gstack', name: 'gstack', local_path: null, last_commit: null, last_sync_at: null, config: '{}', created_at: new Date() },
],
});
await runSources(engine, ['federate', 'gstack']);
const upd = calls.find(c => c.sql.includes('UPDATE sources SET config'));
expect(upd).toBeDefined();
expect(JSON.parse(upd!.params[0] as string)).toEqual({ federated: true });
});
test('unfederate preserves other config keys', async () => {
const { engine, calls } = makeStub({
'SELECT id, name, local_path, last_commit, last_sync_at, config, created_at': [
{ id: 'gstack', name: 'gstack', local_path: null, last_commit: null, last_sync_at: null, config: '{"ttl_days":90,"federated":true}', created_at: new Date() },
],
});
await runSources(engine, ['unfederate', 'gstack']);
const upd = calls.find(c => c.sql.includes('UPDATE sources SET config'));
const parsed = JSON.parse(upd!.params[0] as string);
// Must preserve ttl_days while flipping federated.
expect(parsed.ttl_days).toBe(90);
expect(parsed.federated).toBe(false);
});
});