From b1671ee71875c657020de4e5475d064c9cbedb7b Mon Sep 17 00:00:00 2001 From: masashiono0611 Date: Sun, 19 Jul 2026 01:43:48 +0900 Subject: [PATCH] fix(sync): self-heal a never-git-initialized default brain dir (#2964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dream cycle's sync phase throws unconditionally on a legacy sync.repo_path-anchored default brain dir that was never git init-ed (predates git-backed sync, or was rsync'd without its .git), failing every nightly run with no recovery. doctor's sync_freshness/ sync_consolidation checks report "ok" for this exact brain, but only because they query the sources table (0 rows for a legacy default brain) — a coincidental false-negative, not a real diagnosis. Self-heal by git-initializing the dir and capturing the current on-disk state as the sync baseline, scoped to !opts.sourceId only — gbrain owns this directory outright, unlike a registered local source (sources add --path, no --url) which is the user's own external directory and should keep failing loudly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NCVEvUVm15bFuKqskWgfNS --- src/commands/sync.ts | 39 +++++++++++- test/sync-git-autoinit.test.ts | 112 +++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 test/sync-git-autoinit.test.ts diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 0e857003b..4737501e8 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -1628,7 +1628,44 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise { + let engine: PGLiteEngine; + let dir: string; + + beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); + }, 60_000); + + afterAll(async () => { + await engine.disconnect(); + }, 60_000); + + beforeEach(async () => { + await resetPgliteState(engine); + dir = mkdtempSync(join(tmpdir(), 'gbrain-2964-')); + writeFileSync(join(dir, 'page1.md'), mdPage('Page 1')); + writeFileSync(join(dir, 'page2.md'), mdPage('Page 2')); + }); + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + test('default (no sourceId) sync on a non-git dir auto-inits git and imports files', async () => { + const { performSync } = await import('../src/commands/sync.ts'); + expect(existsSync(join(dir, '.git'))).toBe(false); + + const result = await performSync(engine, { + repoPath: dir, + noPull: true, + noEmbed: true, + full: true, + }); + + expect(result.status).toBe('first_sync'); + expect(result.added).toBe(2); + expect(existsSync(join(dir, '.git'))).toBe(true); + expect(await engine.getPage('page1')).not.toBeNull(); + expect(await engine.getPage('page2')).not.toBeNull(); + }); + + test('a second sync after auto-init sees no changes (baseline commit captured current on-disk state)', async () => { + const { performSync } = await import('../src/commands/sync.ts'); + const first = await performSync(engine, { + repoPath: dir, + noPull: true, + noEmbed: true, + full: true, + }); + expect(first.added).toBe(2); + + // No new files, no explicit `full` — a real incremental sync against the + // auto-init baseline. Before this fix there was no baseline to diff + // against (sync errored outright); a naive fix that skipped the initial + // commit would make this call re-report both files as "added" again. + const second = await performSync(engine, { + repoPath: dir, + noPull: true, + noEmbed: true, + }); + expect(second.status).not.toBe('first_sync'); + expect(second.added).toBe(0); + expect(second.modified).toBe(0); + }); + + test('a registered local source (sourceId set, no remote_url) on a non-git dir still throws — not auto-inited', async () => { + const { performSync } = await import('../src/commands/sync.ts'); + await expect( + performSync(engine, { + repoPath: dir, + sourceId: 'default', + noPull: true, + noEmbed: true, + full: true, + }), + ).rejects.toThrow(/git repository/i); + expect(existsSync(join(dir, '.git'))).toBe(false); + }); +});