GitNexus/gitnexus/test/unit/staleness-timeout.test.ts
Ankit Verma 8bd71c8335
feat(staleness): report diverged and unknown index state instead of fresh (#3257)
* feat(staleness): report diverged and unknown index state instead of fresh

A staleness check collapsed every git failure into { isStale: false,
commitsBehind: 0 }. On a branch-pinned serve clone, a failed re-index
leaves the recorded commit orphaned by the --depth 1 fetch; once the
reflog expires and gc prunes it, rev-list fails and the index silently
reads as fresh while still behind.

checkStaleness / checkStalenessAsync now return an additive status:
current, behind, diverged (rev-list failed but HEAD resolved and differs
from lastCommit) or unknown (HEAD unresolvable, no lastCommit, timeout).
isStale and commitsBehind keep their values in every case.

One payload builder (core/staleness-status.ts) feeds MCP list_repos, the
hot read tools and /api/repos + /api/repo. diverged carries a hint and no
invented count; unknown appears on listings only. The helpers live in a
pure module so existing vi.mock stubs of git-staleness stay valid.

gitnexus group status no longer prints "-1 commits behind".

Fixes #3256

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Address PR review feedback (#3257)

- Document the `current` arm `fromHead` can return after a failed `rev-list`.
  The `staleness-status.ts` status list, the `fromHead` docstring and the
  `stalenessForTool` comment each named only `diverged`/`unknown`, so all three
  described a contract the helpers do not have.
- Document both `unknown` rows on the group status type: no recorded commit
  (`indexStale: true`, `commitsBehind: -1`) and a git probe that could not
  answer (`indexStale: false`, `commitsBehind: 0`), which do not agree on
  either field.
- Update both shipped `gitnexus-guide` copies to the new wire shape
  (`{ status, commitsBehind?, hint? }`), add a `diverged` example carrying no
  count, and state `unknown` is reported only by the `list_repos` listing.
  Presence now means "not `current`", not "behind N". Pinned in
  shipped-skills-sync.
- Lock the timed-out `rev-list` short-circuit with staleness-timeout.test.ts:
  it asserts `unknown` from exactly one git spawn, so removing the `killed`
  guard (which would probe HEAD again and double the #3232 bound) now fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Claim only the uncountable gap in the diverged hint (#3257)

`fromHead` reaches the `diverged` branch on any non-timeout `rev-list` failure
where `rev-parse HEAD` resolves to a different SHA. It compares the two SHAs and
runs no reachability check, so a transient object-read failure lands there while
`lastCommit` is still an ancestor of HEAD — and the hint told the operator the
commit was gone from history. `staleness-status.ts` already documents `diverged`
as only "provably not at HEAD; the count is unknown", so the string was also the
one place contradicting its own contract.

The hint now states what the check established and hedges the usual cause:
"Index is not at HEAD and the commit gap could not be counted — the recorded
commit may no longer be in this clone's history."

Updated with it: the `staleness.test.ts` diverged assertion, and the `diverged`
example plus its lead-in sentence in both shipped `gitnexus-guide` copies (still
byte-identical). `mcp/resources.ts` needs no change — it interpolates
`staleness.hint`, holding no copy of the text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(staleness): cover the remaining #3256 review test gaps

The tri-review's lower-priority gaps, each now locked:

- staleness-fallback.test.ts: after a failed (not timed-out) rev-list,
  both helpers report `current` when HEAD alone still resolves to the
  indexed commit, `diverged` when it resolves elsewhere, and `unknown`
  when it cannot be read, each from exactly rev-list + rev-parse. This
  complements staleness-timeout.test.ts: a timeout spawns once, any other
  failure probes HEAD.
- list_repos carries the listing-only `unknown` and a count-free
  `diverged`; `current` carries nothing.
- group status: a resolvable repo with no recorded commit composes to
  indexStale: true, commitsBehind: -1, status: unknown and renders as
  "STALE (? commits behind)", through the real groupStatus path rather
  than a hand-built row.

Each test was checked against a mutation of the code it guards (the
fromHead current arm, the group no-commit literal, list_repos
includeUnknown); every mutation fails its test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: tech-admin3 <tech-admin@kodnest.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-09-11 08:07:37 +01:00

48 lines
2 KiB
TypeScript

/**
* #3256 + #3232: a `rev-list` that TIMED OUT must answer `unknown` from the
* timeout alone. The #3232 bound exists per request, so asking the same
* unresponsive working tree for HEAD afterwards would double it — and could
* report `diverged`/`current` off a HEAD a hung tree may still answer for.
*
* Its own file because the mock replaces `node:child_process` for the whole
* module graph, while `staleness.test.ts` drives these helpers against real git
* repositories and must keep the real one.
*/
import { describe, expect, it, vi } from 'vitest';
const { spawnedArgs } = vi.hoisted(() => ({ spawnedArgs: [] as string[][] }));
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
const execFile = (
_file: string,
args: readonly string[],
_options: unknown,
callback: (error: Error | null, result: { stdout: string; stderr: string }) => void,
): void => {
spawnedArgs.push([...args]);
// What `promisify(execFile)` rejects with when `timeout` kills the child:
// Node 22 reports `killed: true` alongside `signal: 'SIGTERM'`.
const timedOut = Object.assign(new Error('Command failed: git rev-list'), {
killed: true,
signal: 'SIGTERM',
});
callback(timedOut, { stdout: '', stderr: '' });
};
return { ...actual, execFile: execFile as unknown as typeof actual.execFile };
});
const INDEXED_COMMIT = 'a'.repeat(40);
describe('checkStalenessAsync — timed-out rev-list (#3256)', () => {
it('reports unknown without probing HEAD again', async () => {
const { checkStalenessAsync } = await import('../../src/core/git-staleness.js');
const result = await checkStalenessAsync('/repo', INDEXED_COMMIT);
expect(result).toEqual({ status: 'unknown', isStale: false, commitsBehind: 0 });
// Exactly one spawn. A `rev-parse HEAD` follow-up here is the regression:
// it doubles the hung-mount bound and can flip this answer.
expect(spawnedArgs).toEqual([['rev-list', '--count', `${INDEXED_COMMIT}..HEAD`]]);
});
});