mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
fix(sync): self-heal a never-git-initialized default brain dir (#2964)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCVEvUVm15bFuKqskWgfNS
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
f72de97943
commit
b1671ee718
+38
-1
@@ -1628,7 +1628,44 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
|||||||
// - syncScopeRoot: file walking, imports, deletes, renames
|
// - syncScopeRoot: file walking, imports, deletes, renames
|
||||||
// In the common case (repoPath == git root, no subpath) they are identical.
|
// In the common case (repoPath == git root, no subpath) they are identical.
|
||||||
serr(`[gbrain phase] sync.discover_git_root`);
|
serr(`[gbrain phase] sync.discover_git_root`);
|
||||||
const gitContextRoot = realpathSync(discoverGitRoot(repoPath));
|
// #2964: a legacy `sync.repo_path`-anchored default brain (no `sources`
|
||||||
|
// row, no `opts.sourceId`) can reach here having never been `git init`-ed
|
||||||
|
// — e.g. a brain-pages dir that predates git-backed sync, or one rsync'd
|
||||||
|
// from another machine without its `.git`. gbrain owns this directory
|
||||||
|
// outright (it's not a user-external path the way an `opts.sourceId` +
|
||||||
|
// local `sources add --path` entry is), so self-heal by initializing it
|
||||||
|
// in place instead of failing the sync phase every single run. Mirrors
|
||||||
|
// the recloneIfMissing self-recovery above for owned remote clones.
|
||||||
|
// Scoped to !opts.sourceId only — a registered local source without a
|
||||||
|
// remote_url stays a loud, actionable error (it's the user's own
|
||||||
|
// directory; silently git-initializing it without consent is an
|
||||||
|
// overreach).
|
||||||
|
let gitContextRoot: string;
|
||||||
|
try {
|
||||||
|
gitContextRoot = realpathSync(discoverGitRoot(repoPath));
|
||||||
|
} catch (err) {
|
||||||
|
if (opts.sourceId || !existsSync(repoPath)) throw err;
|
||||||
|
serr(`[gbrain] auto-recovery: git-initializing brain dir ${repoPath} (no git repo found).`);
|
||||||
|
git(repoPath, ['init', '--quiet']);
|
||||||
|
// A fresh `git init` has zero commits, and `git rev-parse HEAD` a few
|
||||||
|
// lines below requires at least one — without a baseline commit we'd
|
||||||
|
// just trade "not a git repo" for "no commits in repo" on the very next
|
||||||
|
// line. Snapshot the CURRENT on-disk state as that baseline (respecting
|
||||||
|
// .gitignore, written first) so future incremental syncs diff against
|
||||||
|
// what's actually here rather than an empty tree — an empty initial
|
||||||
|
// commit would make every existing file look "added" again on the next
|
||||||
|
// sync, even though this full-sync pass already imported them.
|
||||||
|
manageGitignore(repoPath, engine.kind);
|
||||||
|
git(repoPath, ['add', '-A']);
|
||||||
|
// Explicit identity via -c: a headless nightly cron/launchd invocation
|
||||||
|
// has no reason to have global git user.name/user.email configured.
|
||||||
|
git(
|
||||||
|
repoPath,
|
||||||
|
['commit', '--quiet', '--allow-empty', '-m', 'gbrain: initial commit (auto-init by sync)'],
|
||||||
|
['user.name=gbrain', 'user.email=gbrain@localhost'],
|
||||||
|
);
|
||||||
|
gitContextRoot = realpathSync(discoverGitRoot(repoPath));
|
||||||
|
}
|
||||||
const rawScopeRoot = opts.srcSubpath ? join(repoPath, opts.srcSubpath) : repoPath;
|
const rawScopeRoot = opts.srcSubpath ? join(repoPath, opts.srcSubpath) : repoPath;
|
||||||
if (!existsSync(rawScopeRoot)) {
|
if (!existsSync(rawScopeRoot)) {
|
||||||
throw new Error(`Sync scope does not exist: ${rawScopeRoot}`);
|
throw new Error(`Sync scope does not exist: ${rawScopeRoot}`);
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* #2964 — sync phase self-heals a never-git-initialized default brain dir.
|
||||||
|
*
|
||||||
|
* A legacy `sync.repo_path`-anchored default brain (no `sources` row, no
|
||||||
|
* `--source`) can reach `performSync` pointed at a directory that was never
|
||||||
|
* `git init`-ed (predates git-backed sync, or was rsync'd from another
|
||||||
|
* machine without its `.git`). Before this fix, `discoverGitRoot` threw
|
||||||
|
* unconditionally and the dream cycle's sync phase failed every night with
|
||||||
|
* no self-recovery, even though `doctor`'s sync checks reported "ok" (for
|
||||||
|
* an unrelated reason — they only look at the `sources` table, which has
|
||||||
|
* no rows for this kind of brain).
|
||||||
|
*
|
||||||
|
* gbrain owns this directory outright, so the fix self-heals by
|
||||||
|
* `git init`-ing it and capturing the current on-disk state as the sync
|
||||||
|
* baseline — but ONLY for the default/no-`sourceId` path. A registered
|
||||||
|
* local source (`sources add --path`, no `--url`) is the user's own
|
||||||
|
* external directory and must keep failing loudly rather than being
|
||||||
|
* silently git-initialized without consent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||||
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||||
|
|
||||||
|
function mdPage(title: string, body = 'Content.'): string {
|
||||||
|
return `---\ntype: note\ntitle: ${title}\n---\n\n${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('#2964: sync auto-inits a never-git-initialized default brain dir', () => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user