mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 19:01:39 +00:00
* feat(doctor): add isSourceUnchangedSinceSync git-head primitive src/core/git-head.ts is a single-responsibility module: probe git HEAD + working-tree clean state for a local_path, compare against the last_commit SHA the DB stored at last sync completion. Designed for reuse (autopilot's per-source dispatch will want the same gate, filed as v0.41.27.1+ TODO). Shell-injection safe: uses execFileSync with array args. The superseded community PR #1564 used execSync through /bin/sh -c with JSON.stringify for shell-escape, which is unsafe — JSON.stringify escapes for JSON, not shell. Fail-open contract: every error path returns false, preserving the caller's prior time-based behavior. Two test seams (_setGitHeadProbeForTests, _setGitCleanProbeForTests) match the last-retrieved.ts precedent so unit tests stay parallel-eligible (no mock.module per CLAUDE.md R2). Companion test suite: 14 cases including a load-bearing shell-injection regression guard that runs real execFileSync against '/nonexistent/$(touch <sentinel>)/repo' and asserts the sentinel file is never created. * feat(doctor): git-aware sync_freshness check + 9 new test cases checkSyncFreshness gains opts.localOnly: boolean. When local CLI caller passes true, doctor short-circuits the staleness warning iff HEAD == sources.last_commit AND working tree is clean AND sources.chunker_version matches CURRENT (mirrors sync.ts:1057+1075's own "do work?" predicate, so doctor and sync agree). Inline SELECT widens to carry last_commit + chunker_version (columns already exist; no schema migration). Three-bucket count math (unchanged_count + synced_recently_count + stale_count === sources.length) populates Check.details for dashboards / JSON consumers. OK-message reshape: - all-unchanged → "All N up to date (no new commits since last sync)" - mixed → "N source(s): X synced recently, Y unchanged since last sync" - all-recent → "All N synced recently" (back-compat). Trust boundary preserved (Codex P0-1): runDoctor (local CLI, trusted) passes localOnly: true; doctorReportRemote (HTTP MCP, untrusted) keeps the default false. Default-false is fail-closed — a future caller that forgets the opt gets the safe no-probe behavior. checkCycleFreshness is INTENTIONALLY NOT touched (Codex P0-2): last_commit == HEAD answers "new commits to sync?" but cannot answer "did the full cycle complete?" — silencing cycle warns on git-clean sources would mask the case where sync ran but extract/embed/ consolidate/synthesize failed. Test coverage: 9 new cases in test/doctor.test.ts including - HEAD-match short-circuit + cold-path message - HEAD-mismatch warn - NULL last_commit (legacy data) → warn - non-git local_path (probe returns null) → warn (fail-open) - 3-source mixed bucket invariant (sum === length) - chunker-version mismatch warn (dirty-tree-clean still gates) - dirty-tree warn (HEAD-match still gates) - D4 regression guard: localOnly=false (default) NEVER calls probes All 87 tests across git-head + doctor + cycle-freshness suites pass. bun run verify clean (28/28 checks). Co-Authored-By: garrytan-agents <me@garrytan.com> * chore: bump version and changelog (v0.41.27.0) Supersedes community PR #1564. Co-Authored-By preserved. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(test): pin gateway to 1536d for query-cache-knobs-hash suite CI failed on the v0.41.27.0 ship after merging origin/master because test/query-cache-knobs-hash.test.ts predates the v0.36.2.0 flip of DEFAULT_EMBEDDING_DIMENSIONS from 1536 → 1280 (ZE Matryoshka). Without an explicit gateway pin, initSchema() sizes query_cache.embedding at halfvec(1280), but the test fixture (makeEmbedding at line 44) emits 1536-dim unit vectors. Result: 5 cases in the "SemanticQueryCache cross-mode isolation (CDX-4 hotfix)" describe crashed with "expected 1280 dimensions, not 1536". Fix mirrors test/consolidate-valid-until.test.ts (the canonical gateway-pin pattern that landed when the default flipped). resetGateway() + configureGateway({embedding_dimensions: 1536}) in beforeAll forces the schema to size at halfvec(1536) regardless of cross-file gateway state. resetGateway() in afterAll restores defaults so the next file in the shard isn't poisoned. Verified: 9/9 cases pass; bun run verify 29/29 clean. --------- Co-authored-by: garrytan-agents <me@garrytan.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
111 lines
4.2 KiB
TypeScript
111 lines
4.2 KiB
TypeScript
/**
|
|
* v0.41.27.0 — git HEAD freshness probe for `gbrain doctor`.
|
|
*
|
|
* Single primitive. Returns true iff a `local_path` directory is a git repo
|
|
* whose current HEAD matches the `last_commit` SHA the DB recorded at last
|
|
* sync completion. When `requireCleanWorkingTree` is set, also requires the
|
|
* working tree to be clean — mirroring `gbrain sync`'s force-walk gate at
|
|
* sync.ts:1075 so doctor and sync agree on "is there work to do?".
|
|
*
|
|
* Fail-open contract: any error (missing path, not a git repo, git not
|
|
* installed, timeout, NULL inputs, dirty probe errored) returns `false`,
|
|
* which preserves the caller's prior time-based behavior. We never raise.
|
|
*
|
|
* Shell-injection safe: uses execFileSync with array args so a `local_path`
|
|
* containing `$(...)`, backticks, or other shell metacharacters can never
|
|
* escape to a shell. The PR #1564 community version used
|
|
* `execSync(`git -C ${JSON.stringify(path)} ...`)`, which runs through
|
|
* `/bin/sh -c` — `JSON.stringify` escapes for JSON, not shell, so a
|
|
* mutable `sources.local_path` was an RCE-style surface.
|
|
*
|
|
* Designed for reuse: autopilot's per-source dispatch will want the same
|
|
* gate. See plan note "v0.41.27.1+ TODOs" in
|
|
* ~/.claude/plans/system-instruction-you-are-working-eager-bird.md.
|
|
*/
|
|
import { execFileSync } from 'node:child_process';
|
|
|
|
export type GitHeadProbe = (localPath: string) => string | null;
|
|
// `null` distinguishes probe error from known-dirty (false). Doctor treats
|
|
// both as "do not short-circuit", but tests need to assert which path fired.
|
|
export type GitCleanProbe = (localPath: string) => boolean | null;
|
|
|
|
const DEFAULT_HEAD_PROBE: GitHeadProbe = (localPath) => {
|
|
try {
|
|
const out = execFileSync('git', ['-C', localPath, 'rev-parse', 'HEAD'], {
|
|
encoding: 'utf8',
|
|
timeout: 5000,
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
});
|
|
return out.trim() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const DEFAULT_CLEAN_PROBE: GitCleanProbe = (localPath) => {
|
|
try {
|
|
const out = execFileSync('git', ['-C', localPath, 'status', '--porcelain'], {
|
|
encoding: 'utf8',
|
|
timeout: 5000,
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
});
|
|
return out.trim().length === 0;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
let _headProbe: GitHeadProbe = DEFAULT_HEAD_PROBE;
|
|
let _cleanProbe: GitCleanProbe = DEFAULT_CLEAN_PROBE;
|
|
|
|
// Test seam. Matches the `_setChatTransportForTests` precedent at
|
|
// src/core/last-retrieved.ts so tests can drive the public function
|
|
// without mocking child_process or routing through mock.module (R2-compliant).
|
|
export function _setGitHeadProbeForTests(fn: GitHeadProbe | null): void {
|
|
_headProbe = fn ?? DEFAULT_HEAD_PROBE;
|
|
}
|
|
|
|
export function _setGitCleanProbeForTests(fn: GitCleanProbe | null): void {
|
|
_cleanProbe = fn ?? DEFAULT_CLEAN_PROBE;
|
|
}
|
|
|
|
export interface GitFreshnessOpts {
|
|
/**
|
|
* When true, additionally require working tree to be clean (no
|
|
* uncommitted changes). Doctor uses this to mirror `gbrain sync`'s
|
|
* working-tree-dirty gate at sync.ts:1075 — otherwise doctor would
|
|
* say "unchanged" for a repo with pending local edits that sync would
|
|
* actually re-walk on the next run.
|
|
*
|
|
* Default false (HEAD comparison only). Doctor-callers set true.
|
|
*/
|
|
requireCleanWorkingTree?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Returns true iff `localPath` is a git repo whose current HEAD matches
|
|
* `lastCommit`, AND (when `requireCleanWorkingTree`) the working tree
|
|
* is clean.
|
|
*
|
|
* This is NOT a full mirror of `gbrain sync`'s "do work?" predicate.
|
|
* Chunker-version match is computed by the caller because it depends on
|
|
* engine state (`sources.chunker_version` vs `CURRENT_CHUNKER_VERSION`).
|
|
* See `src/commands/doctor.ts:checkSyncFreshness` for the AND
|
|
* combination at the call site.
|
|
*/
|
|
export function isSourceUnchangedSinceSync(
|
|
localPath: string | null | undefined,
|
|
lastCommit: string | null | undefined,
|
|
opts?: GitFreshnessOpts,
|
|
): boolean {
|
|
if (!localPath || !lastCommit) return false;
|
|
const head = _headProbe(localPath);
|
|
if (head === null || head !== lastCommit) return false;
|
|
if (opts?.requireCleanWorkingTree) {
|
|
const isClean = _cleanProbe(localPath);
|
|
// null (probe error) AND false (known dirty) both fail the gate.
|
|
if (isClean !== true) return false;
|
|
}
|
|
return true;
|
|
}
|