mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
* fix(staleness): commit-relative sync staleness (HEAD-hash local, durable column remote)
Quiet, fully-caught-up repos no longer false-alarm as SEVERELY STALE in
gbrain doctor / sources status. Staleness now means "is there committed
content the sync hasn't ingested?" not raw wall-clock since the last sync.
- git-head.ts: requireCleanWorkingTree gains 'ignore-untracked' mode (git
status --porcelain --untracked-files=no). Untracked dirs no longer defeat
the freshness short-circuit — sync's incremental path keys off the commit
diff and never imports untracked files, so doctor agrees with sync.
- source-health.ts: newestCommitMs (HEAD committer time) + pure
lagFromContentMs comparator; computeAllSourceMetrics {probeContent} routes
local→live commit-hash, remote→stored column. Dead isSourceStale removed.
- migration v108 sources.newest_content_at + fresh-schema blobs.
- sync.ts: writeSyncAnchor stamps newest_content_at atomically with
last_commit/last_sync_at; buildSyncStatusReport (remote get_status_snapshot)
reads the column — no git subprocess (v0.41.27.0 trust boundary intact).
- doctor.ts: checkSyncFreshness short-circuit ignores untracked; remote path
reads the column; clock-skew check stays on raw wall-clock.
Local consumers probe live git (catch HEAD moving to an old-dated commit, which
a timestamp compare would miss); remote consumers read the durable column so a
remote-callable endpoint never shells out to a DB-supplied local_path.
Supersedes #1623 (re-implemented in base repo with the trust boundary preserved).
Co-Authored-By: t <t@t>
* chore(ci): offload tests to on-demand cloud runners from a local CLI
scripts/ship-remote-tests.sh pushes the branch, dispatches the test workflow,
and blocks on `gh run watch --exit-status` — a local caller (human or agent)
awaits the GitHub run exactly like a local `bun run test`, with a real pass/fail
exit code. Frees a load-saturated local machine (many Conductor agents running
their own bun-test suites at once → load avg 120 on 16 cores → PGLite OOM/crawl).
test.yml gains workflow_dispatch so the suite can be triggered from any branch.
* chore: bump version and changelog (v0.41.32.0)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
125 lines
5.3 KiB
TypeScript
125 lines
5.3 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.
|
|
// `ignoreUntracked` (v0.41.32.0): when true, untracked files (`git status`
|
|
// `??` rows) do NOT count as dirty — they are not part of the repo and sync's
|
|
// incremental path (commit-diff at sync.ts:1057) never imports them, so a
|
|
// quiet repo with stray untracked dirs is still "unchanged".
|
|
export type GitCleanProbe = (localPath: string, ignoreUntracked?: boolean) => 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, ignoreUntracked) => {
|
|
try {
|
|
// `--untracked-files=no` makes `git status --porcelain` emit ONLY tracked
|
|
// changes. Empty output then means "clean ignoring untracked." This is the
|
|
// v0.41.32.0 fix for the false-SEVERE bug: untracked dirs (`?? companies/`,
|
|
// `?? media/`) on an otherwise-caught-up repo previously made the tree look
|
|
// dirty and defeated the short-circuit.
|
|
const args = ['-C', localPath, 'status', '--porcelain'];
|
|
if (ignoreUntracked) args.push('--untracked-files=no');
|
|
const out = execFileSync('git', args, {
|
|
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 {
|
|
/**
|
|
* Working-tree cleanliness requirement on top of the HEAD==lastCommit check:
|
|
* - `false`/omitted: HEAD comparison only.
|
|
* - `true`: require a fully clean tree (tracked AND untracked) — the
|
|
* v0.41.27.0 posture mirroring `gbrain sync`'s gate at sync.ts:1075.
|
|
* - `'ignore-untracked'` (v0.41.32.0): require no TRACKED changes but allow
|
|
* untracked files. This is what doctor/sources should use: sync's
|
|
* incremental path keys off the commit diff and never imports untracked
|
|
* files, so a quiet repo with stray untracked dirs is genuinely caught up.
|
|
* Fixes the false-SEVERE bug without weakening the commit-hash gate.
|
|
*/
|
|
requireCleanWorkingTree?: boolean | 'ignore-untracked';
|
|
}
|
|
|
|
/**
|
|
* 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 ignoreUntracked = opts.requireCleanWorkingTree === 'ignore-untracked';
|
|
const isClean = _cleanProbe(localPath, ignoreUntracked);
|
|
// null (probe error) AND false (known dirty) both fail the gate.
|
|
if (isClean !== true) return false;
|
|
}
|
|
return true;
|
|
}
|