From 0a1890bbf8c1980bf051cbb2a05b0b644b28cbf3 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 12 Aug 2026 11:18:32 -0700 Subject: [PATCH] fix(sync): never write a baseline commit over an already-populated repo (#3964) Wave-assembled from PR #3964 by @NidTamil. Co-Authored-By: Nidhin Tamil --- src/commands/sync.ts | 105 +++++++++++++++++++++++++++++-- test/sync-baseline-guard.test.ts | 52 +++++++++++++++ test/sync-git-autoinit.test.ts | 50 +++++++++++++++ 3 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 test/sync-baseline-guard.test.ts diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 09cd57910..b120ef625 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -1039,7 +1039,71 @@ export function discoverGitRoot(inputPath: string): string { * 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 { +/** + * Classify a caught `git rev-parse --verify --quiet HEAD` failure for the + * baseline-commit guard. A genuinely UNBORN HEAD makes git exit with status + * EXACTLY 1 and nothing on stderr — precisely what `--verify --quiet` emits + * for an unresolvable HEAD (verified empirically: unborn => exit 1, empty + * stderr; born => exit 0). Every OTHER failure shape — a 30s timeout (killed + * by signal, so `status` is null), an index/ref lock (`fatal: Unable to + * create ...lock`, non-empty stderr), or any nonzero-but-not-1 exit — is NOT + * proof the repo is empty; it is the transient-probe-failure class that + * corrupted the live brain on 2026-08-10. Return 'unborn' ONLY for the clean + * signal so the caller fails CLOSED on 'ambiguous'. Pure (no I/O) so the + * distinction is unit-testable without fault injection. + */ +export function classifyHeadProbeError(err: unknown): 'unborn' | 'ambiguous' { + const e = (err ?? {}) as { status?: number | null; signal?: string | null; stderr?: unknown }; + const stderr = e.stderr == null ? '' : String(e.stderr).trim(); + return e.status === 1 && e.signal == null && stderr === '' ? 'unborn' : 'ambiguous'; +} + +export function createSyncBaselineCommit(repoPath: string): void { + // Fail-closed backstop (2026-08-10 auto-init incident). This function's + // ENTIRE contract is "snapshot an unborn/uninitialized repo as its FIRST + // commit". It must NEVER run on a repo that already has commits: doing so + // stacks a spurious `gbrain: initial commit (auto-init by sync)` commit ON + // TOP of real history and, on a case-insensitive filesystem, re-cases the + // whole tree (`projects` -> `Projects`) as a `git add -A` side effect. + // + // Both call sites are *supposed* to reach here only on an unborn/non-git + // repo, but each infers "unborn" from a FAILURE to observe git state + // (`discoverGitRoot` threw / `git rev-parse HEAD` threw), and those probes + // ALSO fail transiently — a 30s timeout on a large brain, or a concurrent + // `gbrain-sync` holding a git lock — against a fully-populated repo, which + // is exactly what corrupted the live brain on 2026-08-10. So we cannot + // trust "the caller said it's unborn"; verify POSITIVELY here — and, since + // this very probe is subject to the same transient failures, accept ONLY a + // CLEAN unborn signal. A born HEAD (exit 0) OR an ambiguous probe failure + // (timeout / lock / other) both REFUSE, so the backstop is fail-closed + // against every corruption path, not just the born-HEAD one. + // `classifyHeadProbeError` isolates that born/unborn/ambiguous distinction + // as a pure, unit-tested predicate. + // + // Known non-incident edge (B2, documented not fixed): an orphan branch + // (`git switch --orphan`) in a repo with history elsewhere probes as unborn + // and would still be baselined. A gbrain brain is never in that state; it is + // not the incident (no stacking on real history, no re-case of other + // branches), so it is left as a limitation rather than complicating the + // guard with a `rev-list --all` "commits anywhere" probe. + let headState: 'born' | 'unborn' | 'ambiguous'; + try { + git(repoPath, ['rev-parse', '--verify', '--quiet', 'HEAD'], [], 30000, { silenceStderr: true }); + headState = 'born'; + } catch (err) { + headState = classifyHeadProbeError(err); + } + if (headState !== 'unborn') { + throw new Error( + `Refusing to create a sync baseline commit in ${repoPath}: HEAD probe is ` + + `'${headState}', expected a clean unborn HEAD. 'born' = the repo already ` + + `has commits (real history); 'ambiguous' = the HEAD check failed ` + + `transiently (30s timeout, or a concurrent gbrain-sync holding a git ` + + `lock), which is NOT proof the repo is empty. Committing either way ` + + `would stack a bogus auto-init commit on real history and re-case the ` + + `tree on a case-insensitive filesystem.`, + ); + } // #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: @@ -1975,10 +2039,41 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise the first throw was transient and the repo + // (own or ancestor) is real; use it, never init/commit. + // - re-probe THROWS but `.git` is present at repoPath => a real but + // unreadable repo (corrupt, broken gitlink, or a persistent transient) + // — NEVER init/commit over it; surface the original error. + // - re-probe THROWS and no `.git` at repoPath => genuinely not a git + // repo anywhere up the tree; self-heal. + // The createSyncBaselineCommit chokepoint is the fail-closed backstop if + // this ever reaches a baseline on a repo that turns out to have commits. + let reprobedRoot: string | null = null; + try { + reprobedRoot = discoverGitRoot(repoPath); + } catch { + reprobedRoot = null; + } + if (reprobedRoot !== null) { + gitContextRoot = realpathSync(reprobedRoot); + } else if (existsSync(join(repoPath, '.git'))) { + throw err; + } else { + serr(`[gbrain] auto-recovery: git-initializing brain dir ${repoPath} (no git repo found).`); + git(repoPath, ['init', '--quiet']); + createSyncBaselineCommit(repoPath); + gitContextRoot = realpathSync(discoverGitRoot(repoPath)); + } } const rawScopeRoot = opts.srcSubpath ? join(repoPath, opts.srcSubpath) : repoPath; if (!existsSync(rawScopeRoot)) { diff --git a/test/sync-baseline-guard.test.ts b/test/sync-baseline-guard.test.ts new file mode 100644 index 000000000..43466e496 --- /dev/null +++ b/test/sync-baseline-guard.test.ts @@ -0,0 +1,52 @@ +/** + * fork-patch #9 — the sync baseline-commit guard must distinguish a CLEAN + * unborn-HEAD signal from any ambiguous probe failure (timeout / lock / other) + * and fail CLOSED on ambiguity. `classifyHeadProbeError` isolates that decision + * as a pure predicate so the born/unborn/ambiguous distinction is unit-testable + * without fault injection. + * + * Empirical basis (verified on the installed git): `git rev-parse --verify + * --quiet HEAD` exits EXACTLY 1 with empty stderr on an unborn HEAD, exit 0 on + * a born HEAD. A 30s execFileSync timeout throws with `signal:'SIGTERM'` and + * `status:null`; an index/ref lock exits non-zero (e.g. 128) with a `fatal:` + * line on stderr. Only the clean exit-1 signal may be treated as unborn. + */ +import { describe, test, expect } from 'bun:test'; +import { classifyHeadProbeError } from '../src/commands/sync.ts'; + +describe('classifyHeadProbeError: clean-unborn vs ambiguous (fork-patch #9)', () => { + test("exit 1 + no signal + empty stderr => 'unborn'", () => { + expect(classifyHeadProbeError({ status: 1, signal: null, stderr: '' })).toBe('unborn'); + expect(classifyHeadProbeError({ status: 1, signal: null, stderr: Buffer.from('') })).toBe('unborn'); + // stderr that trims to empty is still the clean signal. + expect(classifyHeadProbeError({ status: 1, signal: null, stderr: ' \n' })).toBe('unborn'); + // a missing stderr field (undefined) with the clean exit is still unborn. + expect(classifyHeadProbeError({ status: 1, signal: null })).toBe('unborn'); + }); + + test("timeout kill (SIGTERM / status null) => 'ambiguous' (the incident class)", () => { + expect(classifyHeadProbeError({ status: null, signal: 'SIGTERM', stderr: '' })).toBe('ambiguous'); + expect(classifyHeadProbeError({ status: null, signal: 'SIGKILL', stderr: '' })).toBe('ambiguous'); + }); + + test("index/ref lock or any non-empty stderr => 'ambiguous'", () => { + expect( + classifyHeadProbeError({ status: 128, signal: null, stderr: "fatal: Unable to create '/x/.git/index.lock': File exists" }), + ).toBe('ambiguous'); + // even exit 1: if stderr is non-empty it is NOT the clean unborn signal. + expect(classifyHeadProbeError({ status: 1, signal: null, stderr: 'fatal: something' })).toBe('ambiguous'); + }); + + test("any nonzero-but-not-1 exit => 'ambiguous'", () => { + expect(classifyHeadProbeError({ status: 128, signal: null, stderr: '' })).toBe('ambiguous'); + expect(classifyHeadProbeError({ status: 129, signal: null, stderr: '' })).toBe('ambiguous'); + }); + + test('malformed / missing input never crashes and never claims unborn', () => { + expect(classifyHeadProbeError(undefined)).toBe('ambiguous'); + expect(classifyHeadProbeError(null)).toBe('ambiguous'); + expect(classifyHeadProbeError({})).toBe('ambiguous'); + expect(classifyHeadProbeError(new Error('boom'))).toBe('ambiguous'); + expect(classifyHeadProbeError('nope')).toBe('ambiguous'); + }); +}); diff --git a/test/sync-git-autoinit.test.ts b/test/sync-git-autoinit.test.ts index 01ee33615..ae0fd72ea 100644 --- a/test/sync-git-autoinit.test.ts +++ b/test/sync-git-autoinit.test.ts @@ -319,4 +319,54 @@ describe('#2964: sync auto-inits a never-git-initialized default brain dir', () expect(tracked).not.toContain('private-cache'); }); + test('createSyncBaselineCommit REFUSES to run on a repo that already has commits (2026-08-10 auto-init incident)', async () => { + // Live-incident regression. On 2026-08-10 a transient git-root probe + // failure (a concurrent gbrain-sync holding a git lock during a manual + // `sync --full`) drove the self-heal to `git init` (a no-op reinit on an + // already-initialized repo) + baseline-commit ON TOP of a fully-populated + // ~/brain, stacking two `gbrain: initial commit (auto-init by sync)` + // commits and re-casing projects/ -> Projects/ on the case-insensitive FS. + // The chokepoint must fail closed: a born HEAD means "not empty", never + // "snapshot me as the first commit". + const { execSync } = await import('child_process'); + const { mkdirSync } = await import('fs'); + // Populate with a lowercase projects/ dir (the case-flip victim) and make + // a REAL first commit so HEAD is born. + mkdirSync(join(dir, 'projects')); + writeFileSync(join(dir, 'projects', 'p.md'), mdPage('P')); + execSync('git init -q', { cwd: dir }); + execSync('git -c user.email=t@t.co -c user.name=t add -A', { cwd: dir }); + execSync('git -c user.email=t@t.co -c user.name=t commit -q -m real', { cwd: dir }); + const headBefore = execSync('git rev-parse HEAD', { cwd: dir }).toString().trim(); + const countBefore = Number(execSync('git rev-list --count HEAD', { cwd: dir }).toString().trim()); + + const { createSyncBaselineCommit } = await import('../src/commands/sync.ts'); + expect(() => createSyncBaselineCommit(dir)).toThrow(/already has commits|born/i); + + // No second commit landed; HEAD unmoved; projects/ stays lowercase. + expect(execSync('git rev-parse HEAD', { cwd: dir }).toString().trim()).toBe(headBefore); + expect(Number(execSync('git rev-list --count HEAD', { cwd: dir }).toString().trim())).toBe(countBefore); + const tracked = execSync('git ls-files', { cwd: dir }).toString(); + expect(tracked).toContain('projects/p.md'); + expect(tracked).not.toMatch(/(^|\n)Projects\//); + }); + + test('site-1 fail-closed: a repoPath with a broken .git gitlink (both probes throw) is refused, never init/committed', async () => { + // The fix's site-1 re-probe branch: `.git` is present at repoPath but + // unresolvable — here a gitlink FILE pointing at a nonexistent gitdir. + // `discoverGitRoot` throws, the re-probe throws again, `.git` exists => the + // original "not a git repository" error must propagate. A corrupt/wedged + // repo must NEVER be "healed" by `git init` + a baseline commit over it. + const { performSync } = await import('../src/commands/sync.ts'); + const { statSync } = await import('fs'); + writeFileSync(join(dir, '.git'), 'gitdir: /nonexistent-gitdir-xyz\n'); + + await expect( + performSync(engine, { repoPath: dir, noPull: true, noEmbed: true, full: true }), + ).rejects.toThrow(/git repository/i); + + // No self-heal occurred: `.git` is still our stub FILE, not an init'd dir. + expect(statSync(join(dir, '.git')).isFile()).toBe(true); + }); + });