From 27337b08c1cd54bc86bc701cd5affa2e1ad97f31 Mon Sep 17 00:00:00 2001 From: masashiono0611 Date: Sun, 19 Jul 2026 03:07:15 +0900 Subject: [PATCH] fix(sync): post-heal .gitignore write, supabase_only alias, honor abort signal (#2964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01NCVEvUVm15bFuKqskWgfNS --- src/commands/sync.ts | 53 +++++++++++++++++++++++++++++----- test/sync-git-autoinit.test.ts | 22 +++++++++++++- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 034f60e39..26d6eeacf 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -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 { + 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 { + // 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'); + }); });