mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
fix(sync): post-heal .gitignore write, supabase_only alias, honor abort signal (#2964)
Sixth Codex review round (e687913), 3 P2s:
- Dream-cycle callers (cycle.ts:runPhaseSync) invoke performSync directly
and never run runSync's CLI-only post-success manageGitignoreAtGitRoot.
A brain self-healed only via the dream cycle would have db_only content
correctly excluded from the baseline commit (createSyncBaselineCommit's
pathspec exclusion) but no .gitignore ever written, leaving the user's
own future manual git add/commit unprotected. Added
performFullSyncAndMaybeGitignore, a thin wrapper around the 3
post-self-heal performFullSync call sites that writes .gitignore
(same success-status gate runSync already uses) only when didSelfHeal
is true — a no-op for the normal path, which still relies on runSync
exactly as before.
- The fail-closed sniff-test only checked the canonical `db_only` key;
the deprecated-but-still-supported `supabase_only` alias (same
keep-out-of-git semantics) could silently bypass it. Now checks both.
- Self-heal didn't check opts.signal?.aborted before starting the
(now up to 10-minute) git init + baseline commit, so a cancelled sync
could still mutate disk and overrun its budget instead of returning
partial. Added the check at both self-heal sites, before any git
operation runs.
New test proves .gitignore gets written after a bare performSync call
(no runSync wrapper) — the actual dream-cycle shape.
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
e68791361b
commit
27337b08c1
+46
-7
@@ -999,11 +999,14 @@ function createSyncBaselineCommit(repoPath: string): void {
|
||||
// (e.g. flow-style `db_only: [dir/]` — the narrow custom parser only
|
||||
// handles block-style lists), which would silently resolve zero
|
||||
// exclusions from a file that clearly intended some. If gbrain.yml
|
||||
// exists and mentions db_only but nothing resolved from it, refuse
|
||||
// rather than guess "genuinely empty" vs "unsupported syntax ignored".
|
||||
// exists and mentions db_only (or its deprecated pre-v0.22.11 alias
|
||||
// `supabase_only` — same keep-out-of-git semantics, still a supported
|
||||
// backward-compat key per storage-config.ts) but nothing resolved from
|
||||
// it, refuse rather than guess "genuinely empty" vs "syntax ignored".
|
||||
if (dbOnlyDirs.length === 0) {
|
||||
const yamlPath = join(repoPath, 'gbrain.yml');
|
||||
if (existsSync(yamlPath) && readFileSync(yamlPath, 'utf-8').includes('db_only')) {
|
||||
const yamlContent = existsSync(yamlPath) ? readFileSync(yamlPath, 'utf-8') : '';
|
||||
if (yamlContent.includes('db_only') || yamlContent.includes('supabase_only')) {
|
||||
throw new Error(
|
||||
`${yamlPath} mentions db_only but no directories resolved from it — refusing to ` +
|
||||
`auto-commit (cannot tell "genuinely empty" from "unsupported syntax silently ignored"). ` +
|
||||
@@ -1816,16 +1819,23 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
// the mere absence of `opts.sourceId`/`opts.repoPath` — see that
|
||||
// function's docstring. `!opts.dryRun`: a preview must never write.
|
||||
let gitContextRoot: string;
|
||||
let didSelfHeal = false;
|
||||
try {
|
||||
gitContextRoot = realpathSync(discoverGitRoot(repoPath));
|
||||
} catch (err) {
|
||||
if (opts.dryRun || !existsSync(repoPath) || !(await isAnchorOwnedSyncPath(engine, opts, repoPath))) {
|
||||
if (
|
||||
opts.dryRun ||
|
||||
opts.signal?.aborted ||
|
||||
!existsSync(repoPath) ||
|
||||
!(await isAnchorOwnedSyncPath(engine, opts, repoPath))
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
serr(`[gbrain] auto-recovery: git-initializing brain dir ${repoPath} (no git repo found).`);
|
||||
git(repoPath, ['init', '--quiet']);
|
||||
createSyncBaselineCommit(repoPath);
|
||||
gitContextRoot = realpathSync(discoverGitRoot(repoPath));
|
||||
didSelfHeal = true;
|
||||
}
|
||||
const rawScopeRoot = opts.srcSubpath ? join(repoPath, opts.srcSubpath) : repoPath;
|
||||
if (!existsSync(rawScopeRoot)) {
|
||||
@@ -1967,6 +1977,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
// files well outside the sync scope — refuse instead of guessing.
|
||||
if (
|
||||
opts.dryRun ||
|
||||
opts.signal?.aborted ||
|
||||
gitContextRoot !== realpathSync(repoPath) ||
|
||||
!(await isAnchorOwnedSyncPath(engine, opts, repoPath))
|
||||
) {
|
||||
@@ -1975,6 +1986,34 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
serr(`[gbrain] auto-recovery: repo has no commits yet, creating baseline commit ${gitContextRoot}.`);
|
||||
createSyncBaselineCommit(gitContextRoot);
|
||||
headCommit = git(gitContextRoot, ['rev-parse', 'HEAD']);
|
||||
didSelfHeal = true;
|
||||
}
|
||||
|
||||
// #2964: after a self-heal, write .gitignore for db_only dirs the SAME
|
||||
// way `runSync` does for every other successful sync (post-completion,
|
||||
// never before — see `createSyncBaselineCommit`'s docstring on why
|
||||
// ordering matters). Needed here specifically because the dream cycle
|
||||
// (`cycle.ts:runPhaseSync`) calls `performSync` directly and never goes
|
||||
// through `runSync`'s CLI-only post-success `manageGitignoreAtGitRoot`
|
||||
// call — without this, a brain self-healed via the dream cycle would
|
||||
// have db_only content correctly excluded from THIS commit (the
|
||||
// pathspec exclusion in `createSyncBaselineCommit`) but no `.gitignore`
|
||||
// ever written, leaving future manual `git add`/`commit` by the user
|
||||
// unprotected (Codex review round 6, P2). No-op for a normal
|
||||
// already-git-initialized sync (didSelfHeal stays false) — that case
|
||||
// is unaffected and still relies on `runSync`'s existing post-success
|
||||
// call, exactly as before.
|
||||
async function performFullSyncAndMaybeGitignore(
|
||||
roots: typeof fullSyncRoots,
|
||||
): Promise<SyncResult> {
|
||||
const result = await performFullSync(engine, roots, headCommit, opts);
|
||||
if (didSelfHeal && result.status !== 'blocked_by_failures' && result.status !== 'partial') {
|
||||
// repoPath is `string` by construction (guarded at function entry),
|
||||
// but the guard's narrowing doesn't carry into this nested closure —
|
||||
// reassert via the local roots, which is realpath-derived from it.
|
||||
manageGitignore(roots.gitContextRoot, engine.kind);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// #1970: bookmark reachability. The ONLY thing that should force a full
|
||||
@@ -2003,7 +2042,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
// back to the authoritative full reconcile (which now also purges stale
|
||||
// pages for deleted files; see performFullSync's delete-reconcile pass).
|
||||
serr(`Sync anchor ${lastCommit.slice(0, 8)} object missing (gc'd after history rewrite). Running full reimport.`);
|
||||
return performFullSync(engine, fullSyncRoots, headCommit, opts);
|
||||
return performFullSyncAndMaybeGitignore(fullSyncRoots);
|
||||
}
|
||||
|
||||
// Observability only — NOT control flow. A non-ancestor bookmark is still
|
||||
@@ -2026,7 +2065,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
|
||||
// First sync
|
||||
if (!lastCommit) {
|
||||
return performFullSync(engine, fullSyncRoots, headCommit, opts);
|
||||
return performFullSyncAndMaybeGitignore(fullSyncRoots);
|
||||
}
|
||||
|
||||
// v0.42.x (#1794): resumable incremental sync — resolve the PINNED target.
|
||||
@@ -2147,7 +2186,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
`[sync] delta ${lastCommit.slice(0, 8)}..${pin.slice(0, 8)} unavailable ` +
|
||||
`(${delta.reason}) — falling back to full reconcile.`,
|
||||
);
|
||||
return performFullSync(engine, fullSyncRoots, headCommit, opts);
|
||||
return performFullSyncAndMaybeGitignore(fullSyncRoots);
|
||||
}
|
||||
const manifest = delta.manifest;
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'fs';
|
||||
import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
@@ -304,4 +304,24 @@ describe('#2964: sync auto-inits a never-git-initialized default brain dir', ()
|
||||
const tracked = execSync('git ls-files', { cwd: dir }).toString();
|
||||
expect(tracked).not.toContain('private-cache');
|
||||
});
|
||||
|
||||
test('a self-heal via bare performSync (mirrors cycle.ts:runPhaseSync — no runSync CLI wrapper) still writes .gitignore for db_only dirs (round 6 P2)', async () => {
|
||||
// The dream cycle calls performSync directly and never runs runSync's
|
||||
// post-success manageGitignoreAtGitRoot. Without performSyncInner doing
|
||||
// this itself after a self-heal, a brain that's only ever synced via
|
||||
// the dream cycle would have db_only content correctly excluded from
|
||||
// the baseline commit but .gitignore never written — leaving the
|
||||
// user's own future manual `git add`/`commit` unprotected.
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
const { mkdirSync } = await import('fs');
|
||||
mkdirSync(join(dir, 'private-cache'));
|
||||
writeFileSync(join(dir, 'private-cache', 'secret.bin'), 'binary-ish content');
|
||||
writeFileSync(join(dir, 'gbrain.yml'), 'storage:\n db_only:\n - private-cache\n');
|
||||
|
||||
const result = await performSync(engine, { noPull: true, noEmbed: true, full: true });
|
||||
|
||||
expect(result.status).toBe('first_sync');
|
||||
const gitignore = existsSync(join(dir, '.gitignore')) ? readFileSync(join(dir, '.gitignore'), 'utf-8') : '';
|
||||
expect(gitignore).toContain('private-cache');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user