fix(sync): neutralize a leftover .gitignore during the self-heal first sync (#2964)

Seventh Codex review round (27337b0) ran an actual repro and caught the
primary motivating scenario still broken: a brain rsync'd from another
machine without its .git can retain that machine's old auto-managed
.gitignore. collectSyncableFiles (inside performFullSync) enumerates via
`git ls-files --exclude-standard`, so a leftover db_only ignore rule
would silently omit those pages from THIS first sync's DATABASE import —
the same bug class the round-6 ordering fix prevented for a .gitignore
gbrain would have written itself, just triggered by a pre-existing file
this time.

Fix: performFullSyncAndMaybeGitignore now neutralizes any existing
.gitignore for the duration of the one first-sync call — read, delete,
restore byte-for-byte immediately after (even on error) — before
manageGitignore re-merges the managed db_only block onto the restored
original content. This matches exactly what a truly fresh brain with no
.gitignore at all already does on its first sync (nothing to suppress
collection there either); db_only content stays out of the git COMMIT
independently via createSyncBaselineCommit's pathspec exclusion, which
never depended on .gitignore.

Test proves both halves: db_only markdown IS imported despite a leftover
ignore rule, AND the user's own unrelated .gitignore lines (e.g.
.DS_Store) survive the restore intact.

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:17:51 +09:00
co-authored by Claude Sonnet 5
parent 27337b08c1
commit 54c1e6f4f6
2 changed files with 67 additions and 8 deletions
+37 -8
View File
@@ -1,4 +1,4 @@
import { existsSync, readFileSync, writeFileSync, statSync, realpathSync } from 'fs';
import { existsSync, readFileSync, writeFileSync, statSync, realpathSync, unlinkSync } from 'fs';
import { execFileSync } from 'child_process';
import { join, relative } from 'path';
import type { BrainEngine } from '../core/engine.ts';
@@ -2006,14 +2006,43 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
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);
// #2964 (round 7, P1 — Codex caught this with an actual repro run): a
// brain rsync'd from another machine without its `.git` can still
// retain that machine's auto-managed `.gitignore`. collectSyncableFiles
// (inside performFullSync) enumerates via `git ls-files
// --exclude-standard`, so a leftover db_only ignore rule would
// silently omit those pages from THIS first sync's DB import — the
// same data-loss bug class the ordering fix above prevents for a
// .gitignore gbrain itself would have written, just from a
// pre-existing file instead. Neutralize any existing .gitignore for
// the duration of this ONE first-sync call only, byte-for-byte
// restoring it immediately after (even on error) — matching exactly
// what a truly fresh brain with no .gitignore at all already does on
// its first sync (nothing to suppress collection there either).
// db_only content still stays out of the COMMIT independently
// (createSyncBaselineCommit's pathspec exclusion doesn't depend on
// .gitignore); manageGitignore below re-merges the managed block onto
// the restored original content afterward, so any of the user's own
// unrelated ignore rules in that file survive intact.
const gitignorePath = join(roots.gitContextRoot, '.gitignore');
const savedGitignore = didSelfHeal && existsSync(gitignorePath)
? readFileSync(gitignorePath, 'utf-8')
: null;
if (savedGitignore !== null) unlinkSync(gitignorePath);
try {
const result = await performFullSync(engine, roots, headCommit, opts);
if (savedGitignore !== null) writeFileSync(gitignorePath, savedGitignore);
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 roots, realpath-derived from it.
manageGitignore(roots.gitContextRoot, engine.kind);
}
return result;
} catch (err) {
if (savedGitignore !== null && !existsSync(gitignorePath)) writeFileSync(gitignorePath, savedGitignore);
throw err;
}
return result;
}
// #1970: bookmark reachability. The ONLY thing that should force a full
+30
View File
@@ -324,4 +324,34 @@ describe('#2964: sync auto-inits a never-git-initialized default brain dir', ()
const gitignore = existsSync(join(dir, '.gitignore')) ? readFileSync(join(dir, '.gitignore'), 'utf-8') : '';
expect(gitignore).toContain('private-cache');
});
test('a leftover .gitignore from before the brain lost its .git does not suppress db_only import (round 7 P1: rsync scenario)', async () => {
// The exact primary motivating scenario: a brain rsync'd from another
// machine keeps its OLD auto-managed .gitignore (already listing
// db_only dirs from before) even though `.git` itself never made the
// trip. Codex's round-7 review caught this with a live repro: without
// neutralizing the pre-existing file for this one first-sync call,
// collectSyncableFiles's `git ls-files --exclude-standard` silently
// omits db_only pages from the DATABASE — same bug class as round 6's
// ordering fix, just triggered by a file gbrain didn't write itself.
const { performSync } = await import('../src/commands/sync.ts');
const { mkdirSync } = await import('fs');
mkdirSync(join(dir, 'private-cache'));
writeFileSync(join(dir, 'private-cache', 'note.md'), mdPage('DB-only note'));
writeFileSync(join(dir, 'gbrain.yml'), 'storage:\n db_only:\n - private-cache\n');
// A leftover .gitignore with an UNRELATED custom rule too, proving the
// restore preserves the user's own content rather than discarding it.
writeFileSync(
join(dir, '.gitignore'),
'.DS_Store\n\n# Auto-managed by gbrain (db_only directories)\nprivate-cache/\n',
);
const result = await performSync(engine, { noPull: true, noEmbed: true, full: true });
expect(result.added).toBe(3); // page1, page2, private-cache/note
expect(await engine.getPage('private-cache/note')).not.toBeNull();
const gitignore = readFileSync(join(dir, '.gitignore'), 'utf-8');
expect(gitignore).toContain('.DS_Store');
expect(gitignore).toContain('private-cache');
});
});