mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* fix(sync): self-heal a never-git-initialized default brain dir (#2964) The dream cycle's sync phase throws unconditionally on a legacy sync.repo_path-anchored default brain dir that was never git init-ed (predates git-backed sync, or was rsync'd without its .git), failing every nightly run with no recovery. doctor's sync_freshness/ sync_consolidation checks report "ok" for this exact brain, but only because they query the sources table (0 rows for a legacy default brain) — a coincidental false-negative, not a real diagnosis. Self-heal by git-initializing the dir and capturing the current on-disk state as the sync baseline, scoped to !opts.sourceId only — gbrain owns this directory outright, unlike a registered local source (sources add --path, no --url) which is the user's own external directory and should keep failing loudly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCVEvUVm15bFuKqskWgfNS * fix(sync): dry-run no-write contract, unborn-HEAD recovery, no-gpg-sign (#2964) Codex review on the initial self-heal patch (b1671ee) found 3 real gaps: - P1: the self-heal ran even under --dry-run, mutating the filesystem during what's documented as a preview-only command. Gated the whole self-heal (both discoverGitRoot and the headCommit read) on !opts.dryRun, same as the existing !opts.sourceId ownership check. - P2: if `git init` succeeded but the process died before the baseline commit landed, the next run's discoverGitRoot would succeed (`.git` exists) and skip recovery entirely, permanently wedging on "No commits in repo" forever. Added the same self-heal at the `git rev-parse HEAD` catch site, sharing a new createSyncBaselineCommit helper with the discoverGitRoot catch. - P2: the baseline commit inherited the operator's global commit.gpgSign, which can block headless cron/launchd runs on an unavailable signing agent/pinentry. Added --no-gpg-sign. Two new tests cover dry-run no-mutation and unborn-HEAD recovery. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCVEvUVm15bFuKqskWgfNS * fix(sync): restrict git auto-init self-heal to the anchor-resolved path only (#2964) Second Codex review round (a10eeab) found the ownership check still too loose: - P1 (security): !opts.sourceId alone isn't proof gbrain owns repoPath. jobs.ts's `sync` job handler leaves sourceId undefined whenever job.data.repoPath doesn't match a registered source's local_path, so an admin-scope submit_job({name:'sync', data:{repoPath}}) MCP call could point the self-heal at an arbitrary directory and have it silently git-init + commit + ingest it. Gated both self-heal sites on !opts.repoPath too — only the path resolved from gbrain's own sync.repo_path anchor (never a caller-supplied one) is eligible. - P2: the unborn-HEAD recovery site calls discoverGitRoot, which walks UP from repoPath and can resolve to an ANCESTOR repo for a --src-subpath/subdir-as-repoPath sync with an unborn HEAD. Committing there would `git add -A` sibling files well outside the sync scope. Added a check that gitContextRoot === realpathSync(repoPath) before self-healing; refuses (falls through to the original error) otherwise. Tests rewritten to exercise the true self-heal-eligible path (anchor config via engine.setConfig('sync.repo_path', dir), no repoPath/sourceId passed) instead of an explicit repoPath, plus a new test asserting a caller-supplied repoPath with no sourceId still throws. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCVEvUVm15bFuKqskWgfNS * fix(sync): prove self-heal ownership by anchor VALUE, not field presence (#2964) Third Codex review round (613ad5b) found the previous round's fix broke the very call site it was meant to repair, plus a second scope gap: - P1 (critical): !opts.repoPath rejected self-heal on the REAL production callers too. runPhaseSync (dream cycle's sync phase, cycle.ts) always passes `repoPath: brainDir` explicitly after resolving it upstream, and the CLI's bare `gbrain sync` resolves sourceId='default'. Both made the ownership gate rethrow, leaving `gbrain dream` and `gbrain sync` wedged on the exact non-git legacy brain this fix targets — only synthetic callers that omitted both fields ever healed. Fixed by proving ownership by VALUE instead of by field absence: a new isAnchorOwnedSyncPath() re-reads gbrain's own persisted sync.repo_path config and requires the resolved repoPath to equal it exactly, regardless of whether the caller passed it explicitly or let it default. An attacker-supplied arbitrary path (e.g. via submit_job({name:'sync', data:{repoPath}})) only self-heals if it happens to already equal gbrain's own anchor — which is the legitimate case, not an escalation. opts.sourceId and opts.srcSubpath still disqualify unconditionally (registered/subpath-scoped syncs are a different ownership context). - P2: a --src-subpath sync with an unborn parent-repo HEAD would commit the whole ancestor root, capturing sibling files outside the scope. isAnchorOwnedSyncPath's opts.srcSubpath check closes this; the existing gitContextRoot === repoPath check stays as defense in depth. - P2: manageGitignore's "warn and return" contract (a deliberate side- effect that must never kill the sync job for its OTHER callers) meant a broken gbrain.yml or unwritable .gitignore would silently let the baseline `git add -A` commit db_only content. createSyncBaselineCommit now recomputes db_only exclusion directly from loadStorageConfig and passes it to `git add` as pathspecs, independent of the .gitignore write's success — true fail-closed. (A redundant pathspec exclude for a path .gitignore ALREADY covers makes git's -A bail with "paths ignored, use -f" even though the negation is correct, so each dir is check-ignore'd first and only pathspec-excluded when NOT already covered.) Tests rewritten around the anchor-VALUE model: the critical regression case (explicit repoPath matching the anchor still heals — the exact scenario Codex proved was broken) plus a true negative (a caller-supplied path that does NOT match the anchor still throws), --src-subpath refusal, and db_only fail-closed exclusion. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCVEvUVm15bFuKqskWgfNS * fix(sync): allow default-source ownership, realpath compare, generous timeout, --no-verify (#2964) Fourth Codex review round (9c1d461) found the previous round's ownership gate still didn't match the REAL installed-brain shape, plus 3 more gaps: - P1 (critical): rejecting all non-empty opts.sourceId meant self-heal still never fired on a real brain. Migration sources_table_additive seeds a 'default' source row whose local_path mirrors sync.repo_path on every brain that's run it (virtually all of them), so resolveSourceForDir (dream cycle) and bare `gbrain sync` both resolve sourceId:'default' in practice, never undefined. isAnchorOwnedSyncPath now permits sourceId undefined OR exactly 'default' (gbrain's own bootstrap identity, never something a caller names) and proves ownership by rereading the LIVE anchor for that same identity (sources.default.local_path vs config.sync.repo_path). - P2: compared raw anchor/repoPath strings, so a cosmetic difference (trailing slash, ..) between the stored anchor and dream.ts's path.resolve()-normalized brainDir would defeat the match. Now realpath-compares both sides (fail-closed on ENOENT/dangling). - P2: the shared git() helper's 30s timeout could abort the baseline `git add -A` on a large legacy brain mid-way, after `git init` already created `.git` — leaving an unborn repo every subsequent sync would retry and time out identically forever. Added an optional timeoutMs param (default unchanged at 30s); the baseline add call uses 10min. - P2: the baseline commit could trigger an operator's global core.hooksPath/init.templateDir hooks (pre-commit/commit-msg), breaking headless recovery if those hooks need project tooling or prompt. Added --no-verify. Tests: rewrote the mis-scoped "registered source" test (it used sourceId:'default', which is now correctly permitted) into two — a new regression test proving sourceId='default' + matching local_path heals (the actual production shape), and a corrected non-default-sourceId refusal test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCVEvUVm15bFuKqskWgfNS * fix(sync): defer .gitignore write past first import, rebuild index, fail closed on unparseable db_only (#2964) Fifth Codex review round (c17cd23) found the baseline-commit helper interacting badly with the pre-existing db_only storage-tiering feature: - P1 (data loss): createSyncBaselineCommit called manageGitignore BEFORE performFullSync's collectSyncableFiles ran. collectSyncableFiles enumerates via `git ls-files --cached --others --exclude-standard`, so writing db_only entries into .gitignore first would silently exclude those pages from the DATABASE, not just from git — on a brain's very first sync. This is the exact bug class runSync's existing "manage .gitignore ONLY on successful sync" ordering (this file, ~line 4540, itself a prior Codex P1 fix, comment literally says so) was written to prevent — my new code reintroduced it in a different spot. Fix: stopped calling manageGitignore inside the self-heal at all. db_only exclusion for the COMMIT still happens via the existing pathspec computation (independent of .gitignore); .gitignore itself gets written by the already-existing post-sync flow once this sync completes, same as any other sync. - P1 (data leak): the unborn-HEAD recovery site can reach createSyncBaselineCommit with a repo whose INDEX already has entries staged from some prior operation (manual `git add`, interrupted workflow) before gbrain's self-heal ever touched it. `git add -A` only adds/updates — it doesn't drop an already-staged path our exclusion pathspecs now want excluded. Added `git read-tree --empty` to reset the index before staging (no-op on a freshly-`git init`-ed repo, whose index is already empty). - P2: loadStorageConfig warns-and-returns an EMPTY config (not a throw) for syntactically-valid-but-unsupported YAML (e.g. flow-style `db_only: [dir/]` — the narrow custom parser only handles block-style lists), which would silently resolve zero exclusions from a gbrain.yml that clearly intended some. Added a sniff-test: if gbrain.yml exists and mentions db_only but nothing resolved from it, refuse the baseline commit rather than guess "genuinely empty" vs "syntax silently ignored" (git init may already have run by this point — same "unborn, retry on next sync" recovery path handles it, and will hit this same guard again until the user fixes gbrain.yml). Tests: a positive regression proving db_only markdown IS imported into the DB on first sync (the actual data-loss scenario), the sniff-test refusal, and the stale-staged-content-gets-dropped case for the index rebuild. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCVEvUVm15bFuKqskWgfNS * fix(sync): post-heal .gitignore write, supabase_only alias, honor abort signal (#2964) Sixth Codex review round (e687913), 3 P2s: - Dream-cycle callers (cycle.ts:runPhaseSync) invoke performSync directly and never run runSync's CLI-only post-success manageGitignoreAtGitRoot. A brain self-healed only via the dream cycle would have db_only content correctly excluded from the baseline commit (createSyncBaselineCommit's pathspec exclusion) but no .gitignore ever written, leaving the user's own future manual git add/commit unprotected. Added performFullSyncAndMaybeGitignore, a thin wrapper around the 3 post-self-heal performFullSync call sites that writes .gitignore (same success-status gate runSync already uses) only when didSelfHeal is true — a no-op for the normal path, which still relies on runSync exactly as before. - The fail-closed sniff-test only checked the canonical `db_only` key; the deprecated-but-still-supported `supabase_only` alias (same keep-out-of-git semantics) could silently bypass it. Now checks both. - Self-heal didn't check opts.signal?.aborted before starting the (now up to 10-minute) git init + baseline commit, so a cancelled sync could still mutate disk and overrun its budget instead of returning partial. Added the check at both self-heal sites, before any git operation runs. New test proves .gitignore gets written after a bare performSync call (no runSync wrapper) — the actual dream-cycle shape. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCVEvUVm15bFuKqskWgfNS * 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 * fix(sync): simplify — drop db_only-import machinery, isolate hooks fully (#2964) Eighth Codex review round (54c1e6f) found MORE problems with round 7's .gitignore-neutralization fix (deleting the whole file loses the user's own unrelated ignore rules; a multi-sync retry scenario could silently skip a still-broken db_only file while advancing the bookmark) plus 2 more issues in existing code. Rather than patch those too, stepped back and checked the actual documented semantics of db_only (docs/storage-tiering.md): it's for "bulk machine-generated content... written to disk as a local cache" — DB is the source of truth, disk is a cache populated FROM the DB (`export --restore-only` restores it), never the other way. Nothing in the docs says `gbrain sync`'s git-diff-based file collection is how db_only content is supposed to reach the database — that's ingest-specific tooling's job. Confirmed directly: `loadStorageConfig` returns the byte-identical `{db_tracked:[], db_only:[]}` for a malformed flow-style array AND a literal empty `db_only: []`, so rounds 6-7's "ensure db_only markdown gets imported on this first sync" chase was solving a problem outside sync's actual scope in the first place, on an increasingly complex, adversarially-discovered- edge-case foundation. Reverted: performFullSyncAndMaybeGitignore (the wrapper + didSelfHeal tracking + .gitignore neutralize/restore dance + post-success manageGitignore call). After self-heal, import and any subsequent .gitignore management now behave EXACTLY like any other brain, self-healed or not — runSync's existing post-success manageGitignoreAtGitRoot covers the CLI path identically either way; the dream cycle not calling it is a separate, pre-existing characteristic of the dream cycle in general (applies equally to an already-git-initialized brain going through the same path), not something this fix introduces. Kept (still correct, self-contained, don't depend on the reverted machinery): createSyncBaselineCommit's pathspec-based db_only exclusion for the COMMIT itself (matches the documented "not committed to git" requirement), the fail-closed sniff-test guard (now documents its known, structurally-unavoidable false-positive on a genuinely-empty `db_only: []` — the trade-off is deliberate: low-cost, self-resolving false positive vs. high-cost, hard-to-undo false negative), the index rebuild, and the 600s add timeout. Improved (round 8, P2): hooks isolation. --no-verify only skips pre-commit/commit-msg; added `-c core.hooksPath=/dev/null` for the baseline commit, which disables prepare-commit-msg and post-commit too (the latter runs synchronously inside the same git invocation and could otherwise hang past the timeout without even being the slow step). Tests: removed the 3 that exercised the reverted db_only-import machinery; the remaining 12 (ownership, dry-run, index rebuild, sniff test, commit-exclusion, unborn-HEAD recovery) are unaffected by the simplification. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCVEvUVm15bFuKqskWgfNS * fix(sync): unconditional db_only exclusion, literal pathspecs, precise sniff test (#2964) Ninth Codex review round (a6e07f6): - P1: the check-ignore pre-filter (skip pathspec-excluding a dir already covered by .gitignore) could be defeated by a pre-existing .gitignore that ignores a db_only tree with a wildcard but re-includes a child via negation (e.g. `private-cache/*` + `!private-cache/index.md`) — check-ignore on the directory still reports "ignored", so the filter skipped the pathspec exclusion, and `git add -A` staged the re-included child anyway. Fixed by making exclusion unconditional: every db_only dir is always pathspec-excluded now, never pre-filtered against .gitignore state at all — our own pathspec doesn't consult .gitignore, so no .gitignore content (negated or not) can defeat it. The advisory "paths ignored... use -f" error this can now trigger when a dir IS also already .gitignore'd (verified: git still stages everything else correctly despite the nonzero exit) is caught and swallowed by matching its exact stderr text; anything else rethrows. - P2: `:!dir` pathspec shorthand reinterprets a dir name that itself starts with a pathspec magic character (e.g. `:private/`) instead of excluding it literally. Switched to `:(exclude,literal)dir`. - P2: the fail-closed sniff-test's bare substring search on gbrain.yml's raw content could trip on a comment or unrelated prose mentioning "db_only" even when there's no real storage section at all, refusing self-heal forever on an unrelated false positive. Now requires an actual YAML key line (`db_only:`/`supabase_only:`, trimmed, ignoring `#` comments) — the round-8-documented "genuinely empty db_only: []" false positive is unchanged and remains an accepted trade-off (still structurally indistinguishable from unsupported syntax at the loadStorageConfig API boundary), but comment/prose mentions no longer false-positive. Not fixed (deliberately, documented trade-off — see PR description): Codex's other P1 this round (refuse baselining when other git refs/ history exist alongside an unborn HEAD) is a narrow, non-destructive scenario — self-heal only ever acts on the current branch ref when it's provably commit-less, never touches or deletes any other ref (remote- tracking, other branches), so at worst it creates a possibly-unexpected extra commit on an otherwise-empty branch the user hadn't checked out yet. Chasing it further trades diminishing real-world risk reduction against unbounded scope growth in what's fundamentally still the self-heal fix from round 1. Two new tests: unconditional exclusion despite a matching pre-existing .gitignore (proves the advisory-swallow path), and a comment-only gbrain.yml no longer false-positives the sniff test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCVEvUVm15bFuKqskWgfNS --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+299
-4
@@ -919,10 +919,10 @@ export function buildAutoEmbedArgs(slugs: string[], sourceId?: string): string[]
|
||||
* 100 MiB is generous but still bounded — a 100K-file diff with long
|
||||
* paths tops out around 10–20 MiB in practice.
|
||||
*/
|
||||
function git(repoPath: string, args: string[], configs: string[] = []): string {
|
||||
function git(repoPath: string, args: string[], configs: string[] = [], timeoutMs = 30000): string {
|
||||
return execFileSync('git', buildGitInvocation(repoPath, args, configs), {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 100 * 1024 * 1024,
|
||||
}).trim();
|
||||
}
|
||||
@@ -943,6 +943,171 @@ export function discoverGitRoot(inputPath: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #2964: snapshot the CURRENT on-disk state of a gbrain-owned brain dir as
|
||||
* a baseline commit — used both right after a self-healing `git init` (no
|
||||
* `.git` at all) and to recover a repo left with `.git` but zero commits
|
||||
* (an interrupted prior self-heal, or a `git init` from some other source
|
||||
* that never got a first commit). Respects `.gitignore` (written first) so
|
||||
* future incremental syncs diff against what's actually here rather than
|
||||
* an empty tree — an empty initial commit would make every existing file
|
||||
* look "added" again on the next sync, even though the full-sync pass that
|
||||
* follows already imported them from disk directly.
|
||||
*
|
||||
* `--no-gpg-sign` + explicit `-c user.name/user.email`: this runs from a
|
||||
* headless nightly cron/launchd invocation, which has no reason to have
|
||||
* git signing/identity configured, and must not block on an unavailable
|
||||
* signing agent or pinentry prompt.
|
||||
*
|
||||
* db_only exclusion is recomputed directly and passed to `git add` as
|
||||
* negative pathspecs, rather than relying solely on `manageGitignore`
|
||||
* having written `.gitignore` successfully: that helper is deliberately
|
||||
* best-effort (a broken gbrain.yml parse, or an unwritable .gitignore,
|
||||
* only warns and returns — the right default for its OTHER callers, where
|
||||
* .gitignore management is a side effect that must never kill the sync
|
||||
* job). For a commit we are about to create ourselves, "fail open" there
|
||||
* would mean silently committing db_only content into git history. Fail
|
||||
* closed instead: db_only exclusion doesn't depend on the .gitignore
|
||||
* write having succeeded. `loadStorageConfig` throwing (unreadable
|
||||
* 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 {
|
||||
// #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:
|
||||
//
|
||||
// 1. Ordering (Codex review round 6, P1): `collectSyncableFiles` — the
|
||||
// file enumeration `performFullSync` runs right after this function
|
||||
// returns — honors `.gitignore` via `git ls-files --exclude-standard`.
|
||||
// Writing db_only entries into `.gitignore` BEFORE that first import
|
||||
// would silently exclude those pages from the database entirely.
|
||||
// That's the exact bug class `runSync`'s existing "manage .gitignore
|
||||
// ONLY on successful sync" ordering (this file, `manageGitignoreAtGitRoot`
|
||||
// callers below — itself a prior Codex P1 fix) exists to prevent. Leave
|
||||
// `.gitignore` untouched here; the existing post-sync flow writes it
|
||||
// once this sync completes, same as it does for every other sync.
|
||||
// 2. Fail-closed (rounds 5-6): `manageGitignore`'s "warn and return" on a
|
||||
// broken gbrain.yml/unwritable .gitignore is the right default for its
|
||||
// OTHER callers (a side effect that must never kill the sync job), but
|
||||
// wrong for a commit we are creating ourselves — silently committing
|
||||
// db_only content into git history.
|
||||
const storageConfig = loadStorageConfig(repoPath);
|
||||
const dbOnlyDirs = storageConfig?.db_only ?? [];
|
||||
// Sniff-test fail-closed (round 6, P2): `loadStorageConfig` warns-and-
|
||||
// returns an EMPTY config for syntactically-valid-but-unsupported YAML
|
||||
// (e.g. flow-style `db_only: [dir/]` — the narrow custom parser only
|
||||
// handles block-style lists), which would silently resolve zero
|
||||
// exclusions from a file that clearly intended some. If gbrain.yml
|
||||
// exists and mentions db_only (or its deprecated pre-v0.22.11 alias
|
||||
// `supabase_only` — same keep-out-of-git semantics, still a supported
|
||||
// backward-compat key per storage-config.ts) but nothing resolved from
|
||||
// it, refuse rather than guess "genuinely empty" vs "syntax ignored".
|
||||
//
|
||||
// Known false-positive (round 8 review): a genuinely, intentionally
|
||||
// empty `db_only: []` mentioning the word also refuses, and can't be
|
||||
// told apart from the unsupported-syntax case — `loadStorageConfig`
|
||||
// returns the IDENTICAL `{db_tracked:[],db_only:[]}` for both (verified
|
||||
// directly: flow-style `[dir/]` and literal `[]` both collapse to that
|
||||
// same shape). Distinguishing them would mean teaching this function
|
||||
// about the parser's internal line-recognition rules, which belongs in
|
||||
// storage-config.ts, not here. Accepted trade-off: the false-positive
|
||||
// cost is low and self-resolving (the brain stays wedged with a clear,
|
||||
// actionable error until the user drops the pointless empty stanza or
|
||||
// fixes their syntax; retried on every subsequent sync); the
|
||||
// false-negative this guards against — silently committing db_only
|
||||
// content into permanent git history — is high-cost and hard to undo.
|
||||
if (dbOnlyDirs.length === 0) {
|
||||
const yamlPath = join(repoPath, 'gbrain.yml');
|
||||
const yamlContent = existsSync(yamlPath) ? readFileSync(yamlPath, 'utf-8') : '';
|
||||
// A YAML KEY line (`db_only:` / `supabase_only:`, ignoring leading
|
||||
// whitespace and `#` comments), not a bare substring search — round 9,
|
||||
// P2: a comment or unrelated prose value that happens to mention the
|
||||
// word (e.g. `# db_only handling TBD`) must not trip this guard on an
|
||||
// otherwise-genuinely-config-free gbrain.yml.
|
||||
const mentionsUnresolvedKey = yamlContent.split('\n').some((line) => {
|
||||
const trimmed = line.trim();
|
||||
return !trimmed.startsWith('#') && /^(db_only|supabase_only)\s*:/.test(trimmed);
|
||||
});
|
||||
if (mentionsUnresolvedKey) {
|
||||
throw new Error(
|
||||
`${yamlPath} mentions db_only but no directories resolved from it — refusing to ` +
|
||||
`auto-commit (cannot tell "genuinely empty" from "unsupported syntax silently ignored"). ` +
|
||||
`Fix gbrain.yml's storage.db_only syntax, or git-init this directory manually.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// #2964 (round 9, P1): every db_only dir is ALWAYS pathspec-excluded,
|
||||
// unconditionally — never pre-filtered against what an existing
|
||||
// `.gitignore` claims to already cover. An earlier version checked
|
||||
// `git check-ignore -q dir` first and skipped the pathspec when it
|
||||
// already reported "ignored" (to dodge the advisory error below), but
|
||||
// `check-ignore` on a directory can say "ignored" even when a
|
||||
// pre-existing `.gitignore` re-includes a child via negation (e.g.
|
||||
// `private-cache/*` + `!private-cache/index.md`) — the filter would
|
||||
// then skip excluding it via pathspec, and `git add -A` would stage
|
||||
// that re-included child despite the whole directory being declared
|
||||
// db_only. Our OWN pathspec exclusion is unconditional and doesn't
|
||||
// consult `.gitignore` at all, so it can't be defeated by ANY
|
||||
// .gitignore content, negated or not. `:(exclude,literal)dir` (not the
|
||||
// `:!dir` shorthand) so a db_only dir name that itself starts with a
|
||||
// pathspec magic character like `:` is excluded literally rather than
|
||||
// reinterpreted (round 9, P2).
|
||||
const excludePathspecs = dbOnlyDirs.map((dir) => `:(exclude,literal)${dir}`);
|
||||
// Clear the index before staging (round 6, P1): the unborn-HEAD
|
||||
// recovery site can reach this function with a repo whose index
|
||||
// already has entries staged from some OTHER prior operation (a manual
|
||||
// `git add`, an interrupted workflow) before gbrain ever touched it.
|
||||
// `add -A` only adds/updates — it does not drop an already-staged path
|
||||
// that our exclusion pathspecs above now want excluded. `read-tree
|
||||
// --empty` resets the index without touching the working tree; a
|
||||
// no-op on a freshly-`git init`-ed repo, whose index is already empty.
|
||||
git(repoPath, ['read-tree', '--empty']);
|
||||
try {
|
||||
// #2964: 10 minutes, not the shared git() helper's 30s default — this
|
||||
// full-tree `git add -A` walks a legacy brain that may hold years of
|
||||
// accumulated content. A 30s timeout would abort staging after `git
|
||||
// init` already created `.git`, leaving an unborn repo that every
|
||||
// subsequent sync would retry (and time out identically) forever;
|
||||
// the unborn-HEAD recovery path exists for OTHER causes of that
|
||||
// state, not to be this one's normal first outcome.
|
||||
git(repoPath, ['add', '-A', '--', '.', ...excludePathspecs], [], 600_000);
|
||||
} catch (err) {
|
||||
// Now that exclusion is always applied (never pre-filtered), an
|
||||
// explicit pathspec exclusion for a path a pre-existing `.gitignore`
|
||||
// ALSO happens to cover trips git's advice.addIgnoredFile: nonzero
|
||||
// exit + "paths ignored by one of your .gitignore files, use -f",
|
||||
// even though the add otherwise fully succeeded (verified directly:
|
||||
// `git status --short` right after this exact error shows every
|
||||
// non-excluded path staged correctly). Recognize and swallow ONLY
|
||||
// this exact advisory; anything else (timeout, permission denied,
|
||||
// real corruption) rethrows.
|
||||
const stderr = err && typeof err === 'object' && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : '';
|
||||
if (!stderr.includes('ignored by one of your .gitignore files')) throw err;
|
||||
}
|
||||
git(
|
||||
repoPath,
|
||||
// --no-verify only skips pre-commit/commit-msg — prepare-commit-msg
|
||||
// and (worse, since it runs AFTER the commit object already exists,
|
||||
// synchronously inside this same git invocation) post-commit are
|
||||
// NOT covered by it. An operator's global core.hooksPath or
|
||||
// init.templateDir can wire either, expecting project tooling,
|
||||
// prompting interactively, or hanging — none of which a headless
|
||||
// self-heal commit can satisfy, and a hanging post-commit hook would
|
||||
// burn the 600s budget above without even being the slow step.
|
||||
// `-c core.hooksPath=/dev/null` (in configs, below) makes git look
|
||||
// for hook scripts inside a location that can't contain any,
|
||||
// disabling the entire hooks path for this one invocation — the
|
||||
// complete form of what --no-verify only partially covers, kept for
|
||||
// explicitness on the two hooks it does name.
|
||||
[
|
||||
'commit', '--quiet', '--allow-empty', '--no-gpg-sign', '--no-verify',
|
||||
'-m', 'gbrain: initial commit (auto-init by sync)',
|
||||
],
|
||||
['user.name=gbrain', 'user.email=gbrain@localhost', 'core.hooksPath=/dev/null'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* #774 NAV-1 TOCTOU: true only if filePath realpath-resolves inside gitRoot.
|
||||
* Guards symlink escape at the per-file level (a committed symlink whose
|
||||
@@ -1009,6 +1174,65 @@ async function readSyncAnchor(
|
||||
return await engine.getConfig(`sync.${which}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* #2964: is `repoPath` gbrain's own default-brain anchor, as opposed to a
|
||||
* path some caller merely happened to pass through unchanged?
|
||||
*
|
||||
* `!opts.sourceId` alone is NOT sufficient — and neither is rejecting
|
||||
* `opts.sourceId` outright: migration `sources_table_additive` (v20)
|
||||
* seeds a `'default'` source row whose `local_path` is copied FROM
|
||||
* `config.sync.repo_path` on every brain that has ever run it (i.e.
|
||||
* effectively all of them by now), and `writeSyncAnchor` keeps that row's
|
||||
* `local_path` current on every sync thereafter. So on a real installed
|
||||
* brain, `resolveSourceForDir` (dream cycle) and the CLI's bare `gbrain
|
||||
* sync` both resolve `sourceId: 'default'`, NOT `undefined` — rejecting
|
||||
* all non-empty `sourceId` (an earlier, insufficiently-reviewed version
|
||||
* of this check) made self-heal never fire on that real path either,
|
||||
* masked in tests only because a freshly-`initSchema()`'d test brain's
|
||||
* `'default'` row has a null `local_path` (Codex review round 5).
|
||||
*
|
||||
* The actual boundary: `'default'` is gbrain's own bootstrap identity,
|
||||
* not something a caller names — a DIFFERENT, non-default `sourceId` is
|
||||
* what an explicit `sources add <id> --path <dir>` registration (a
|
||||
* user's own external directory) looks like, and that's what must keep
|
||||
* failing loudly. So: permit `sourceId` when it's exactly `undefined` or
|
||||
* `'default'`, reject any other id, and for BOTH permitted cases prove
|
||||
* ownership by VALUE — reread the live anchor for that same identity
|
||||
* (`sources.default.local_path` when sourceId='default', else
|
||||
* `config.sync.repo_path`) and require the resolved `repoPath` to
|
||||
* REALPATH-equal it (not raw string equality: `dream`'s `resolveBrainDir`
|
||||
* normalizes via `path.resolve`, so a trailing slash or `..` in the
|
||||
* stored anchor must not defeat the match — Codex review round 5, P2).
|
||||
* An arbitrary caller-supplied path (e.g. an admin-scope
|
||||
* `submit_job({name:'sync', data:{repoPath}})`) only passes this check
|
||||
* if it already equals gbrain's own anchor by realpath identity — at
|
||||
* which point self-healing it is exactly the legitimate case, not an
|
||||
* escalation.
|
||||
*
|
||||
* `opts.srcSubpath` disqualifies unconditionally: a subpath-scoped sync
|
||||
* only wants THAT subdirectory captured, but the self-heal baseline
|
||||
* commit runs `git add -A` at the git root (there's no file list yet to
|
||||
* scope it to — collection happens after this point) — see the P2 review
|
||||
* finding on `createSyncBaselineCommit`'s callers.
|
||||
*/
|
||||
async function isAnchorOwnedSyncPath(
|
||||
engine: BrainEngine,
|
||||
opts: SyncOpts,
|
||||
repoPath: string,
|
||||
): Promise<boolean> {
|
||||
if (opts.srcSubpath) return false;
|
||||
if (opts.sourceId && opts.sourceId !== 'default') return false;
|
||||
const anchor = await readSyncAnchor(engine, opts.sourceId, 'repo_path');
|
||||
if (anchor === null) return false;
|
||||
try {
|
||||
return realpathSync(anchor) === realpathSync(repoPath);
|
||||
} catch {
|
||||
// Anchor or repoPath doesn't realpath-resolve (dangling/nonexistent) —
|
||||
// can't prove identity, so don't self-heal.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSyncAnchor(
|
||||
engine: BrainEngine,
|
||||
sourceId: string | undefined,
|
||||
@@ -1628,7 +1852,33 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
// - syncScopeRoot: file walking, imports, deletes, renames
|
||||
// In the common case (repoPath == git root, no subpath) they are identical.
|
||||
serr(`[gbrain phase] sync.discover_git_root`);
|
||||
const gitContextRoot = realpathSync(discoverGitRoot(repoPath));
|
||||
// #2964: a legacy `sync.repo_path`-anchored default brain can reach here
|
||||
// having never been `git init`-ed — e.g. a brain-pages dir that predates
|
||||
// git-backed sync, or one rsync'd from another machine without its
|
||||
// `.git`. gbrain owns that directory outright, so self-heal by
|
||||
// initializing it in place instead of failing the sync phase every
|
||||
// single run. Mirrors the recloneIfMissing self-recovery above for
|
||||
// owned remote clones. Ownership is proven by VALUE (resolved repoPath
|
||||
// equals gbrain's persisted anchor) via `isAnchorOwnedSyncPath`, not by
|
||||
// the mere absence of `opts.sourceId`/`opts.repoPath` — see that
|
||||
// function's docstring. `!opts.dryRun`: a preview must never write.
|
||||
let gitContextRoot: string;
|
||||
try {
|
||||
gitContextRoot = realpathSync(discoverGitRoot(repoPath));
|
||||
} catch (err) {
|
||||
if (
|
||||
opts.dryRun ||
|
||||
opts.signal?.aborted ||
|
||||
!existsSync(repoPath) ||
|
||||
!(await isAnchorOwnedSyncPath(engine, opts, repoPath))
|
||||
) {
|
||||
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));
|
||||
}
|
||||
const rawScopeRoot = opts.srcSubpath ? join(repoPath, opts.srcSubpath) : repoPath;
|
||||
if (!existsSync(rawScopeRoot)) {
|
||||
throw new Error(`Sync scope does not exist: ${rawScopeRoot}`);
|
||||
@@ -1753,9 +2003,54 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
try {
|
||||
headCommit = git(gitContextRoot, ['rev-parse', 'HEAD']);
|
||||
} catch {
|
||||
throw new Error(`No commits in repo ${repoPath}. Make at least one commit before syncing.`);
|
||||
// #2964: unborn-HEAD recovery. `.git` exists (discoverGitRoot succeeded
|
||||
// above) but there are zero commits — e.g. a prior self-heal `git init`
|
||||
// ran but the process died before the baseline commit landed, leaving
|
||||
// this brain permanently wedged on "No commits in repo" every night
|
||||
// thereafter. Finish the same baseline-commit self-heal the
|
||||
// discoverGitRoot catch above would have done, gated the same way
|
||||
// (ownership proven by value, never on a dry-run preview) PLUS a scope
|
||||
// check: `discoverGitRoot` walks UP from `repoPath`, so it can resolve
|
||||
// to an ANCESTOR repo, not `repoPath` itself (most plausible for a
|
||||
// `--src-subpath` sync, but `isAnchorOwnedSyncPath` already refuses
|
||||
// that case — kept here too as defense in depth against any other path
|
||||
// where gitContextRoot could diverge from repoPath). Committing at an
|
||||
// ancestor (`git add -A` at gitContextRoot) would capture sibling
|
||||
// files well outside the sync scope — refuse instead of guessing.
|
||||
if (
|
||||
opts.dryRun ||
|
||||
opts.signal?.aborted ||
|
||||
gitContextRoot !== realpathSync(repoPath) ||
|
||||
!(await isAnchorOwnedSyncPath(engine, opts, repoPath))
|
||||
) {
|
||||
throw new Error(`No commits in repo ${repoPath}. Make at least one commit before syncing.`);
|
||||
}
|
||||
serr(`[gbrain] auto-recovery: repo has no commits yet, creating baseline commit ${gitContextRoot}.`);
|
||||
createSyncBaselineCommit(gitContextRoot);
|
||||
headCommit = git(gitContextRoot, ['rev-parse', 'HEAD']);
|
||||
}
|
||||
|
||||
// #2964: self-heal deliberately does NOT special-case db_only/.gitignore
|
||||
// interaction beyond the COMMIT itself (createSyncBaselineCommit's
|
||||
// pathspec exclusion, which stands on its own regardless of what
|
||||
// .gitignore says). db_only content is documented as DB-sourced ("bulk
|
||||
// machine-generated content... written to disk as a local cache", see
|
||||
// docs/storage-tiering.md) — it reaches the database via ingest-specific
|
||||
// paths, never via gbrain sync's git-diff-based file collection, and
|
||||
// `.gitignore` management there is entirely about keeping db_only out of
|
||||
// git history, not about what sync imports. An earlier version of this
|
||||
// fix (Codex review rounds 6-7) tried to also guarantee db_only markdown
|
||||
// gets imported on this first sync and that .gitignore gets written
|
||||
// post-success even when called outside runSync — solving a problem
|
||||
// that, per the docs above, isn't actually in scope for what sync is
|
||||
// for. Reverted in round 8 review discussion in favor of this simpler
|
||||
// design: after self-heal, the import + any subsequent .gitignore
|
||||
// management behave EXACTLY the same as for any other brain, self-healed
|
||||
// or not (runSync's existing post-success manageGitignoreAtGitRoot call
|
||||
// covers the CLI path identically either way; the dream cycle not
|
||||
// calling it is a separate, pre-existing characteristic of the dream
|
||||
// cycle in general, not something this fix introduces or worsens).
|
||||
|
||||
// #1970: bookmark reachability. The ONLY thing that should force a full
|
||||
// reconcile is a truly-absent object; a present-but-non-ancestor bookmark
|
||||
// (history rewrite: force-push, master→main consolidation, squash) is still
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* #2964 — sync phase self-heals a never-git-initialized default brain dir.
|
||||
*
|
||||
* A legacy `sync.repo_path`-anchored default brain can reach `performSync`
|
||||
* pointed at a directory that was never `git init`-ed (predates git-backed
|
||||
* sync, or was rsync'd from another machine without its `.git`). Before
|
||||
* this fix, `discoverGitRoot` threw unconditionally and the dream cycle's
|
||||
* sync phase failed every night with no self-recovery, even though
|
||||
* `doctor`'s sync checks reported "ok" (for an unrelated reason — they
|
||||
* only look at the `sources` table in a way this brain shape doesn't hit).
|
||||
*
|
||||
* gbrain owns that directory outright, so the fix self-heals by `git
|
||||
* init`-ing it and capturing the current on-disk state as the sync
|
||||
* baseline. Ownership is proven by VALUE — the resolved `repoPath` must
|
||||
* realpath-equal gbrain's own anchor — not by whether
|
||||
* `opts.repoPath`/`opts.sourceId` happen to be set:
|
||||
*
|
||||
* - Gating on `!opts.repoPath` (round 3) would have made self-heal never
|
||||
* fire on `runPhaseSync` (dream cycle), which always resolves the
|
||||
* anchor itself and passes it through explicitly as `opts.repoPath`.
|
||||
* - Gating on `!opts.sourceId` (round 4) would ALSO never fire in
|
||||
* practice: migration `sources_table_additive` seeds a `'default'`
|
||||
* source row whose `local_path` mirrors `sync.repo_path` on every
|
||||
* brain that's run it (i.e. virtually all installed brains today), so
|
||||
* both the dream cycle and bare `gbrain sync` resolve
|
||||
* `sourceId: 'default'`, never `undefined`, in reality — a fresh test
|
||||
* brain's null `local_path` masked this (Codex review round 5).
|
||||
*
|
||||
* The actual boundary implemented by `isAnchorOwnedSyncPath`: `sourceId`
|
||||
* must be `undefined` OR exactly `'default'` (gbrain's own bootstrap
|
||||
* identity — a DIFFERENT id is what an explicit `sources add <id> --path
|
||||
* <dir>` registration of a user's own external directory looks like),
|
||||
* AND the resolved `repoPath` must realpath-equal the LIVE anchor for
|
||||
* that same identity. A caller-supplied path that does not match (a
|
||||
* registered non-default source, or an admin-scope
|
||||
* `submit_job({name:'sync', data:{repoPath}})` MCP call with an
|
||||
* unrelated path) must keep failing loudly rather than being silently
|
||||
* git-initialized without consent.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
|
||||
function mdPage(title: string, body = 'Content.'): string {
|
||||
return `---\ntype: note\ntitle: ${title}\n---\n\n${body}`;
|
||||
}
|
||||
|
||||
describe('#2964: sync auto-inits a never-git-initialized default brain dir', () => {
|
||||
let engine: PGLiteEngine;
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
}, 60_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
dir = mkdtempSync(join(tmpdir(), 'gbrain-2964-'));
|
||||
writeFileSync(join(dir, 'page1.md'), mdPage('Page 1'));
|
||||
writeFileSync(join(dir, 'page2.md'), mdPage('Page 2'));
|
||||
// The self-heal-eligible anchor: gbrain's own persisted config, not a
|
||||
// caller-supplied --repo / job.data.repoPath (those are proven by
|
||||
// VALUE against this anchor, not by mere absence — see file docstring).
|
||||
await engine.setConfig('sync.repo_path', dir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('anchor-resolved sync (no repoPath, no sourceId) on a non-git dir auto-inits git and imports files', async () => {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
expect(existsSync(join(dir, '.git'))).toBe(false);
|
||||
|
||||
const result = await performSync(engine, { noPull: true, noEmbed: true, full: true });
|
||||
|
||||
expect(result.status).toBe('first_sync');
|
||||
expect(result.added).toBe(2);
|
||||
expect(existsSync(join(dir, '.git'))).toBe(true);
|
||||
expect(await engine.getPage('page1')).not.toBeNull();
|
||||
expect(await engine.getPage('page2')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('explicit repoPath matching the anchor still auto-inits (mirrors gbrain dream\'s sync phase)', async () => {
|
||||
// cycle.ts's runPhaseSync (the actual dream-cycle call site this bug
|
||||
// was filed against) always passes `repoPath: brainDir` explicitly —
|
||||
// it already resolved the anchor itself upstream and threads it
|
||||
// through. Gating self-heal on `!opts.repoPath` would silently never
|
||||
// fire here; ownership must be proven by matching the anchor's VALUE.
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
expect(existsSync(join(dir, '.git'))).toBe(false);
|
||||
|
||||
const result = await performSync(engine, { repoPath: dir, noPull: true, noEmbed: true, full: true });
|
||||
|
||||
expect(result.status).toBe('first_sync');
|
||||
expect(result.added).toBe(2);
|
||||
expect(existsSync(join(dir, '.git'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a second sync after auto-init sees no changes (baseline commit captured current on-disk state)', async () => {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
const first = await performSync(engine, { noPull: true, noEmbed: true, full: true });
|
||||
expect(first.added).toBe(2);
|
||||
|
||||
// No new files, no explicit `full` — a real incremental sync against the
|
||||
// auto-init baseline. Before this fix there was no baseline to diff
|
||||
// against (sync errored outright); a naive fix that skipped the initial
|
||||
// commit would make this call re-report both files as "added" again.
|
||||
const second = await performSync(engine, { noPull: true, noEmbed: true });
|
||||
expect(second.status).not.toBe('first_sync');
|
||||
expect(second.added).toBe(0);
|
||||
expect(second.modified).toBe(0);
|
||||
});
|
||||
|
||||
test("sourceId='default' whose local_path mirrors the anchor still auto-inits (P1: the real installed-brain shape)", async () => {
|
||||
// Migration sources_table_additive seeds a 'default' source row with
|
||||
// local_path copied from sync.repo_path on every brain that's run it
|
||||
// — i.e. this, not a bare no-sourceId call, is what runPhaseSync/CLI
|
||||
// `gbrain sync` actually resolve to on a real installed brain.
|
||||
await engine.executeRaw(`UPDATE sources SET local_path = $1 WHERE id = 'default'`, [dir]);
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
expect(existsSync(join(dir, '.git'))).toBe(false);
|
||||
|
||||
const result = await performSync(engine, {
|
||||
repoPath: dir,
|
||||
sourceId: 'default',
|
||||
noPull: true,
|
||||
noEmbed: true,
|
||||
full: true,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('first_sync');
|
||||
expect(result.added).toBe(2);
|
||||
expect(existsSync(join(dir, '.git'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a registered non-default local source (sourceId != default, no remote_url) on a non-git dir still throws — not auto-inited', async () => {
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config) VALUES ('mysource', 'mysource', $1, '{}'::jsonb)`,
|
||||
[dir],
|
||||
);
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
await expect(
|
||||
performSync(engine, {
|
||||
repoPath: dir,
|
||||
sourceId: 'mysource',
|
||||
noPull: true,
|
||||
noEmbed: true,
|
||||
full: true,
|
||||
}),
|
||||
).rejects.toThrow(/git repository/i);
|
||||
expect(existsSync(join(dir, '.git'))).toBe(false);
|
||||
});
|
||||
|
||||
test('a caller-supplied repoPath that does NOT match the anchor still throws (P1: MCP submit_job arbitrary-path guard)', async () => {
|
||||
// Mirrors jobs.ts: submit_job({name:'sync', data:{repoPath}}) reaches
|
||||
// performSyncInner with sourceId left undefined whenever repoPath
|
||||
// doesn't match a registered source's local_path. Self-heal must not
|
||||
// fire for a path that isn't gbrain's own anchor, even with no
|
||||
// sourceId set — only exact anchor-value equality (the previous test)
|
||||
// is eligible.
|
||||
const other = mkdtempSync(join(tmpdir(), 'gbrain-2964-other-'));
|
||||
writeFileSync(join(other, 'unrelated.md'), mdPage('Unrelated'));
|
||||
try {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
await expect(
|
||||
performSync(engine, { repoPath: other, noPull: true, noEmbed: true, full: true }),
|
||||
).rejects.toThrow(/git repository/i);
|
||||
expect(existsSync(join(other, '.git'))).toBe(false);
|
||||
} finally {
|
||||
rmSync(other, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('--src-subpath on the anchor-resolved path still throws — not auto-inited (P2: subpath scope guard)', async () => {
|
||||
// A self-heal baseline commit runs `git add -A` at the git root before
|
||||
// any subpath-scoped file collection happens, so it would capture
|
||||
// sibling directories a --src-subpath sync never intended to touch.
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
await expect(
|
||||
performSync(engine, {
|
||||
srcSubpath: 'wiki',
|
||||
noPull: true,
|
||||
noEmbed: true,
|
||||
full: true,
|
||||
}),
|
||||
).rejects.toThrow(/git repository/i);
|
||||
expect(existsSync(join(dir, '.git'))).toBe(false);
|
||||
});
|
||||
|
||||
test('--dry-run on the anchor-resolved path throws without writing anything to disk', async () => {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
await expect(
|
||||
performSync(engine, { repoPath: dir, dryRun: true, noPull: true, noEmbed: true, full: true }),
|
||||
).rejects.toThrow(/git repository/i);
|
||||
// The whole point of --dry-run is "preview only" — it must never git-init
|
||||
// or commit on our behalf, even though this is otherwise self-heal-eligible.
|
||||
expect(existsSync(join(dir, '.git'))).toBe(false);
|
||||
});
|
||||
|
||||
test('unborn-HEAD recovery: a bare `git init` with zero commits (interrupted prior self-heal) still completes', async () => {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
const { execSync } = await import('child_process');
|
||||
// Simulate a self-heal that ran `git init` but died before the baseline
|
||||
// commit landed (process killed, disk full, etc.) — `.git` exists so
|
||||
// discoverGitRoot succeeds, but `git rev-parse HEAD` still fails.
|
||||
execSync('git init -q', { cwd: dir });
|
||||
|
||||
const result = await performSync(engine, { repoPath: dir, noPull: true, noEmbed: true, full: true });
|
||||
|
||||
expect(result.status).toBe('first_sync');
|
||||
expect(result.added).toBe(2);
|
||||
expect(execSync('git rev-parse HEAD', { cwd: dir }).toString().trim()).not.toBe('');
|
||||
});
|
||||
|
||||
test('db_only paths are excluded from the baseline commit even without gbrain.yml write support (P2: fail-closed exclusion)', async () => {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
const { mkdirSync } = await import('fs');
|
||||
const { execSync } = await import('child_process');
|
||||
mkdirSync(join(dir, 'private-cache'));
|
||||
writeFileSync(join(dir, 'private-cache', 'secret.bin'), 'binary-ish content');
|
||||
writeFileSync(
|
||||
join(dir, 'gbrain.yml'),
|
||||
'storage:\n db_only:\n - private-cache\n',
|
||||
);
|
||||
|
||||
await performSync(engine, { noPull: true, noEmbed: true, full: true });
|
||||
|
||||
expect(existsSync(join(dir, '.git'))).toBe(true);
|
||||
const tracked = execSync('git ls-files', { cwd: dir }).toString();
|
||||
expect(tracked).not.toContain('private-cache');
|
||||
});
|
||||
|
||||
test('db_only exclusion applies even when a pre-existing .gitignore already covers the same dir (round 9 P1: unconditional pathspec)', async () => {
|
||||
// Regression for the "check-ignore pre-filter" version of this logic:
|
||||
// when a dir is ALSO already covered by an existing .gitignore, git's
|
||||
// `-A` bails with an advisory "paths ignored... use -f" even though
|
||||
// the add otherwise succeeds. Exclusion must be unconditional and the
|
||||
// advisory must not surface as a hard failure.
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
const { mkdirSync } = await import('fs');
|
||||
const { execSync } = await import('child_process');
|
||||
mkdirSync(join(dir, 'private-cache'));
|
||||
writeFileSync(join(dir, 'private-cache', 'secret.bin'), 'binary-ish content');
|
||||
writeFileSync(join(dir, 'gbrain.yml'), 'storage:\n db_only:\n - private-cache\n');
|
||||
writeFileSync(join(dir, '.gitignore'), 'private-cache/\n');
|
||||
|
||||
const result = await performSync(engine, { noPull: true, noEmbed: true, full: true });
|
||||
|
||||
expect(result.status).toBe('first_sync');
|
||||
const tracked = execSync('git ls-files', { cwd: dir }).toString();
|
||||
expect(tracked).not.toContain('private-cache');
|
||||
});
|
||||
|
||||
test('a comment merely mentioning db_only does not false-positive the sniff test (round 9 P2)', async () => {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
// No `storage:` section at all — just a comment mentioning the word.
|
||||
// A bare substring search would wrongly refuse this brain forever.
|
||||
writeFileSync(join(dir, 'gbrain.yml'), '# db_only handling: TBD, not configured yet\n');
|
||||
|
||||
const result = await performSync(engine, { noPull: true, noEmbed: true, full: true });
|
||||
|
||||
expect(result.status).toBe('first_sync');
|
||||
expect(result.added).toBe(2);
|
||||
});
|
||||
|
||||
test('a gbrain.yml that mentions db_only but resolves no dirs refuses the baseline commit (round 6 P2: unsupported-syntax sniff test)', async () => {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
const { execSync } = await import('child_process');
|
||||
// Flow-style array — valid YAML, but the narrow custom parser only
|
||||
// handles block-style lists, so loadStorageConfig warns and resolves
|
||||
// an empty db_only list rather than throwing.
|
||||
writeFileSync(join(dir, 'gbrain.yml'), 'storage:\n db_only: [private-cache/]\n');
|
||||
|
||||
await expect(
|
||||
performSync(engine, { noPull: true, noEmbed: true, full: true }),
|
||||
).rejects.toThrow(/db_only/i);
|
||||
// `git init` (site 1's first step) already ran before the sniff-test
|
||||
// guard (inside createSyncBaselineCommit) refused — that's fine, it's
|
||||
// the same "unborn repo" state the round-6-P1 index-rebuild test above
|
||||
// recovers from on a later retry, which would hit this same guard and
|
||||
// refuse again until gbrain.yml is fixed. What must NOT happen is a
|
||||
// commit landing with unknown/unexcluded content.
|
||||
expect(existsSync(join(dir, '.git'))).toBe(true);
|
||||
let hasCommit = true;
|
||||
try {
|
||||
execSync('git rev-parse HEAD', { cwd: dir, stdio: 'pipe' });
|
||||
} catch {
|
||||
hasCommit = false;
|
||||
}
|
||||
expect(hasCommit).toBe(false);
|
||||
});
|
||||
|
||||
test('unborn-HEAD recovery drops stale staged content the exclusion pathspec now wants excluded (round 6 P1: index rebuild)', async () => {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
const { mkdirSync } = await import('fs');
|
||||
const { execSync } = await import('child_process');
|
||||
mkdirSync(join(dir, 'private-cache'));
|
||||
writeFileSync(join(dir, 'private-cache', 'secret.bin'), 'binary-ish content');
|
||||
writeFileSync(join(dir, 'gbrain.yml'), 'storage:\n db_only:\n - private-cache\n');
|
||||
// Simulate an interrupted workflow that left this file staged in an
|
||||
// unborn repo BEFORE gbrain's self-heal ever ran.
|
||||
execSync('git init -q', { cwd: dir });
|
||||
execSync('git add private-cache/secret.bin', { cwd: dir });
|
||||
|
||||
await performSync(engine, { noPull: true, noEmbed: true, full: true });
|
||||
|
||||
const tracked = execSync('git ls-files', { cwd: dir }).toString();
|
||||
expect(tracked).not.toContain('private-cache');
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user