mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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:
co-authored by
Claude Sonnet 5
parent
27337b08c1
commit
54c1e6f4f6
+37
-8
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user