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 <nidhin@ymail.com>
This commit is contained in:
Garry Tan
2026-08-12 14:38:36 -07:00
committed by Sina Matian
co-authored by Nidhin Tamil
parent a849d833eb
commit 0a1890bbf8
3 changed files with 202 additions and 5 deletions
+100 -5
View File
@@ -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<Sy
) {
throw err;
}
serr(`[gbrain] auto-recovery: git-initializing brain dir ${repoPath} (no git repo found).`);
git(repoPath, ['init', '--quiet']);
createSyncBaselineCommit(repoPath);
gitContextRoot = realpathSync(discoverGitRoot(repoPath));
// 2026-08-10 incident guard. `discoverGitRoot` is a 30s-bounded
// `git rev-parse --show-toplevel` that walks UP; it can throw for reasons
// OTHER than "no git repo" — a transient timeout on a large brain, or a
// concurrent `gbrain-sync` holding a git lock — on a directory that IS a
// git repo, whether the repo root is `repoPath` itself OR an ANCESTOR
// (subdir-anchored brain, the #753/#774 monorepo pattern). Trusting a
// single throw and running `git init` (a no-op reinit at repoPath, or a
// NEW nested repo shadowing the ancestor) + baseline-commit stacks a
// spurious auto-init commit and re-cases the tree on a case-insensitive
// filesystem. So do NOT self-heal on one throw — re-probe once:
// - re-probe SUCCEEDS => 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)) {
+52
View File
@@ -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');
});
});
+50
View File
@@ -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);
});
});