Files
gbrain/src/core/git-head.ts
T
f79c1306a2 v0.41.32.0 fix(staleness): commit-relative sync staleness (supersedes #1623) (#1656)
* 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>
2026-05-30 10:56:46 -07:00

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;
}