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:
masashiono0611
2026-07-19 03:07:15 +09:00
co-authored by Claude Sonnet 5
parent e68791361b
commit 27337b08c1
2 changed files with 67 additions and 8 deletions
+46 -7
View File
@@ -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;
+21 -1
View File
@@ -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');
});
});