mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 03:12:32 +00:00
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>
432 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|