diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 0e857003b..55177421b 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -919,10 +919,10 @@ export function buildAutoEmbedArgs(slugs: string[], sourceId?: string): string[] * 100 MiB is generous but still bounded — a 100K-file diff with long * paths tops out around 10–20 MiB in practice. */ -function git(repoPath: string, args: string[], configs: string[] = []): string { +function git(repoPath: string, args: string[], configs: string[] = [], timeoutMs = 30000): string { return execFileSync('git', buildGitInvocation(repoPath, args, configs), { encoding: 'utf-8', - timeout: 30000, + timeout: timeoutMs, maxBuffer: 100 * 1024 * 1024, }).trim(); } @@ -943,6 +943,171 @@ export function discoverGitRoot(inputPath: string): string { } } +/** + * #2964: snapshot the CURRENT on-disk state of a gbrain-owned brain dir as + * a baseline commit — used both right after a self-healing `git init` (no + * `.git` at all) and to recover a repo left with `.git` but zero commits + * (an interrupted prior self-heal, or a `git init` from some other source + * that never got a first commit). Respects `.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 the full-sync pass that + * follows already imported them from disk directly. + * + * `--no-gpg-sign` + explicit `-c user.name/user.email`: this runs from a + * headless nightly cron/launchd invocation, which has no reason to have + * git signing/identity configured, and must not block on an unavailable + * signing agent or pinentry prompt. + * + * db_only exclusion is recomputed directly and passed to `git add` as + * negative pathspecs, rather than relying solely on `manageGitignore` + * having written `.gitignore` successfully: that helper is deliberately + * best-effort (a broken gbrain.yml parse, or an unwritable .gitignore, + * only warns and returns — the right default for its OTHER callers, where + * .gitignore management is a side effect that must never kill the sync + * job). For a commit we are about to create ourselves, "fail open" there + * would mean silently committing db_only content into git history. Fail + * closed instead: db_only exclusion doesn't depend on the .gitignore + * write having succeeded. `loadStorageConfig` throwing (unreadable + * gbrain.yml, or a semantic overlap) propagates — better to leave this + * self-heal wedged with a clear error than commit unknown content. + */ +function createSyncBaselineCommit(repoPath: string): void { + // #2964: db_only exclusion is computed directly from loadStorageConfig + // and passed to `git add` as pathspecs — deliberately NOT via + // manageGitignore/.gitignore, for two independent reasons: + // + // 1. Ordering (Codex review round 6, P1): `collectSyncableFiles` — the + // file enumeration `performFullSync` runs right after this function + // returns — honors `.gitignore` via `git ls-files --exclude-standard`. + // Writing db_only entries into `.gitignore` BEFORE that first import + // would silently exclude those pages from the database entirely. + // That's the exact bug class `runSync`'s existing "manage .gitignore + // ONLY on successful sync" ordering (this file, `manageGitignoreAtGitRoot` + // callers below — itself a prior Codex P1 fix) exists to prevent. Leave + // `.gitignore` untouched here; the existing post-sync flow writes it + // once this sync completes, same as it does for every other sync. + // 2. Fail-closed (rounds 5-6): `manageGitignore`'s "warn and return" on a + // broken gbrain.yml/unwritable .gitignore is the right default for its + // OTHER callers (a side effect that must never kill the sync job), but + // wrong for a commit we are creating ourselves — silently committing + // db_only content into git history. + const storageConfig = loadStorageConfig(repoPath); + const dbOnlyDirs = storageConfig?.db_only ?? []; + // Sniff-test fail-closed (round 6, P2): `loadStorageConfig` warns-and- + // returns an EMPTY config for syntactically-valid-but-unsupported YAML + // (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 (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". + // + // Known false-positive (round 8 review): a genuinely, intentionally + // empty `db_only: []` mentioning the word also refuses, and can't be + // told apart from the unsupported-syntax case — `loadStorageConfig` + // returns the IDENTICAL `{db_tracked:[],db_only:[]}` for both (verified + // directly: flow-style `[dir/]` and literal `[]` both collapse to that + // same shape). Distinguishing them would mean teaching this function + // about the parser's internal line-recognition rules, which belongs in + // storage-config.ts, not here. Accepted trade-off: the false-positive + // cost is low and self-resolving (the brain stays wedged with a clear, + // actionable error until the user drops the pointless empty stanza or + // fixes their syntax; retried on every subsequent sync); the + // false-negative this guards against — silently committing db_only + // content into permanent git history — is high-cost and hard to undo. + if (dbOnlyDirs.length === 0) { + const yamlPath = join(repoPath, 'gbrain.yml'); + const yamlContent = existsSync(yamlPath) ? readFileSync(yamlPath, 'utf-8') : ''; + // A YAML KEY line (`db_only:` / `supabase_only:`, ignoring leading + // whitespace and `#` comments), not a bare substring search — round 9, + // P2: a comment or unrelated prose value that happens to mention the + // word (e.g. `# db_only handling TBD`) must not trip this guard on an + // otherwise-genuinely-config-free gbrain.yml. + const mentionsUnresolvedKey = yamlContent.split('\n').some((line) => { + const trimmed = line.trim(); + return !trimmed.startsWith('#') && /^(db_only|supabase_only)\s*:/.test(trimmed); + }); + if (mentionsUnresolvedKey) { + 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"). ` + + `Fix gbrain.yml's storage.db_only syntax, or git-init this directory manually.`, + ); + } + } + // #2964 (round 9, P1): every db_only dir is ALWAYS pathspec-excluded, + // unconditionally — never pre-filtered against what an existing + // `.gitignore` claims to already cover. An earlier version checked + // `git check-ignore -q dir` first and skipped the pathspec when it + // already reported "ignored" (to dodge the advisory error below), but + // `check-ignore` on a directory can say "ignored" even when a + // pre-existing `.gitignore` re-includes a child via negation (e.g. + // `private-cache/*` + `!private-cache/index.md`) — the filter would + // then skip excluding it via pathspec, and `git add -A` would stage + // that re-included child despite the whole directory being declared + // db_only. Our OWN pathspec exclusion is unconditional and doesn't + // consult `.gitignore` at all, so it can't be defeated by ANY + // .gitignore content, negated or not. `:(exclude,literal)dir` (not the + // `:!dir` shorthand) so a db_only dir name that itself starts with a + // pathspec magic character like `:` is excluded literally rather than + // reinterpreted (round 9, P2). + const excludePathspecs = dbOnlyDirs.map((dir) => `:(exclude,literal)${dir}`); + // Clear the index before staging (round 6, P1): the unborn-HEAD + // recovery site can reach this function with a repo whose index + // already has entries staged from some OTHER prior operation (a manual + // `git add`, an interrupted workflow) before gbrain ever touched it. + // `add -A` only adds/updates — it does not drop an already-staged path + // that our exclusion pathspecs above now want excluded. `read-tree + // --empty` resets the index without touching the working tree; a + // no-op on a freshly-`git init`-ed repo, whose index is already empty. + git(repoPath, ['read-tree', '--empty']); + try { + // #2964: 10 minutes, not the shared git() helper's 30s default — this + // full-tree `git add -A` walks a legacy brain that may hold years of + // accumulated content. A 30s timeout would abort staging after `git + // init` already created `.git`, leaving an unborn repo that every + // subsequent sync would retry (and time out identically) forever; + // the unborn-HEAD recovery path exists for OTHER causes of that + // state, not to be this one's normal first outcome. + git(repoPath, ['add', '-A', '--', '.', ...excludePathspecs], [], 600_000); + } catch (err) { + // Now that exclusion is always applied (never pre-filtered), an + // explicit pathspec exclusion for a path a pre-existing `.gitignore` + // ALSO happens to cover trips git's advice.addIgnoredFile: nonzero + // exit + "paths ignored by one of your .gitignore files, use -f", + // even though the add otherwise fully succeeded (verified directly: + // `git status --short` right after this exact error shows every + // non-excluded path staged correctly). Recognize and swallow ONLY + // this exact advisory; anything else (timeout, permission denied, + // real corruption) rethrows. + const stderr = err && typeof err === 'object' && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : ''; + if (!stderr.includes('ignored by one of your .gitignore files')) throw err; + } + git( + repoPath, + // --no-verify only skips pre-commit/commit-msg — prepare-commit-msg + // and (worse, since it runs AFTER the commit object already exists, + // synchronously inside this same git invocation) post-commit are + // NOT covered by it. An operator's global core.hooksPath or + // init.templateDir can wire either, expecting project tooling, + // prompting interactively, or hanging — none of which a headless + // self-heal commit can satisfy, and a hanging post-commit hook would + // burn the 600s budget above without even being the slow step. + // `-c core.hooksPath=/dev/null` (in configs, below) makes git look + // for hook scripts inside a location that can't contain any, + // disabling the entire hooks path for this one invocation — the + // complete form of what --no-verify only partially covers, kept for + // explicitness on the two hooks it does name. + [ + 'commit', '--quiet', '--allow-empty', '--no-gpg-sign', '--no-verify', + '-m', 'gbrain: initial commit (auto-init by sync)', + ], + ['user.name=gbrain', 'user.email=gbrain@localhost', 'core.hooksPath=/dev/null'], + ); +} + /** * #774 NAV-1 TOCTOU: true only if filePath realpath-resolves inside gitRoot. * Guards symlink escape at the per-file level (a committed symlink whose @@ -1009,6 +1174,65 @@ async function readSyncAnchor( return await engine.getConfig(`sync.${which}`); } +/** + * #2964: is `repoPath` gbrain's own default-brain anchor, as opposed to a + * path some caller merely happened to pass through unchanged? + * + * `!opts.sourceId` alone is NOT sufficient — and neither is rejecting + * `opts.sourceId` outright: migration `sources_table_additive` (v20) + * seeds a `'default'` source row whose `local_path` is copied FROM + * `config.sync.repo_path` on every brain that has ever run it (i.e. + * effectively all of them by now), and `writeSyncAnchor` keeps that row's + * `local_path` current on every sync thereafter. So on a real installed + * brain, `resolveSourceForDir` (dream cycle) and the CLI's bare `gbrain + * sync` both resolve `sourceId: 'default'`, NOT `undefined` — rejecting + * all non-empty `sourceId` (an earlier, insufficiently-reviewed version + * of this check) made self-heal never fire on that real path either, + * masked in tests only because a freshly-`initSchema()`'d test brain's + * `'default'` row has a null `local_path` (Codex review round 5). + * + * The actual boundary: `'default'` is gbrain's own bootstrap identity, + * not something a caller names — a DIFFERENT, non-default `sourceId` is + * what an explicit `sources add --path ` registration (a + * user's own external directory) looks like, and that's what must keep + * failing loudly. So: permit `sourceId` when it's exactly `undefined` or + * `'default'`, reject any other id, and for BOTH permitted cases prove + * ownership by VALUE — reread the live anchor for that same identity + * (`sources.default.local_path` when sourceId='default', else + * `config.sync.repo_path`) and require the resolved `repoPath` to + * REALPATH-equal it (not raw string equality: `dream`'s `resolveBrainDir` + * normalizes via `path.resolve`, so a trailing slash or `..` in the + * stored anchor must not defeat the match — Codex review round 5, P2). + * An arbitrary caller-supplied path (e.g. an admin-scope + * `submit_job({name:'sync', data:{repoPath}})`) only passes this check + * if it already equals gbrain's own anchor by realpath identity — at + * which point self-healing it is exactly the legitimate case, not an + * escalation. + * + * `opts.srcSubpath` disqualifies unconditionally: a subpath-scoped sync + * only wants THAT subdirectory captured, but the self-heal baseline + * commit runs `git add -A` at the git root (there's no file list yet to + * scope it to — collection happens after this point) — see the P2 review + * finding on `createSyncBaselineCommit`'s callers. + */ +async function isAnchorOwnedSyncPath( + engine: BrainEngine, + opts: SyncOpts, + repoPath: string, +): Promise { + if (opts.srcSubpath) return false; + if (opts.sourceId && opts.sourceId !== 'default') return false; + const anchor = await readSyncAnchor(engine, opts.sourceId, 'repo_path'); + if (anchor === null) return false; + try { + return realpathSync(anchor) === realpathSync(repoPath); + } catch { + // Anchor or repoPath doesn't realpath-resolve (dangling/nonexistent) — + // can't prove identity, so don't self-heal. + return false; + } +} + async function writeSyncAnchor( engine: BrainEngine, sourceId: string | undefined, @@ -1628,7 +1852,33 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise --path + * ` registration of a user's own external directory looks like), + * AND the resolved `repoPath` must realpath-equal the LIVE anchor for + * that same identity. A caller-supplied path that does not match (a + * registered non-default source, or an admin-scope + * `submit_job({name:'sync', data:{repoPath}})` MCP call with an + * unrelated path) 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')); + // The self-heal-eligible anchor: gbrain's own persisted config, not a + // caller-supplied --repo / job.data.repoPath (those are proven by + // VALUE against this anchor, not by mere absence — see file docstring). + await engine.setConfig('sync.repo_path', dir); + }); + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + test('anchor-resolved sync (no repoPath, no sourceId) 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, { 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('explicit repoPath matching the anchor still auto-inits (mirrors gbrain dream\'s sync phase)', async () => { + // cycle.ts's runPhaseSync (the actual dream-cycle call site this bug + // was filed against) always passes `repoPath: brainDir` explicitly — + // it already resolved the anchor itself upstream and threads it + // through. Gating self-heal on `!opts.repoPath` would silently never + // fire here; ownership must be proven by matching the anchor's VALUE. + 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); + }); + + 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, { 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, { noPull: true, noEmbed: true }); + expect(second.status).not.toBe('first_sync'); + expect(second.added).toBe(0); + expect(second.modified).toBe(0); + }); + + test("sourceId='default' whose local_path mirrors the anchor still auto-inits (P1: the real installed-brain shape)", async () => { + // Migration sources_table_additive seeds a 'default' source row with + // local_path copied from sync.repo_path on every brain that's run it + // — i.e. this, not a bare no-sourceId call, is what runPhaseSync/CLI + // `gbrain sync` actually resolve to on a real installed brain. + await engine.executeRaw(`UPDATE sources SET local_path = $1 WHERE id = 'default'`, [dir]); + const { performSync } = await import('../src/commands/sync.ts'); + expect(existsSync(join(dir, '.git'))).toBe(false); + + const result = await performSync(engine, { + repoPath: dir, + sourceId: 'default', + noPull: true, + noEmbed: true, + full: true, + }); + + expect(result.status).toBe('first_sync'); + expect(result.added).toBe(2); + expect(existsSync(join(dir, '.git'))).toBe(true); + }); + + test('a registered non-default local source (sourceId != default, no remote_url) on a non-git dir still throws — not auto-inited', async () => { + await engine.executeRaw( + `INSERT INTO sources (id, name, local_path, config) VALUES ('mysource', 'mysource', $1, '{}'::jsonb)`, + [dir], + ); + const { performSync } = await import('../src/commands/sync.ts'); + await expect( + performSync(engine, { + repoPath: dir, + sourceId: 'mysource', + noPull: true, + noEmbed: true, + full: true, + }), + ).rejects.toThrow(/git repository/i); + expect(existsSync(join(dir, '.git'))).toBe(false); + }); + + test('a caller-supplied repoPath that does NOT match the anchor still throws (P1: MCP submit_job arbitrary-path guard)', async () => { + // Mirrors jobs.ts: submit_job({name:'sync', data:{repoPath}}) reaches + // performSyncInner with sourceId left undefined whenever repoPath + // doesn't match a registered source's local_path. Self-heal must not + // fire for a path that isn't gbrain's own anchor, even with no + // sourceId set — only exact anchor-value equality (the previous test) + // is eligible. + const other = mkdtempSync(join(tmpdir(), 'gbrain-2964-other-')); + writeFileSync(join(other, 'unrelated.md'), mdPage('Unrelated')); + try { + const { performSync } = await import('../src/commands/sync.ts'); + await expect( + performSync(engine, { repoPath: other, noPull: true, noEmbed: true, full: true }), + ).rejects.toThrow(/git repository/i); + expect(existsSync(join(other, '.git'))).toBe(false); + } finally { + rmSync(other, { recursive: true, force: true }); + } + }); + + test('--src-subpath on the anchor-resolved path still throws — not auto-inited (P2: subpath scope guard)', async () => { + // A self-heal baseline commit runs `git add -A` at the git root before + // any subpath-scoped file collection happens, so it would capture + // sibling directories a --src-subpath sync never intended to touch. + const { performSync } = await import('../src/commands/sync.ts'); + await expect( + performSync(engine, { + srcSubpath: 'wiki', + noPull: true, + noEmbed: true, + full: true, + }), + ).rejects.toThrow(/git repository/i); + expect(existsSync(join(dir, '.git'))).toBe(false); + }); + + test('--dry-run on the anchor-resolved path throws without writing anything to disk', async () => { + const { performSync } = await import('../src/commands/sync.ts'); + await expect( + performSync(engine, { repoPath: dir, dryRun: true, noPull: true, noEmbed: true, full: true }), + ).rejects.toThrow(/git repository/i); + // The whole point of --dry-run is "preview only" — it must never git-init + // or commit on our behalf, even though this is otherwise self-heal-eligible. + expect(existsSync(join(dir, '.git'))).toBe(false); + }); + + test('unborn-HEAD recovery: a bare `git init` with zero commits (interrupted prior self-heal) still completes', async () => { + const { performSync } = await import('../src/commands/sync.ts'); + const { execSync } = await import('child_process'); + // Simulate a self-heal that ran `git init` but died before the baseline + // commit landed (process killed, disk full, etc.) — `.git` exists so + // discoverGitRoot succeeds, but `git rev-parse HEAD` still fails. + execSync('git init -q', { cwd: dir }); + + 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(execSync('git rev-parse HEAD', { cwd: dir }).toString().trim()).not.toBe(''); + }); + + test('db_only paths are excluded from the baseline commit even without gbrain.yml write support (P2: fail-closed exclusion)', async () => { + const { performSync } = await import('../src/commands/sync.ts'); + const { mkdirSync } = await import('fs'); + const { execSync } = await import('child_process'); + 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', + ); + + await performSync(engine, { noPull: true, noEmbed: true, full: true }); + + expect(existsSync(join(dir, '.git'))).toBe(true); + const tracked = execSync('git ls-files', { cwd: dir }).toString(); + expect(tracked).not.toContain('private-cache'); + }); + + test('db_only exclusion applies even when a pre-existing .gitignore already covers the same dir (round 9 P1: unconditional pathspec)', async () => { + // Regression for the "check-ignore pre-filter" version of this logic: + // when a dir is ALSO already covered by an existing .gitignore, git's + // `-A` bails with an advisory "paths ignored... use -f" even though + // the add otherwise succeeds. Exclusion must be unconditional and the + // advisory must not surface as a hard failure. + const { performSync } = await import('../src/commands/sync.ts'); + const { mkdirSync } = await import('fs'); + const { execSync } = await import('child_process'); + 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'); + writeFileSync(join(dir, '.gitignore'), 'private-cache/\n'); + + const result = await performSync(engine, { noPull: true, noEmbed: true, full: true }); + + expect(result.status).toBe('first_sync'); + const tracked = execSync('git ls-files', { cwd: dir }).toString(); + expect(tracked).not.toContain('private-cache'); + }); + + test('a comment merely mentioning db_only does not false-positive the sniff test (round 9 P2)', async () => { + const { performSync } = await import('../src/commands/sync.ts'); + // No `storage:` section at all — just a comment mentioning the word. + // A bare substring search would wrongly refuse this brain forever. + writeFileSync(join(dir, 'gbrain.yml'), '# db_only handling: TBD, not configured yet\n'); + + const result = await performSync(engine, { noPull: true, noEmbed: true, full: true }); + + expect(result.status).toBe('first_sync'); + expect(result.added).toBe(2); + }); + + test('a gbrain.yml that mentions db_only but resolves no dirs refuses the baseline commit (round 6 P2: unsupported-syntax sniff test)', async () => { + const { performSync } = await import('../src/commands/sync.ts'); + const { execSync } = await import('child_process'); + // Flow-style array — valid YAML, but the narrow custom parser only + // handles block-style lists, so loadStorageConfig warns and resolves + // an empty db_only list rather than throwing. + writeFileSync(join(dir, 'gbrain.yml'), 'storage:\n db_only: [private-cache/]\n'); + + await expect( + performSync(engine, { noPull: true, noEmbed: true, full: true }), + ).rejects.toThrow(/db_only/i); + // `git init` (site 1's first step) already ran before the sniff-test + // guard (inside createSyncBaselineCommit) refused — that's fine, it's + // the same "unborn repo" state the round-6-P1 index-rebuild test above + // recovers from on a later retry, which would hit this same guard and + // refuse again until gbrain.yml is fixed. What must NOT happen is a + // commit landing with unknown/unexcluded content. + expect(existsSync(join(dir, '.git'))).toBe(true); + let hasCommit = true; + try { + execSync('git rev-parse HEAD', { cwd: dir, stdio: 'pipe' }); + } catch { + hasCommit = false; + } + expect(hasCommit).toBe(false); + }); + + test('unborn-HEAD recovery drops stale staged content the exclusion pathspec now wants excluded (round 6 P1: index rebuild)', async () => { + const { performSync } = await import('../src/commands/sync.ts'); + const { mkdirSync } = await import('fs'); + const { execSync } = await import('child_process'); + 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'); + // Simulate an interrupted workflow that left this file staged in an + // unborn repo BEFORE gbrain's self-heal ever ran. + execSync('git init -q', { cwd: dir }); + execSync('git add private-cache/secret.bin', { cwd: dir }); + + await performSync(engine, { noPull: true, noEmbed: true, full: true }); + + const tracked = execSync('git ls-files', { cwd: dir }).toString(); + expect(tracked).not.toContain('private-cache'); + }); + +});