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'); + }); });