mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
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:
committed by
Sina Matian
co-authored by
Nidhin Tamil
parent
a849d833eb
commit
0a1890bbf8
+100
-5
@@ -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)) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user