mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +00:00
* fix(bootstrap): extend probes for files/oauth_clients/sources.archived* + add MIGRATIONS introspection guard Adds 7 new forward-reference probes to applyForwardReferenceBootstrap on both engines, closes the column-only forward-ref class via a new MIGRATIONS-source introspection contract test. New probes: - files.source_id + files.page_id (v18 forward refs) - oauth_clients.source_id + oauth_clients.federated_read (v60+v61+v65) - sources.archived + archived_at + archive_expires_at (v34 promoted from JSONB) The sources.archived* columns are the codex-flagged class: they're added inline in v34's CREATE TABLE definition but `CREATE TABLE IF NOT EXISTS sources` is a no-op on pre-v34 brains, so downstream visibility filters (search/list_pages) trip on old brains. needsPagesBootstrap now folds archive columns into its CREATE TABLE so pre-v0.18 brains get a v34-shape sources in one go; needsSourcesArchive then only fires on the pre-v34 case (sources exists, archive cols don't). Closes the structural bug class via test/helpers/extract-added-columns.ts: reads src/core/migrate.ts as text and extracts every ALTER TABLE ADD COLUMN. The new contract test asserts every (table, column) pair is covered by EITHER the bootstrap's ALTER TABLE statements, the bootstrap's CREATE TABLE definitions, OR the schema blob's CREATE TABLE bodies. The column-only class (no index, no FK; just an inline CREATE TABLE column the schema blob can't add to existing tables) is now caught at PR time. Source-text introspection catches all three migration shapes uniformly: - top-level `sql:` field - `sqlFor.postgres` / `sqlFor.pglite` overrides - handler-body `engine.runMigration(N, \`ALTER TABLE ...\`)` (v34 shape) Pre-existing parseBaseTableColumns parser bug fixed: now strips `--` line comments and `/* ... */` blocks before identifying column names. Without this, a column preceded by a comment was silently dropped. Catches pages.page_kind and others that were silently uncovered. 13 columns added by migrations but not in PGLITE_SCHEMA_SQL are exempted with a unified rationale: they have no schema-blob forward reference; migration handles all upgrade paths cleanly. Refreshing the schema blob is a separate concern. Issues closed: #1018 (v60 oauth_clients), #974 (files.source_id/page_id), #820 (v0.13.0 migration files.page_id cascade); pre-empts the sources.archived class before any pre-v34 brain trips on it. Tests: - 9 cases in test/schema-bootstrap-coverage.test.ts (5 existing + 4 new) - helper-level unit tests cover SQL shape variants (IF NOT EXISTS, quoted identifiers, ALTER TABLE IF EXISTS ONLY, multi-statement) - planted-bug regression verifies the gate actually catches new uncovered columns Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(orphans): filter soft-deleted pages on both candidate and link-source sides Closes #1021. The v0.26.5 soft-delete invariant requires that findOrphanPages exclude both: 1. Candidate pages that are themselves soft-deleted 2. Inbound links from soft-deleted source pages Pre-fix, findOrphanPages had no deleted_at filter at all. Soft-deleted pages with no inbound links were counted as orphans (inflating counts). Pre-codex-tension-D11, only the candidate-side filter was planned. Codex C11 caught the second case: a live page that has ONE inbound link from a soft-deleted source page was hidden from orphan results — the link still existed in the links table, the EXISTS subquery saw it, the page looked "linked." Now the inner JOIN on pages enforces src.deleted_at IS NULL. Three regression tests pin the contract: - soft-deleted page with no inbound → NOT orphan - live page with ONLY inbound link from soft-deleted source → IS orphan - live page with live inbound → NOT orphan (smoke check that the new filters don't break unchanged behavior) Engine parity: same SQL shape on both Postgres and PGLite engines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(think): route runThink through gateway.chat adapter (closes #952) Pre-fix, runThink instantiated `new Anthropic()` directly and read ANTHROPIC_API_KEY from process.env. Claude Desktop's stdio MCP launch doesn't inherit shell env, so `gbrain config set anthropic_api_key sk-...` (writes to ~/.gbrain/config.json) never reached the SDK and every MCP think call degraded to "no LLM available." The adapter routes through gateway.chat() — the canonical seam per CLAUDE.md. Gateway reads the API key from gbrain config OR env, picks up prompt caching, rate-leases, retry, and the test seam (__setChatTransportForTests) that v0.31.12 established. Per plan-eng-review D10 (cross-model tension with codex C7+C8+C9+C10), four spec points landed: 1. Drop `new Anthropic()` direct path entirely. Every non-stub LLM call from runThink routes through gateway. 2. Real availability check (NOT a false-positive `getChatModel()` truthy). `tryBuildGatewayClient` probes both the recipe (resolveRecipe throws AIConfigError on unknown providers) AND the API key (reads process.env + loadConfig at the gbrain config layer for parity with gateway's own auth resolution). Returns null on miss; runThink takes the graceful "no LLM available" early-return preserving the legacy NO_ANTHROPIC_API_KEY warning signal. 3. Model-id normalization. resolveModel returns bare anthropic ids (claude-opus-4-7); gateway.chat needs provider:model. Adapter auto-prefixes anthropic: when the id is bare. Provider:model strings pass through unchanged. 4. Response-shape conversion. ChatResult → Anthropic.Message via chatResultToMessage. mapStopReason translates gateway's provider-neutral stop reasons (end / length / tool_calls / refusal / content_filter / other) to Anthropic's stop_reason ('end_turn' / 'max_tokens' / 'tool_use'); refusal/content_filter/other fall through to end_turn (no Anthropic equivalent). Usage tokens pass through. `opts.client` injection preserved (test seam — see ThinkLLMClient). `opts.stubResponse` preserved (pure-test escape). Tests: - test/think-gateway-adapter.test.ts (9 cases): response shape, stop reason mapping, model-id normalization (bare + prefixed), provider unknown returns null, ANTHROPIC_API_KEY absent returns null (regression for legacy graceful degradation), hasAnthropicKey reads process.env correctly. Uses withEnv per the test-isolation contract. - test/think-pipeline.serial.test.ts (17 existing cases): unchanged; the graceful-degradation case at line 213 still produces the NO_ANTHROPIC_API_KEY warning because tryBuildGatewayClient returns null when no key is configured, taking the legacy early-return path. Closes #952. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sync): distinguish git worktree from submodule via path-segment match (closes #889) Pre-fix, `manageGitignore` treated every `.git`-as-file as a submodule and skipped gitignore management. Both submodules AND worktrees use `.git` as a file (not a directory), so the legacy `statSync.isFile()` check couldn't discriminate. Worktrees got misclassified as submodules and their .gitignore wasn't managed. Per plan-eng-review D4 (chose path-segment match over absolute-vs- relative path heuristic): the gitdir path contains: - `/modules/<name>` for submodules (skip — managed by parent repo) - `/worktrees/<name>` for worktrees (MANAGE — first-class repo) Both are documented Git internal layouts, stable across all 4 {relative, absolute} × {modules, worktrees} combinations including the absorbed-submodule edge case from `git submodule absorbgitdirs` (where the submodule's gitdir flips to an absolute path). Malformed `.git` file (no `gitdir:` prefix, IO error) → MANAGE, preserving the pre-#889 catch{} fail-closed-toward-managing semantics. Tests (5 new + 1 regression renamed): - REGRESSION: submodule relative gitdir/modules/ → skip (D49 contract) - absorbed submodule absolute gitdir/modules/ → skip (edge case) - CRITICAL: worktree absolute gitdir/worktrees/ → MANAGE (closes #889) - worktree relative gitdir/worktrees/ → MANAGE - malformed .git file → MANAGE (preserves catch behavior) - regular .git directory → MANAGE (existing smoke) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(walkers): pruneDir helper + descent-time exclusion + transcript predicate (closes #923, #202) Per plan-eng-review D12 (cross-model tension with codex C12+C13), three structural changes: 1. Extract `pruneDir(name)` helper in src/core/sync.ts. Returns false for directory names walkers must NEVER descend into: `node_modules` (latent bug — no leading dot), dot-prefix dirs (`.git`, `.obsidian`, `.raw`, `.cache`, etc.), `ops`, and `*.raw` sidecar dirs (gbrain convention — `people/pedro.raw/` holds raw source for pedro.md). Walkers consult it at descent time BEFORE recursion, saving the IO cost of walking entire vendor / hidden / sidecar subtrees only to filter them at file-emit time. 2. `isSyncable` itself gains the same exclusion set (via pruneDir on each path segment). Closes the latent bug where node_modules markdown files slipped through: `node_modules/some-pkg/README.md` returned true pre-fix because the legacy dot-prefix check only blocked `.node_modules` (with a leading dot), not the actual `node_modules`. CRITICAL regression test in test/sync.test.ts pins the contract per IRON RULE. 3. Two walkers rewritten to use pruneDir at descent + per-walker file predicate at emit: - `walkMarkdownFiles` (src/commands/extract.ts): pruneDir + isSyncable ({strategy:'markdown'}). Pre-fix this walker had ONLY an ad-hoc dot-prefix exclusion and didn't call isSyncable at all — descended into node_modules, emitted markdown files from there, ignored README/ ops/.raw filters. - `listTextFiles` (src/core/cycle/transcript-discovery.ts): pruneDir + own .txt/.md predicate. DOES NOT use isSyncable({strategy:'markdown'}) because transcripts accept .txt and don't share markdown sync's README/ops exclusions (codex C12). Also made RECURSIVE — pre-fix it walked only the top dir, so transcripts in `corpus/2026/` were invisible (codex C14 — descent-time pruning is the right shape but the test would have passed vacuously on a non-recursive walker). Verified blast radius before adding node_modules: every existing isSyncable caller (sync.ts:558-561 sync filter, frontmatter.ts:264 validate, brain-writer.ts:305 reverse-write, import.ts:454 import filter) wants node_modules excluded — this is a latent-bug fix, not a behavior change for any legitimate caller. Tests: - 7 new isSyncable cases including the node_modules CRITICAL regression - 6 new pruneDir cases (node_modules, dot-prefix, ops, *.raw, content dirs that should pass, empty-string default) - Existing extract.test.ts + extract-fs.test.ts unchanged and passing Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(todos): file v0.36.x follow-ups for runThink rewrite + Supabase bootstrap parity Two follow-up TODOs filed during the v0.36 dreamy-thompson wave: 1. runThink full rewrite (D5+D7 from plan-eng-review): drop the ThinkLLMClient indirection now that v0.36 routes through gateway.chat. 12+ tests need migration to __setChatTransportForTests. Blocked by this wave landing. 2. Supabase parity test for applyForwardReferenceBootstrap (codex C6 residual): real Docker Postgres E2E catches schema correctness but not Supabase pooler/direct-pool routing. The probe uses this.sql but PostgresEngine.initSchema chooses a DDL connection; the divergence has caused multiple historical wedges (#699, #820 lineage). Both entries include full context per the CLAUDE.md TODOS-format spec (what, why, pros, cons, blocked-by, plan reference). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(bootstrap): thread DDL connection through applyForwardReferenceBootstrap Codex adversarial review during /ship caught a P1: initSchema selected a DDL connection, took pg_advisory_lock(42) on it, but applyForwardReferenceBootstrap used `this.sql` (the instance pool) inside. Bootstrap probes ran outside the lock scope on a different connection. Failure mode: two concurrent gbrain instances could BOTH enter the bootstrap block on Supabase transaction-pooler setups because the advisory lock was held on a different connection than the one running ALTER TABLE. The pooler's statement_timeout could also kill the probes mid-flight without affecting the lock-holder, leaving an inconsistent schema state. Fix: applyForwardReferenceBootstrap now accepts an optional connection parameter. initSchema passes the DDL conn (the one holding the lock). this.sql remains the fallback for any unit-test path that calls bootstrap directly. PGLite engine doesn't need this change — single connection, no pooler. This was pre-existing (every prior probe used this.sql), but the v0.36 wave is explicitly about fixing the Supabase upgrade-wedge class. Codex's position was correct: don't ship the wave with the underlying connection mismatch still there. The Supabase parity TEST FIXTURE follow-up remains on TODOS.md (test infra needed to PROVE the fix works under real pooler topology), but the bug itself is closed. 15/15 bootstrap tests pass. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.35.5.0) Six-correctness-fix wave: bootstrap forward-ref class (4 issues + 1 pre-empt), orphans soft-delete leak (both sides), runThink → gateway.chat adapter, git worktree vs submodule discriminator, walker pruneDir + descent-time exclusion, plus a Codex-P1 catch during /ship that threaded the DDL connection through applyForwardReferenceBootstrap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: update CLAUDE.md for v0.35.5.0 backend correctness wave Fold v0.35.5.0 file-level annotations into CLAUDE.md: - postgres-engine.ts + pglite-engine.ts: 7 new applyForwardReferenceBootstrap probes (files.source_id/page_id, oauth_clients.source_id/federated_read, sources.archived/archived_at/archive_expires_at) + DDL connection threading - test/schema-bootstrap-coverage.test.ts: new MIGRATIONS-source introspection guard + parseBaseTableColumns comment-stripping fix - src/core/sync.ts: new pruneDir helper + manageGitignore worktree discriminator - src/core/think/index.ts (new entry): runThink gateway adapter for MCP stdio key resolution - src/core/operations.ts (new entry): findOrphanPages soft-delete filter Regenerate llms-full.txt via bun run build:llms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
613 lines
22 KiB
TypeScript
613 lines
22 KiB
TypeScript
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
|
import { buildSyncManifest, isSyncable, pathToSlug, pruneDir } from '../src/core/sync.ts';
|
|
import { buildGitInvocation } from '../src/commands/sync.ts';
|
|
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { execSync } from 'child_process';
|
|
import { tmpdir } from 'os';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
|
|
describe('buildSyncManifest', () => {
|
|
test('parses A/M/D entries from single commit', () => {
|
|
const output = `A\tpeople/new-person.md\nM\tpeople/existing-person.md\nD\tpeople/deleted-person.md`;
|
|
const manifest = buildSyncManifest(output);
|
|
expect(manifest.added).toEqual(['people/new-person.md']);
|
|
expect(manifest.modified).toEqual(['people/existing-person.md']);
|
|
expect(manifest.deleted).toEqual(['people/deleted-person.md']);
|
|
expect(manifest.renamed).toEqual([]);
|
|
});
|
|
|
|
test('parses R100 rename entries', () => {
|
|
const output = `R100\tpeople/old-name.md\tpeople/new-name.md`;
|
|
const manifest = buildSyncManifest(output);
|
|
expect(manifest.renamed).toEqual([{ from: 'people/old-name.md', to: 'people/new-name.md' }]);
|
|
expect(manifest.added).toEqual([]);
|
|
expect(manifest.modified).toEqual([]);
|
|
expect(manifest.deleted).toEqual([]);
|
|
});
|
|
|
|
test('parses partial rename (R075)', () => {
|
|
const output = `R075\tpeople/old.md\tpeople/new.md`;
|
|
const manifest = buildSyncManifest(output);
|
|
expect(manifest.renamed).toEqual([{ from: 'people/old.md', to: 'people/new.md' }]);
|
|
});
|
|
|
|
test('handles empty diff', () => {
|
|
const manifest = buildSyncManifest('');
|
|
expect(manifest.added).toEqual([]);
|
|
expect(manifest.modified).toEqual([]);
|
|
expect(manifest.deleted).toEqual([]);
|
|
expect(manifest.renamed).toEqual([]);
|
|
});
|
|
|
|
test('handles mixed entries with blank lines', () => {
|
|
const output = `A\tpeople/a.md\n\nM\tpeople/b.md\n\nD\tpeople/c.md`;
|
|
const manifest = buildSyncManifest(output);
|
|
expect(manifest.added).toEqual(['people/a.md']);
|
|
expect(manifest.modified).toEqual(['people/b.md']);
|
|
expect(manifest.deleted).toEqual(['people/c.md']);
|
|
});
|
|
|
|
test('skips malformed lines', () => {
|
|
const output = `A\tpeople/a.md\ngarbage line\nM\tpeople/b.md`;
|
|
const manifest = buildSyncManifest(output);
|
|
expect(manifest.added).toEqual(['people/a.md']);
|
|
expect(manifest.modified).toEqual(['people/b.md']);
|
|
});
|
|
});
|
|
|
|
describe('isSyncable', () => {
|
|
test('accepts normal .md files', () => {
|
|
expect(isSyncable('people/pedro-franceschi.md')).toBe(true);
|
|
expect(isSyncable('meetings/2026-04-03-lunch.md')).toBe(true);
|
|
expect(isSyncable('daily/2026-04-05.md')).toBe(true);
|
|
expect(isSyncable('notes.md')).toBe(true);
|
|
});
|
|
|
|
test('accepts .mdx files', () => {
|
|
expect(isSyncable('components/hero.mdx')).toBe(true);
|
|
expect(isSyncable('docs/getting-started.mdx')).toBe(true);
|
|
});
|
|
|
|
test('rejects non-.md/.mdx files', () => {
|
|
expect(isSyncable('people/photo.jpg')).toBe(false);
|
|
expect(isSyncable('config.json')).toBe(false);
|
|
expect(isSyncable('src/cli.ts')).toBe(false);
|
|
});
|
|
|
|
test('rejects files in hidden directories', () => {
|
|
expect(isSyncable('.git/config')).toBe(false);
|
|
expect(isSyncable('.obsidian/plugins.md')).toBe(false);
|
|
expect(isSyncable('people/.hidden/secret.md')).toBe(false);
|
|
});
|
|
|
|
test('rejects .raw/ sidecar directories', () => {
|
|
expect(isSyncable('people/pedro.raw/source.md')).toBe(false);
|
|
expect(isSyncable('dir/.raw/notes.md')).toBe(false);
|
|
});
|
|
|
|
test('rejects skip-list basenames', () => {
|
|
expect(isSyncable('schema.md')).toBe(false);
|
|
expect(isSyncable('index.md')).toBe(false);
|
|
expect(isSyncable('log.md')).toBe(false);
|
|
expect(isSyncable('README.md')).toBe(false);
|
|
expect(isSyncable('people/README.md')).toBe(false);
|
|
});
|
|
|
|
test('rejects ops/ directory', () => {
|
|
expect(isSyncable('ops/deploy-log.md')).toBe(false);
|
|
expect(isSyncable('ops/config.md')).toBe(false);
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// v0.36 walker drift fix (closes #923, #202): node_modules exclusion
|
|
// ────────────────────────────────────────────────────────────────
|
|
|
|
test('CRITICAL latent-bug regression: rejects node_modules paths at any depth', () => {
|
|
// Pre-v0.36, isSyncable had no node_modules check. Any markdown file
|
|
// under a non-dot `node_modules` directory slipped through. This is
|
|
// the canonical latent-bug fix gated by IRON RULE per the wave plan.
|
|
expect(isSyncable('node_modules/some-pkg/README.md')).toBe(false);
|
|
expect(isSyncable('node_modules/some-pkg/CHANGELOG.md')).toBe(false);
|
|
expect(isSyncable('node_modules/some-pkg/docs/api.md')).toBe(false);
|
|
expect(isSyncable('apps/web/node_modules/dep/notes.md')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('pruneDir', () => {
|
|
test('blocks node_modules (no leading dot, the latent-bug case)', () => {
|
|
expect(pruneDir('node_modules')).toBe(false);
|
|
});
|
|
|
|
test('blocks dot-prefix dirs (.git, .obsidian, .raw, .cache, etc.)', () => {
|
|
expect(pruneDir('.git')).toBe(false);
|
|
expect(pruneDir('.obsidian')).toBe(false);
|
|
expect(pruneDir('.raw')).toBe(false);
|
|
expect(pruneDir('.cache')).toBe(false);
|
|
expect(pruneDir('.vscode')).toBe(false);
|
|
});
|
|
|
|
test('blocks ops (gbrain operational dir)', () => {
|
|
expect(pruneDir('ops')).toBe(false);
|
|
});
|
|
|
|
test('blocks *.raw sidecar dirs (gbrain convention)', () => {
|
|
expect(pruneDir('.raw')).toBe(false);
|
|
expect(pruneDir('pedro.raw')).toBe(false);
|
|
expect(pruneDir('article.raw')).toBe(false);
|
|
});
|
|
|
|
test('allows normal content dirs', () => {
|
|
expect(pruneDir('wiki')).toBe(true);
|
|
expect(pruneDir('people')).toBe(true);
|
|
expect(pruneDir('meetings')).toBe(true);
|
|
expect(pruneDir('corpus')).toBe(true);
|
|
expect(pruneDir('2026')).toBe(true);
|
|
});
|
|
|
|
test('empty string returns true (defensive default)', () => {
|
|
expect(pruneDir('')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('pathToSlug', () => {
|
|
test('strips .md extension and lowercases', () => {
|
|
expect(pathToSlug('people/pedro-franceschi.md')).toBe('people/pedro-franceschi');
|
|
});
|
|
|
|
test('normalizes to lowercase', () => {
|
|
expect(pathToSlug('People/Pedro-Franceschi.md')).toBe('people/pedro-franceschi');
|
|
});
|
|
|
|
test('strips leading slash', () => {
|
|
expect(pathToSlug('/people/pedro.md')).toBe('people/pedro');
|
|
});
|
|
|
|
test('normalizes backslash separators', () => {
|
|
expect(pathToSlug('people\\pedro.md')).toBe('people/pedro');
|
|
});
|
|
|
|
test('handles flat files', () => {
|
|
expect(pathToSlug('notes.md')).toBe('notes');
|
|
});
|
|
|
|
test('handles nested paths', () => {
|
|
expect(pathToSlug('projects/gbrain/spec.md')).toBe('projects/gbrain/spec');
|
|
});
|
|
|
|
test('adds repo prefix when provided', () => {
|
|
expect(pathToSlug('people/pedro.md', 'brain')).toBe('brain/people/pedro');
|
|
});
|
|
|
|
test('no prefix when not provided', () => {
|
|
expect(pathToSlug('people/pedro.md')).toBe('people/pedro');
|
|
});
|
|
|
|
test('handles empty string', () => {
|
|
expect(pathToSlug('')).toBe('');
|
|
});
|
|
|
|
test('handles file with only extension', () => {
|
|
expect(pathToSlug('.md')).toBe('');
|
|
});
|
|
|
|
test('slugifies spaces to hyphens', () => {
|
|
expect(pathToSlug('Apple Notes/2017-05-03 ohmygreen.md')).toBe('apple-notes/2017-05-03-ohmygreen');
|
|
});
|
|
|
|
test('strips special characters', () => {
|
|
expect(pathToSlug('notes/meeting (march 2024).md')).toBe('notes/meeting-march-2024');
|
|
});
|
|
});
|
|
|
|
describe('isSyncable edge cases', () => {
|
|
test('rejects uppercase .MD extension', () => {
|
|
// isSyncable checks path.endsWith('.md'), so .MD should fail
|
|
expect(isSyncable('people/someone.MD')).toBe(false);
|
|
});
|
|
|
|
test('rejects files with no extension', () => {
|
|
expect(isSyncable('README')).toBe(false);
|
|
});
|
|
|
|
test('accepts deeply nested .md files', () => {
|
|
expect(isSyncable('a/b/c/d/e/f/deep.md')).toBe(true);
|
|
});
|
|
|
|
test('rejects .md files inside nested hidden dirs', () => {
|
|
expect(isSyncable('docs/.internal/secret.md')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('buildSyncManifest edge cases', () => {
|
|
test('handles tab-separated fields correctly', () => {
|
|
const output = "A\tpath/to/file.md";
|
|
const manifest = buildSyncManifest(output);
|
|
expect(manifest.added).toEqual(['path/to/file.md']);
|
|
});
|
|
|
|
test('handles multiple renames', () => {
|
|
const output = [
|
|
'R100\told/a.md\tnew/a.md',
|
|
'R095\told/b.md\tnew/b.md',
|
|
].join('\n');
|
|
const manifest = buildSyncManifest(output);
|
|
expect(manifest.renamed).toHaveLength(2);
|
|
expect(manifest.renamed[0].from).toBe('old/a.md');
|
|
expect(manifest.renamed[1].from).toBe('old/b.md');
|
|
});
|
|
|
|
test('ignores unknown status codes', () => {
|
|
const output = "X\tunknown/file.md";
|
|
const manifest = buildSyncManifest(output);
|
|
expect(manifest.added).toEqual([]);
|
|
expect(manifest.modified).toEqual([]);
|
|
expect(manifest.deleted).toEqual([]);
|
|
expect(manifest.renamed).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// performSync dry-run (v0.17 regression guard for full-sync silent writes)
|
|
// ────────────────────────────────────────────────────────────────
|
|
|
|
describe('performSync dry-run never writes', () => {
|
|
let engine: PGLiteEngine;
|
|
let repoPath: string;
|
|
|
|
// One PGLite per file — beforeEach wipes data only. Each test still gets a
|
|
// fresh git repo via mkdtempSync, but skips the ~20s PGLite cold-start.
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-sync-dryrun-'));
|
|
execSync('git init', { cwd: repoPath, stdio: 'pipe' });
|
|
execSync('git config user.email "test@test.com"', { cwd: repoPath, stdio: 'pipe' });
|
|
execSync('git config user.name "Test"', { cwd: repoPath, stdio: 'pipe' });
|
|
mkdirSync(join(repoPath, 'people'), { recursive: true });
|
|
writeFileSync(join(repoPath, 'people/alice.md'), [
|
|
'---',
|
|
'type: person',
|
|
'title: Alice',
|
|
'---',
|
|
'',
|
|
'Alice is a person.',
|
|
].join('\n'));
|
|
writeFileSync(join(repoPath, 'people/bob.md'), [
|
|
'---',
|
|
'type: person',
|
|
'title: Bob',
|
|
'---',
|
|
'',
|
|
'Bob is another person.',
|
|
].join('\n'));
|
|
execSync('git add -A && git commit -m "initial"', { cwd: repoPath, stdio: 'pipe' });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
|
|
});
|
|
|
|
test('first-sync dry-run does NOT write to DB or advance the bookmark', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
dryRun: true,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
|
|
// Status + counts reflect what WOULD be imported.
|
|
expect(result.status).toBe('dry_run');
|
|
expect(result.added).toBe(2); // alice + bob, both syncable
|
|
expect(result.chunksCreated).toBe(0);
|
|
expect(result.embedded).toBe(0);
|
|
|
|
// DB is clean: no pages written.
|
|
expect(await engine.getPage('people/alice')).toBeNull();
|
|
expect(await engine.getPage('people/bob')).toBeNull();
|
|
|
|
// Bookmark NOT set — this is the regression the guard enforces.
|
|
expect(await engine.getConfig('sync.last_commit')).toBeNull();
|
|
expect(await engine.getConfig('sync.repo_path')).toBeNull();
|
|
});
|
|
|
|
test('incremental dry-run does NOT write to DB or advance the bookmark', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
// First do a real sync to seed the bookmark.
|
|
const real = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
expect(real.status).toBe('first_sync');
|
|
const bookmarkAfterReal = await engine.getConfig('sync.last_commit');
|
|
expect(bookmarkAfterReal).not.toBeNull();
|
|
|
|
// Add a third file.
|
|
writeFileSync(join(repoPath, 'people/carol.md'), [
|
|
'---',
|
|
'type: person',
|
|
'title: Carol',
|
|
'---',
|
|
'',
|
|
'Carol joins the cast.',
|
|
].join('\n'));
|
|
execSync('git add -A && git commit -m "add carol"', { cwd: repoPath, stdio: 'pipe' });
|
|
|
|
// Incremental sync in dry-run mode.
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
dryRun: true,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
|
|
expect(result.status).toBe('dry_run');
|
|
expect(result.added).toBe(1); // carol only
|
|
expect(result.chunksCreated).toBe(0);
|
|
expect(result.embedded).toBe(0);
|
|
|
|
// carol is NOT in the DB.
|
|
expect(await engine.getPage('people/carol')).toBeNull();
|
|
// alice + bob still present from the real sync.
|
|
expect(await engine.getPage('people/alice')).not.toBeNull();
|
|
expect(await engine.getPage('people/bob')).not.toBeNull();
|
|
|
|
// Bookmark unchanged — still at the pre-carol commit.
|
|
const bookmarkAfterDry = await engine.getConfig('sync.last_commit');
|
|
expect(bookmarkAfterDry).toBe(bookmarkAfterReal);
|
|
});
|
|
|
|
test('full-sync (--full) dry-run does NOT write to DB or advance the bookmark', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
// Seed the bookmark so we hit the full-sync-with-bookmark path when --full is set.
|
|
await performSync(engine, { repoPath, noPull: true, noEmbed: true });
|
|
// Clear DB so we can observe that a --full dry-run doesn't re-import.
|
|
await (engine as any).db.exec(`DELETE FROM content_chunks; DELETE FROM pages;`);
|
|
const bookmarkBefore = await engine.getConfig('sync.last_commit');
|
|
expect(bookmarkBefore).not.toBeNull();
|
|
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
full: true, // force full-sync path
|
|
dryRun: true,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
|
|
expect(result.status).toBe('dry_run');
|
|
expect(result.added).toBe(2); // alice + bob would be imported
|
|
expect(result.chunksCreated).toBe(0);
|
|
|
|
// DB empty — full-sync dry-run did not reimport.
|
|
expect(await engine.getPage('people/alice')).toBeNull();
|
|
expect(await engine.getPage('people/bob')).toBeNull();
|
|
|
|
// Bookmark unchanged.
|
|
const bookmarkAfter = await engine.getConfig('sync.last_commit');
|
|
expect(bookmarkAfter).toBe(bookmarkBefore);
|
|
});
|
|
|
|
test('SyncResult exposes embedded count field', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
dryRun: true,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
// Structural assertion: the contract includes `embedded: number`.
|
|
expect(typeof result.embedded).toBe('number');
|
|
});
|
|
|
|
test('detached HEAD skips git pull and ingests local working-tree files', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
const seeded = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
noExtract: true,
|
|
});
|
|
expect(seeded.status).toBe('first_sync');
|
|
|
|
execSync('git checkout --detach HEAD', { cwd: repoPath, stdio: 'pipe' });
|
|
writeFileSync(join(repoPath, 'people/detached-local.md'), [
|
|
'---',
|
|
'type: person',
|
|
'title: Detached Local',
|
|
'---',
|
|
'',
|
|
'This file exists only in the detached working tree.',
|
|
].join('\n'));
|
|
|
|
const errors: string[] = [];
|
|
const originalError = console.error;
|
|
console.error = (...args: unknown[]) => {
|
|
errors.push(args.map(String).join(' '));
|
|
};
|
|
|
|
try {
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
noEmbed: true,
|
|
noExtract: true,
|
|
});
|
|
|
|
expect(result.status).toBe('synced');
|
|
expect(result.added).toBe(1);
|
|
expect(result.pagesAffected).toContain('people/detached-local');
|
|
} finally {
|
|
console.error = originalError;
|
|
}
|
|
|
|
expect(errors.join('\n')).toContain(`Detached HEAD on ${repoPath}; skipping git pull. Syncing from local working tree.`);
|
|
expect(errors.join('\n')).not.toContain('git pull failed');
|
|
|
|
const page = await engine.getPage('people/detached-local');
|
|
expect(page).not.toBeNull();
|
|
expect(page!.title).toBe('Detached Local');
|
|
});
|
|
|
|
test('detached HEAD with --no-pull also ingests local working-tree files', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
const seeded = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
noExtract: true,
|
|
});
|
|
expect(seeded.status).toBe('first_sync');
|
|
|
|
execSync('git checkout --detach HEAD', { cwd: repoPath, stdio: 'pipe' });
|
|
writeFileSync(join(repoPath, 'people/detached-nopull.md'), [
|
|
'---',
|
|
'type: person',
|
|
'title: Detached NoPull',
|
|
'---',
|
|
'',
|
|
'Only in detached working tree, --no-pull caller.',
|
|
].join('\n'));
|
|
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
noExtract: true,
|
|
});
|
|
|
|
expect(result.status).toBe('synced');
|
|
expect(result.added).toBe(1);
|
|
expect(result.pagesAffected).toContain('people/detached-nopull');
|
|
|
|
const page = await engine.getPage('people/detached-nopull');
|
|
expect(page).not.toBeNull();
|
|
expect(page!.title).toBe('Detached NoPull');
|
|
});
|
|
});
|
|
|
|
describe('sync regression — #132 nested transaction deadlock', () => {
|
|
test('src/commands/sync.ts does not wrap the add/modify loop in engine.transaction()', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/sync.ts', import.meta.url)).text();
|
|
// Accept either of the historical loop shapes: the original inline
|
|
// `for (const path of [...filtered.added, ...filtered.modified])` or
|
|
// the v0.15.2 progress-wrapped variant where the list is hoisted into
|
|
// a local `addsAndMods` variable first.
|
|
const inlineIdx = source.indexOf('for (const path of [...filtered.added, ...filtered.modified]');
|
|
const hoistedIdx = source.indexOf('const addsAndMods = [...filtered.added, ...filtered.modified]');
|
|
const loopStart = inlineIdx !== -1 ? inlineIdx : hoistedIdx;
|
|
expect(loopStart).toBeGreaterThan(-1);
|
|
const prelude = source.slice(0, loopStart);
|
|
const lastTxIdx = prelude.lastIndexOf('engine.transaction');
|
|
if (lastTxIdx !== -1) {
|
|
const lineStart = prelude.lastIndexOf('\n', lastTxIdx) + 1;
|
|
const line = prelude.slice(lineStart, prelude.indexOf('\n', lastTxIdx));
|
|
expect(line.trim().startsWith('//')).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('resolveSlugByPathOrSourcePath (CJK wave v0.32.7, codex F4)', () => {
|
|
let pgEngine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
pgEngine = new PGLiteEngine();
|
|
await pgEngine.connect({});
|
|
await pgEngine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await pgEngine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await (pgEngine as any).db.exec('DELETE FROM content_chunks');
|
|
await (pgEngine as any).db.exec('DELETE FROM pages');
|
|
});
|
|
|
|
test('returns stored slug when source_path matches a row', async () => {
|
|
const { resolveSlugByPathOrSourcePath } = await import('../src/commands/sync.ts');
|
|
// Seed a frontmatter-fallback page: slug doesn't derive from path (emoji)
|
|
await pgEngine.executeRaw(
|
|
`INSERT INTO pages (slug, type, title, compiled_truth, page_kind, source_path)
|
|
VALUES ('projects/launch', 'project', 'Launch', 'body', 'markdown', '🚀.md')`,
|
|
);
|
|
const slug = await resolveSlugByPathOrSourcePath(pgEngine, '🚀.md');
|
|
expect(slug).toBe('projects/launch');
|
|
});
|
|
|
|
test('falls back to resolveSlugForPath when no source_path matches', async () => {
|
|
const { resolveSlugByPathOrSourcePath } = await import('../src/commands/sync.ts');
|
|
// No row seeded — fallback returns the path-derived slug.
|
|
const slug = await resolveSlugByPathOrSourcePath(pgEngine, 'concepts/hello-world.md');
|
|
expect(slug).toBe('concepts/hello-world');
|
|
});
|
|
|
|
test('scoped by source_id when provided', async () => {
|
|
const { resolveSlugByPathOrSourcePath } = await import('../src/commands/sync.ts');
|
|
// Same source_path under TWO sources — without source_id scope we'd
|
|
// get either at random. With source_id we get the right one.
|
|
await pgEngine.executeRaw(
|
|
`INSERT INTO sources (id, name) VALUES ('source-a', 'A') ON CONFLICT DO NOTHING`,
|
|
);
|
|
await pgEngine.executeRaw(
|
|
`INSERT INTO sources (id, name) VALUES ('source-b', 'B') ON CONFLICT DO NOTHING`,
|
|
);
|
|
await pgEngine.executeRaw(
|
|
`INSERT INTO pages (source_id, slug, type, title, compiled_truth, page_kind, source_path)
|
|
VALUES ('source-a', 'slug-a/page', 'note', 'A', 'a', 'markdown', '🚀.md')`,
|
|
);
|
|
await pgEngine.executeRaw(
|
|
`INSERT INTO pages (source_id, slug, type, title, compiled_truth, page_kind, source_path)
|
|
VALUES ('source-b', 'slug-b/page', 'note', 'B', 'b', 'markdown', '🚀.md')`,
|
|
);
|
|
expect(await resolveSlugByPathOrSourcePath(pgEngine, '🚀.md', 'source-a')).toBe('slug-a/page');
|
|
expect(await resolveSlugByPathOrSourcePath(pgEngine, '🚀.md', 'source-b')).toBe('slug-b/page');
|
|
});
|
|
});
|
|
|
|
describe('git() helper invocation order (CJK wave v0.32.7)', () => {
|
|
// The git CLI requires `-c key=val` to appear BEFORE the subcommand,
|
|
// and `-C path` BEFORE the subcommand too. Pin the emit order so a future
|
|
// refactor can't silently put `-c` after the subcommand and break CJK
|
|
// path emission.
|
|
|
|
test('core.quotepath=false is always emitted first', () => {
|
|
const argv = buildGitInvocation('/repo', ['diff', '--name-status']);
|
|
expect(argv).toEqual([
|
|
'-c', 'core.quotepath=false',
|
|
'-C', '/repo',
|
|
'diff', '--name-status',
|
|
]);
|
|
});
|
|
|
|
test('extra configs append AFTER quotepath, BEFORE -C and subcommand', () => {
|
|
const argv = buildGitInvocation('/repo', ['diff'], ['foo=bar', 'baz=qux']);
|
|
expect(argv).toEqual([
|
|
'-c', 'core.quotepath=false',
|
|
'-c', 'foo=bar',
|
|
'-c', 'baz=qux',
|
|
'-C', '/repo',
|
|
'diff',
|
|
]);
|
|
});
|
|
|
|
test('empty args produces a valid invocation', () => {
|
|
const argv = buildGitInvocation('/repo', []);
|
|
expect(argv).toEqual([
|
|
'-c', 'core.quotepath=false',
|
|
'-C', '/repo',
|
|
]);
|
|
});
|
|
});
|