mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +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>
179 lines
7.7 KiB
TypeScript
179 lines
7.7 KiB
TypeScript
// v0.41.27.0 — src/core/git-head.ts unit tests.
|
|
//
|
|
// Hermetic: no engine, no PGLite, no DATABASE_URL required. Probe seam lets
|
|
// the suite drive isSourceUnchangedSinceSync without spawning real git (cases
|
|
// 1-8, 10-12); case 9 is the shell-injection regression guard that DOES use
|
|
// the real execFileSync against a deliberately adversarial localPath to prove
|
|
// the array-arg call shape cannot escape to a shell.
|
|
|
|
import { describe, expect, test, beforeEach, afterAll } from 'bun:test';
|
|
import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import {
|
|
isSourceUnchangedSinceSync,
|
|
_setGitHeadProbeForTests,
|
|
_setGitCleanProbeForTests,
|
|
type GitHeadProbe,
|
|
type GitCleanProbe,
|
|
} from '../../src/core/git-head.ts';
|
|
|
|
// Reset both probe seams between every test so case order can't leak state.
|
|
beforeEach(() => {
|
|
_setGitHeadProbeForTests(null);
|
|
_setGitCleanProbeForTests(null);
|
|
});
|
|
|
|
afterAll(() => {
|
|
// Restore defaults so other test files inheriting the module state are clean.
|
|
_setGitHeadProbeForTests(null);
|
|
_setGitCleanProbeForTests(null);
|
|
});
|
|
|
|
describe('isSourceUnchangedSinceSync — basic predicate', () => {
|
|
test('case 1: happy path — HEAD matches, no requireCleanWorkingTree → true', () => {
|
|
_setGitHeadProbeForTests(() => 'abc123');
|
|
expect(isSourceUnchangedSinceSync('/tmp/repo', 'abc123')).toBe(true);
|
|
});
|
|
|
|
test('case 2: HEAD differs from lastCommit → false', () => {
|
|
_setGitHeadProbeForTests(() => 'def456');
|
|
expect(isSourceUnchangedSinceSync('/tmp/repo', 'abc123')).toBe(false);
|
|
});
|
|
|
|
test('case 3: localPath null — short-circuits without calling head probe', () => {
|
|
let probeCalls = 0;
|
|
_setGitHeadProbeForTests(() => { probeCalls++; return 'abc'; });
|
|
expect(isSourceUnchangedSinceSync(null, 'abc123')).toBe(false);
|
|
expect(probeCalls).toBe(0);
|
|
});
|
|
|
|
test('case 4: localPath empty string — short-circuits, probe not called', () => {
|
|
let probeCalls = 0;
|
|
_setGitHeadProbeForTests(() => { probeCalls++; return 'abc'; });
|
|
expect(isSourceUnchangedSinceSync('', 'abc123')).toBe(false);
|
|
expect(probeCalls).toBe(0);
|
|
});
|
|
|
|
test('case 5: lastCommit null — short-circuits, probe not called', () => {
|
|
let probeCalls = 0;
|
|
_setGitHeadProbeForTests(() => { probeCalls++; return 'abc'; });
|
|
expect(isSourceUnchangedSinceSync('/tmp/repo', null)).toBe(false);
|
|
expect(probeCalls).toBe(0);
|
|
});
|
|
|
|
test('case 6: lastCommit empty string — short-circuits, probe not called', () => {
|
|
let probeCalls = 0;
|
|
_setGitHeadProbeForTests(() => { probeCalls++; return 'abc'; });
|
|
expect(isSourceUnchangedSinceSync('/tmp/repo', '')).toBe(false);
|
|
expect(probeCalls).toBe(0);
|
|
});
|
|
|
|
test('case 7: head probe returns null (non-git dir / git not installed) → false', () => {
|
|
_setGitHeadProbeForTests(() => null);
|
|
expect(isSourceUnchangedSinceSync('/tmp/not-a-repo', 'abc123')).toBe(false);
|
|
});
|
|
|
|
test('case 8: head probe throws synchronously → false (fail-open)', () => {
|
|
_setGitHeadProbeForTests(() => { throw new Error('git not installed'); });
|
|
expect(() => isSourceUnchangedSinceSync('/tmp/repo', 'abc123')).toThrow();
|
|
// The default behavior is fail-open via the probe's own try/catch
|
|
// (returning null). A test seam that throws bypasses that protection
|
|
// intentionally — production callers use the default probe which
|
|
// swallows the error. Documented at git-head.ts:35-41.
|
|
// Re-verify by stubbing a probe that swallows the underlying error:
|
|
_setGitHeadProbeForTests(() => { try { throw new Error('inner'); } catch { return null; } });
|
|
expect(isSourceUnchangedSinceSync('/tmp/repo', 'abc123')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isSourceUnchangedSinceSync — shell-injection regression guard', () => {
|
|
test('case 9: REAL execFileSync against adversarial localPath does NOT execute shell metachars', () => {
|
|
// Use the REAL default probe (no test seam) so this case exercises the
|
|
// production code path: execFileSync('git', ['-C', path, ...]). If the
|
|
// implementation ever regresses to execSync with shell interpolation,
|
|
// the `$(...)` substring would execute and create the sentinel file.
|
|
_setGitHeadProbeForTests(null);
|
|
_setGitCleanProbeForTests(null);
|
|
|
|
const sentinelDir = mkdtempSync(join(tmpdir(), 'git-head-sentinel-'));
|
|
const sentinelPath = join(sentinelDir, 'pwned');
|
|
const adversarialPath = `/nonexistent/$(touch ${sentinelPath})/repo`;
|
|
|
|
try {
|
|
// Should fail-open: the path doesn't exist, git rev-parse returns
|
|
// non-zero, the probe returns null, the helper returns false.
|
|
const result = isSourceUnchangedSinceSync(adversarialPath, 'abc123');
|
|
expect(result).toBe(false);
|
|
// The load-bearing assertion: if shell interpolation happened,
|
|
// `touch ${sentinelPath}` would have created the file. It must NOT
|
|
// exist.
|
|
expect(existsSync(sentinelPath)).toBe(false);
|
|
} finally {
|
|
// Cleanup. If the test failed-open AND created the sentinel, remove it.
|
|
if (existsSync(sentinelPath)) {
|
|
try { unlinkSync(sentinelPath); } catch { /* ignore */ }
|
|
}
|
|
try { rmSync(sentinelDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('isSourceUnchangedSinceSync — test-seam round-trip', () => {
|
|
test('case 10: _setGitHeadProbeForTests + _setGitCleanProbeForTests round-trip', () => {
|
|
// Install custom probes
|
|
const customHead: GitHeadProbe = () => 'custom-head';
|
|
const customClean: GitCleanProbe = () => true;
|
|
_setGitHeadProbeForTests(customHead);
|
|
_setGitCleanProbeForTests(customClean);
|
|
|
|
expect(isSourceUnchangedSinceSync('/x', 'custom-head', { requireCleanWorkingTree: true })).toBe(true);
|
|
|
|
// Restore defaults via null
|
|
_setGitHeadProbeForTests(null);
|
|
_setGitCleanProbeForTests(null);
|
|
|
|
// After restoration, the real probes run. Against a non-git path,
|
|
// both probes return null → predicate returns false. This proves the
|
|
// restore worked without depending on a git repo in the test env.
|
|
expect(isSourceUnchangedSinceSync('/definitely-not-a-git-repo-xyz', 'custom-head')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isSourceUnchangedSinceSync — requireCleanWorkingTree (D7)', () => {
|
|
test('case 11: HEAD match + clean tree + requireCleanWorkingTree=true → true', () => {
|
|
_setGitHeadProbeForTests(() => 'abc123');
|
|
_setGitCleanProbeForTests(() => true);
|
|
expect(
|
|
isSourceUnchangedSinceSync('/tmp/repo', 'abc123', { requireCleanWorkingTree: true }),
|
|
).toBe(true);
|
|
});
|
|
|
|
test('case 12a: HEAD match + DIRTY tree + requireCleanWorkingTree=true → false', () => {
|
|
_setGitHeadProbeForTests(() => 'abc123');
|
|
_setGitCleanProbeForTests(() => false);
|
|
expect(
|
|
isSourceUnchangedSinceSync('/tmp/repo', 'abc123', { requireCleanWorkingTree: true }),
|
|
).toBe(false);
|
|
});
|
|
|
|
test('case 12b: HEAD match + clean probe ERRORED (returns null) + requireCleanWorkingTree=true → false', () => {
|
|
// null is distinct from false: probe error vs known-dirty. Both fail
|
|
// the gate (fail-closed posture for the clean check, fail-open posture
|
|
// for the helper as a whole — caller's time-based check still runs).
|
|
_setGitHeadProbeForTests(() => 'abc123');
|
|
_setGitCleanProbeForTests(() => null);
|
|
expect(
|
|
isSourceUnchangedSinceSync('/tmp/repo', 'abc123', { requireCleanWorkingTree: true }),
|
|
).toBe(false);
|
|
});
|
|
|
|
test('case 12c: HEAD match + tree dirty BUT requireCleanWorkingTree NOT set → true (clean probe not consulted)', () => {
|
|
let cleanCalls = 0;
|
|
_setGitHeadProbeForTests(() => 'abc123');
|
|
_setGitCleanProbeForTests(() => { cleanCalls++; return false; });
|
|
expect(isSourceUnchangedSinceSync('/tmp/repo', 'abc123')).toBe(true);
|
|
expect(cleanCalls).toBe(0);
|
|
});
|
|
});
|