GitNexus/gitnexus/test/unit/staleness-fallback.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

100 lines
3.7 KiB
TypeScript

/**
* #3256: what a staleness check reports once `rev-list` has failed for a reason
* OTHER than a timeout. `staleness.test.ts` reaches `diverged` and `unknown`
* against real repositories, but not the third arm of `fromHead`: HEAD still
* resolves to the indexed commit, so the index is at HEAD however `rev-list`
* failed. Real git cannot fail `<sha>..HEAD` while HEAD prints that same SHA
* without a corrupted object store, so this drives it through a mock.
*
* Its own file for the same reason as `staleness-timeout.test.ts`: the mock
* replaces `node:child_process` for the whole module graph.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { plan, spawnedArgs } = vi.hoisted(() => ({
plan: { head: null as string | null },
spawnedArgs: [] as string[][],
}));
// `rev-list` exits 128 the way git does for a missing object; `rev-parse HEAD`
// answers `plan.head`, or fails when it is null.
const answer = (args: readonly string[]): { error: Error | null; stdout: string } => {
spawnedArgs.push([...args]);
if (args[0] === 'rev-parse' && plan.head) return { error: null, stdout: `${plan.head}\n` };
const failure = Object.assign(new Error(`Command failed: git ${args.join(' ')}`), {
code: 128,
killed: false,
});
return { error: failure, stdout: '' };
};
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 => {
const { error, stdout } = answer(args);
callback(error, { stdout, stderr: '' });
};
const execFileSync = (_file: string, args: readonly string[]): string => {
const { error, stdout } = answer(args);
if (error) throw error;
return stdout;
};
return {
...actual,
execFile: execFile as unknown as typeof actual.execFile,
execFileSync: execFileSync as unknown as typeof actual.execFileSync,
};
});
import { checkStaleness, checkStalenessAsync } from '../../src/core/git-staleness.js';
const INDEXED_COMMIT = 'a'.repeat(40);
const REV_LIST = ['rev-list', '--count', `${INDEXED_COMMIT}..HEAD`];
const REV_PARSE = ['rev-parse', 'HEAD'];
const bothHelpers = {
checkStaleness: async (repo: string, lastCommit: string) => checkStaleness(repo, lastCommit),
checkStalenessAsync,
};
describe('staleness after a failed (not timed-out) rev-list (#3256)', () => {
beforeEach(() => {
plan.head = null;
spawnedArgs.length = 0;
});
for (const [name, check] of Object.entries(bothHelpers)) {
describe(name, () => {
it('reports current when HEAD alone still resolves to the indexed commit', async () => {
plan.head = INDEXED_COMMIT;
const result = await check('/repo', INDEXED_COMMIT);
expect(result).toEqual({ isStale: false, commitsBehind: 0, status: 'current' });
// The answer came from the HEAD probe, not from rev-list.
expect(spawnedArgs).toEqual([REV_LIST, REV_PARSE]);
});
it('reports diverged when HEAD resolves elsewhere', async () => {
plan.head = 'b'.repeat(40);
const result = await check('/repo', INDEXED_COMMIT);
expect(result).toMatchObject({ isStale: false, commitsBehind: 0, status: 'diverged' });
expect(spawnedArgs).toEqual([REV_LIST, REV_PARSE]);
});
it('reports unknown when HEAD cannot be read either', async () => {
const result = await check('/repo', INDEXED_COMMIT);
expect(result).toEqual({ isStale: false, commitsBehind: 0, status: 'unknown' });
expect(spawnedArgs).toEqual([REV_LIST, REV_PARSE]);
});
});
}
});