gitnexus 1.6.12-rc.30 → 1.6.12-rc.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,8 @@ import { getStoragePaths, loadMeta, saveMeta } from '../storage/repo-manager.js'
7
7
  import { closeLbug, executeQuery, executeWithReusedStatement, fetchExistingEmbeddingHashes, initLbug, } from '../core/lbug/lbug-adapter.js';
8
8
  import { runEmbeddingPipeline } from '../core/embeddings/embedding-pipeline.js';
9
9
  import { resolveEmbeddingIdentity } from '../core/embeddings/embedding-identity.js';
10
- import { checkpointKind, decideEmbeddingResume, mintInterruptedCheckpoint, mintPartialCheckpoint, mintUnverifiedCountCheckpoint, } from '../core/embedding-checkpoint.js';
10
+ import { decideEmbeddingResume, mintInterruptedCheckpoint, mintPartialCheckpoint, mintUnverifiedCountCheckpoint, } from '../core/embedding-checkpoint.js';
11
+ import { EMBEDDING_DIMS, embeddingDimsMismatch } from '../core/lbug/schema.js';
11
12
  import { measurePersistedEmbeddingCount, persistedEmbeddingCountOrUndefined, } from '../core/embedding-count.js';
12
13
  /** Add missing embeddings directly to a healthy index, checkpointing periodically. */
13
14
  export const embeddingsSyncCommand = async (inputPath) => {
@@ -45,10 +46,16 @@ export const embeddingsSyncCommand = async (inputPath) => {
45
46
  const identityDiffers = checkpoint.provider !== identity.provider ||
46
47
  checkpoint.model !== identity.model ||
47
48
  checkpoint.dimensions !== identity.dimensions;
48
- // `abandon` on a non-interrupted foreign identity drops the pending set
49
- // only. Existing rows stay; sync would then embed the holes under the new
50
- // identity and mix vector spaces. Fail closed — rebuild via analyze.
51
- if (identityDiffers && checkpointKind(checkpoint) !== 'unverified-count') {
49
+ // `abandon` on a foreign identity drops the pending set only. Existing
50
+ // rows stay; sync would then embed the holes under the new identity and
51
+ // mix vector spaces. Fail closed — rebuild via analyze.
52
+ //
53
+ // Every kind is gated, `unverified-count` included. Exempting it looked
54
+ // safe because that kind only records "the count could not be read", but
55
+ // `decideEmbeddingResume` returns `abandon` for it BEFORE comparing
56
+ // identity, so the exemption was the only thing standing between a
57
+ // foreign identity and a silently mixed table.
58
+ if (identityDiffers) {
52
59
  throw new Error(`Cannot sync embeddings: the index checkpoint was written by ${checkpoint.model} ` +
53
60
  `(${checkpoint.provider}) at ${checkpoint.dimensions} dimensions, but this run ` +
54
61
  `resolves ${identity.model} (${identity.provider}) at ${identity.dimensions}. ` +
@@ -60,19 +67,34 @@ export const embeddingsSyncCommand = async (inputPath) => {
60
67
  resumedFrom = decision.resumedFrom;
61
68
  }
62
69
  }
70
+ // The vector column is FLOAT[N] fixed when the index was built, and the
71
+ // pipeline deletes each batch's stale rows immediately before inserting the
72
+ // replacements — so a width change here deletes rows it cannot re-insert.
73
+ // `analyze` forces a full rebuild on the same mismatch; only a rebuild can
74
+ // retype the column, so this writer refuses instead. An absent recorded
75
+ // width is not a mismatch (see `embeddingDimsMismatch`).
76
+ if (embeddingDimsMismatch(meta.embeddingDims, EMBEDDING_DIMS)) {
77
+ throw new Error(`Cannot sync embeddings: this index stores FLOAT[${meta.embeddingDims}] vectors, ` +
78
+ `but this run embeds at ${EMBEDDING_DIMS} dimensions. ` +
79
+ 'Run `gitnexus analyze --embeddings --force` to rebuild the column at the new width.');
80
+ }
63
81
  await initLbug(lbugPath);
64
82
  try {
65
83
  const existing = await fetchExistingEmbeddingHashes(executeQuery);
66
84
  let lastPercent = -1;
67
85
  const countEmbeddings = async () => persistedEmbeddingCountOrUndefined(await measurePersistedEmbeddingCount(executeQuery));
68
- const saveCheckpoint = async (checkpoint, pendingNodeIds, embeddings) => {
86
+ // One write path for every meta update this command makes. The re-read
87
+ // happens immediately before each save so a concurrent writer's fields
88
+ // survive. #2790 traced two production drifts to hand-copied writers of
89
+ // these exact fields, so this file keeps one copy instead of three.
90
+ const persistMeta = async (patch) => {
69
91
  const latest = (await loadMeta(metaDir)) ?? meta;
70
- await saveMeta(metaDir, {
71
- ...latest,
72
- ...(embeddings === undefined ? {} : { stats: { ...latest.stats, embeddings } }),
73
- embeddingCheckpoint: mintInterruptedCheckpoint(identity, checkpoint, pendingNodeIds),
74
- });
92
+ await saveMeta(metaDir, { ...latest, ...patch(latest) });
75
93
  };
94
+ const saveCheckpoint = async (checkpoint, pendingNodeIds, embeddings) => persistMeta((latest) => ({
95
+ ...(embeddings === undefined ? {} : { stats: { ...latest.stats, embeddings } }),
96
+ embeddingCheckpoint: mintInterruptedCheckpoint(identity, checkpoint, pendingNodeIds),
97
+ }));
76
98
  cliInfo(`Embedding ${repoPath}`);
77
99
  cliInfo(`Checkpointed nodes already present: ${existing?.size ?? 0}`);
78
100
  const result = await runEmbeddingPipeline(executeQuery, executeWithReusedStatement, (progress) => {
@@ -91,13 +113,11 @@ export const embeddingsSyncCommand = async (inputPath) => {
91
113
  },
92
114
  });
93
115
  const embeddings = await countEmbeddings();
94
- const latest = (await loadMeta(metaDir)) ?? meta;
95
116
  if (embeddings === undefined) {
96
117
  // Keep last-known stats.embeddings. An interrupted window marker would
97
118
  // fail the identity gate on the next run even though this run finished;
98
119
  // unverified-count is the recovery kind that forces a recount (#2790).
99
- await saveMeta(metaDir, {
100
- ...latest,
120
+ await persistMeta(() => ({
101
121
  embeddingCheckpoint: result.failedNodeIds.length
102
122
  ? mintPartialCheckpoint(identity, result, resumedFrom)
103
123
  : mintUnverifiedCountCheckpoint(identity, {
@@ -105,16 +125,15 @@ export const embeddingsSyncCommand = async (inputPath) => {
105
125
  totalNodes: result.nodesProcessed,
106
126
  chunksProcessed: result.chunksProcessed,
107
127
  }),
108
- });
128
+ }));
109
129
  throw new Error('Could not verify persisted embedding count.');
110
130
  }
111
- await saveMeta(metaDir, {
112
- ...latest,
131
+ await persistMeta((latest) => ({
113
132
  stats: { ...latest.stats, embeddings },
114
133
  embeddingCheckpoint: result.failedNodeIds.length
115
134
  ? mintPartialCheckpoint(identity, result, resumedFrom)
116
135
  : undefined,
117
- });
136
+ }));
118
137
  cliInfo(`Embeddings ready: ${embeddings}`);
119
138
  }
120
139
  finally {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Rendering for `gitnexus group status` rows, kept out of the Commander action
3
+ * so it can be tested. Inline, the cell was only reachable by booting a backend
4
+ * against a real group, which is how `STALE (-1 commits behind)` went unnoticed
5
+ * (#3256).
6
+ */
7
+ /** The fields of a `groupStatus` repo row the index column reads. */
8
+ export interface GroupRepoIndexRow {
9
+ indexStale: boolean;
10
+ commitsBehind?: number;
11
+ }
12
+ /**
13
+ * The index column of a `group status` row. The output is unchanged except
14
+ * for one case: a count that is not a real count renders as `?`.
15
+ *
16
+ * `group/service.ts` has always reported a repo with no recorded commit as
17
+ * `{ indexStale: true, commitsBehind: -1 }`. The previous `?? '?'` fallback
18
+ * never caught that, because `??` only falls back on `null` / `undefined`.
19
+ */
20
+ export declare const formatIndexStatusCell: (row: GroupRepoIndexRow) => string;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Rendering for `gitnexus group status` rows, kept out of the Commander action
3
+ * so it can be tested. Inline, the cell was only reachable by booting a backend
4
+ * against a real group, which is how `STALE (-1 commits behind)` went unnoticed
5
+ * (#3256).
6
+ */
7
+ /**
8
+ * The index column of a `group status` row. The output is unchanged except
9
+ * for one case: a count that is not a real count renders as `?`.
10
+ *
11
+ * `group/service.ts` has always reported a repo with no recorded commit as
12
+ * `{ indexStale: true, commitsBehind: -1 }`. The previous `?? '?'` fallback
13
+ * never caught that, because `??` only falls back on `null` / `undefined`.
14
+ */
15
+ export const formatIndexStatusCell = (row) => {
16
+ if (!row.indexStale)
17
+ return 'OK ';
18
+ const n = row.commitsBehind;
19
+ const count = typeof n === 'number' && n >= 0 ? String(n) : '?';
20
+ return `STALE (${count} commits behind)`;
21
+ };
package/dist/cli/group.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // gitnexus/src/cli/group.ts
2
2
  import { createRequire } from 'node:module';
3
3
  import { logger } from '../core/logger.js';
4
+ import { formatIndexStatusCell } from './group-status-format.js';
4
5
  const _require = createRequire(import.meta.url);
5
6
  const yaml = _require('js-yaml');
6
7
  export function registerGroupCommands(program) {
@@ -120,9 +121,7 @@ export function registerGroupCommands(program) {
120
121
  console.log(` ${repoPath.padEnd(25)} MISSING (no entry in the registry)`);
121
122
  continue;
122
123
  }
123
- const idx = row.indexStale
124
- ? `STALE (${row.commitsBehind ?? '?'} commits behind)`
125
- : 'OK ';
124
+ const idx = formatIndexStatusCell(row);
126
125
  const ctr = row.contractsStale ? ' CONTRACTS_STALE' : '';
127
126
  console.log(` ${repoPath.padEnd(25)} ${idx}${ctr}`);
128
127
  }
@@ -11,6 +11,7 @@
11
11
  * via `AbortSignal.timeout` on the underlying fetch.
12
12
  */
13
13
  import { chunk } from '../../lib/utils.js';
14
+ import { parseTruthyEnv } from '../ingestion/utils/env.js';
14
15
  import { CircuitOpenError, ResilientFetchExhaustedError, isTerminalNetworkError, resilientFetch, } from '../../_shared/index.js';
15
16
  const DEFAULT_HTTP_TIMEOUT_MS = 180_000;
16
17
  const MAX_HTTP_TIMEOUT_MS = 300_000;
@@ -157,7 +158,12 @@ const readConfig = () => {
157
158
  retryCapMs: parsePositiveIntegerEnv('GITNEXUS_EMBEDDING_RETRY_CAP_MS', HTTP_RETRY_CAP_MS, 300_000),
158
159
  minIntervalMs: parseNonNegativeIntegerEnv('GITNEXUS_EMBEDDING_MIN_INTERVAL_MS', 0, 300_000),
159
160
  timeoutMs: parsePositiveIntegerEnv(HTTP_TIMEOUT_ENV, DEFAULT_HTTP_TIMEOUT_MS, MAX_HTTP_TIMEOUT_MS),
160
- retryTimeouts: parseNonNegativeIntegerEnv('GITNEXUS_EMBEDDING_RETRY_TIMEOUTS', 0, 1) === 1,
161
+ // A boolean toggle, so it takes the repo's truthy convention (`1`/`true`/
162
+ // `yes`) and falls back to the documented default on anything else. The
163
+ // integer parser this used throws on a non-digit, which turned the
164
+ // conventional `=true` into a hard failure of every embedding call rather
165
+ // than either enabling the flag or leaving it off.
166
+ retryTimeouts: parseTruthyEnv(process.env.GITNEXUS_EMBEDDING_RETRY_TIMEOUTS),
161
167
  requestDimensions,
162
168
  };
163
169
  };
@@ -3,11 +3,8 @@
3
3
  * Lives in core/ so application code does not depend on the MCP package layer.
4
4
  */
5
5
  import { type CwdMatch } from '../storage/repo-manager.js';
6
- export interface StalenessInfo {
7
- isStale: boolean;
8
- commitsBehind: number;
9
- hint?: string;
10
- }
6
+ import type { StalenessInfo } from './staleness-status.js';
7
+ export type { StalenessInfo, StalenessStatus } from './staleness-status.js';
11
8
  /**
12
9
  * Check how many commits the index is behind HEAD (synchronous; uses git CLI).
13
10
  */
@@ -14,10 +14,64 @@ const execFileAsync = promisify(execFile);
14
14
  * degrades to "not stale" quickly rather than holding a request open.
15
15
  */
16
16
  const STALENESS_TIMEOUT_MS = 5_000;
17
+ const behindHint = (n) => `⚠️ Index is ${n} commit${n > 1 ? 's' : ''} behind HEAD. Run analyze tool to update.`;
18
+ // Says only what a failed count plus a resolved HEAD establish: the index is not
19
+ // at HEAD and the gap is uncountable. Reaching here does NOT prove the indexed
20
+ // commit left history — that is the usual cause (a pruned `fetch --depth 1`),
21
+ // but any other `rev-list` failure lands here too, so the cause is hedged.
22
+ const DIVERGED_HINT = "⚠️ 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. Run analyze tool to update.";
23
+ const unknown = () => ({ isStale: false, commitsBehind: 0, status: 'unknown' });
24
+ const fromCount = (commitsBehind) => commitsBehind > 0
25
+ ? { isStale: true, commitsBehind, hint: behindHint(commitsBehind), status: 'behind' }
26
+ : { isStale: false, commitsBehind: 0, status: 'current' };
27
+ /**
28
+ * `rev-list` could not answer. Asking for HEAD alone needs no history walk and
29
+ * still separates all three answers: HEAD unreadable is `unknown`, HEAD past the
30
+ * indexed commit is `diverged`, and HEAD still *at* it is `current` — the ref
31
+ * prints the indexed SHA, so the index is at HEAD however `rev-list` failed. The
32
+ * historical fail-open values are kept either way; only `status` differs.
33
+ */
34
+ const fromHead = (head, lastCommit) => {
35
+ if (!head)
36
+ return unknown();
37
+ if (head === lastCommit)
38
+ return { isStale: false, commitsBehind: 0, status: 'current' };
39
+ return { isStale: false, commitsBehind: 0, hint: DIVERGED_HINT, status: 'diverged' };
40
+ };
41
+ const readHeadSync = (repoPath) => {
42
+ try {
43
+ return (execFileSync('git', ['rev-parse', 'HEAD'], {
44
+ cwd: repoPath,
45
+ encoding: 'utf-8',
46
+ stdio: ['pipe', 'pipe', 'pipe'],
47
+ windowsHide: true,
48
+ }).trim() || null);
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ };
54
+ const readHeadAsync = async (repoPath) => {
55
+ try {
56
+ const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], {
57
+ cwd: repoPath,
58
+ encoding: 'utf-8',
59
+ windowsHide: true,
60
+ timeout: STALENESS_TIMEOUT_MS,
61
+ });
62
+ return stdout.trim() || null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ };
17
68
  /**
18
69
  * Check how many commits the index is behind HEAD (synchronous; uses git CLI).
19
70
  */
20
71
  export function checkStaleness(repoPath, lastCommit) {
72
+ // No recorded commit is not "at HEAD": there is nothing to measure against.
73
+ if (!lastCommit)
74
+ return unknown();
21
75
  try {
22
76
  const result = execFileSync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], {
23
77
  cwd: repoPath,
@@ -25,18 +79,10 @@ export function checkStaleness(repoPath, lastCommit) {
25
79
  stdio: ['pipe', 'pipe', 'pipe'],
26
80
  windowsHide: true,
27
81
  }).trim();
28
- const commitsBehind = parseInt(result, 10) || 0;
29
- if (commitsBehind > 0) {
30
- return {
31
- isStale: true,
32
- commitsBehind,
33
- hint: `⚠️ Index is ${commitsBehind} commit${commitsBehind > 1 ? 's' : ''} behind HEAD. Run analyze tool to update.`,
34
- };
35
- }
36
- return { isStale: false, commitsBehind: 0 };
82
+ return fromCount(parseInt(result, 10) || 0);
37
83
  }
38
84
  catch {
39
- return { isStale: false, commitsBehind: 0 };
85
+ return fromHead(readHeadSync(repoPath), lastCommit);
40
86
  }
41
87
  }
42
88
  /**
@@ -45,6 +91,8 @@ export function checkStaleness(repoPath, lastCommit) {
45
91
  * repos in parallel (issue #1363: 200 repos × sync spawn ≈ 50 s).
46
92
  */
47
93
  export async function checkStalenessAsync(repoPath, lastCommit) {
94
+ if (!lastCommit)
95
+ return unknown();
48
96
  try {
49
97
  // Note: promisified execFile captures stdout/stderr by default (no stdio option needed,
50
98
  // unlike the sync variant which requires explicit stdio: ['pipe','pipe','pipe']).
@@ -57,22 +105,18 @@ export async function checkStalenessAsync(repoPath, lastCommit) {
57
105
  // A working tree on a disconnected network mount or behind a stuck lock
58
106
  // does exactly that, and `/api/repos` fans this out once per registered
59
107
  // repo, so one unreachable mount could hold the whole listing open
60
- // (#3232 review). The timeout kills the child and rejects, which routes
61
- // a hang into the same fail-closed "not stale" answer as a bad SHA.
108
+ // (#3232 review). The timeout kills the child and rejects, and the catch
109
+ // below reports it as `unknown` — still the fail-closed `isStale: false`.
62
110
  timeout: STALENESS_TIMEOUT_MS,
63
111
  });
64
- const commitsBehind = parseInt(stdout.trim(), 10) || 0;
65
- if (commitsBehind > 0) {
66
- return {
67
- isStale: true,
68
- commitsBehind,
69
- hint: `⚠️ Index is ${commitsBehind} commit${commitsBehind > 1 ? 's' : ''} behind HEAD. Run analyze tool to update.`,
70
- };
71
- }
72
- return { isStale: false, commitsBehind: 0 };
112
+ return fromCount(parseInt(stdout.trim(), 10) || 0);
73
113
  }
74
- catch {
75
- return { isStale: false, commitsBehind: 0 };
114
+ catch (err) {
115
+ // A rev-list that timed out means the working tree is not answering. Asking
116
+ // it again for HEAD would only double the bound #3232 put on a hung mount.
117
+ if (err.killed)
118
+ return unknown();
119
+ return fromHead(await readHeadAsync(repoPath), lastCommit);
76
120
  }
77
121
  }
78
122
  /**
@@ -5,6 +5,7 @@
5
5
  import fsp from 'node:fs/promises';
6
6
  import path from 'node:path';
7
7
  import { checkStaleness } from '../git-staleness.js';
8
+ import { stalenessStatus } from '../staleness-status.js';
8
9
  import { canonicalizePath, loadMeta, readRegistryStrict, registryPathEquals, } from '../../storage/repo-manager.js';
9
10
  import { crossRepoCompleteness } from './completeness.js';
10
11
  import { recordedMatchStages, recordedRepoList } from './completeness.js';
@@ -635,7 +636,7 @@ export class GroupService {
635
636
  const meta = (await loadMeta(repoObj.storagePath)) ?? {};
636
637
  const staleness = meta.lastCommit
637
638
  ? checkStaleness(repoObj.repoPath, meta.lastCommit)
638
- : { isStale: true, commitsBehind: -1 };
639
+ : { isStale: true, commitsBehind: -1, status: 'unknown' };
639
640
  const snapshot = registry?.repoSnapshots?.[repoPath];
640
641
  const contractsStale = snapshot && meta.indexedAt ? snapshot.indexedAt !== meta.indexedAt : !snapshot;
641
642
  repoStatuses[repoPath] = {
@@ -644,6 +645,7 @@ export class GroupService {
644
645
  missing: false,
645
646
  unresolvable: false,
646
647
  commitsBehind: staleness.commitsBehind,
648
+ status: stalenessStatus(staleness),
647
649
  };
648
650
  }
649
651
  catch (err) {
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The shape of a staleness answer, and the one wire payload every surface
3
+ * emits for it (#3256). Pure: no git, no I/O.
4
+ *
5
+ * Kept apart from `git-staleness.ts` on purpose. Tests across the suite stub
6
+ * that module with a fixed `vi.mock` factory so nothing shells out to git; a
7
+ * pure helper exported from it would come back `undefined` under every such
8
+ * stub. Here it is imported for real wherever the git probes are mocked.
9
+ */
10
+ /**
11
+ * What a staleness check was able to establish.
12
+ *
13
+ * `isStale` / `commitsBehind` alone cannot say "could not tell": every git
14
+ * failure collapses into `{ isStale: false, commitsBehind: 0 }`. That is
15
+ * deliberate — pinned by the fail-open tests, because the hot read tools must
16
+ * never fail or nag on an index they cannot measure — but it also made a
17
+ * provably stale index indistinguishable from a fresh one. `status` is the
18
+ * additive channel that separates them for a caller that wants to act on it:
19
+ *
20
+ * - `current` — the index is at HEAD: `rev-list` answered 0, or it could not
21
+ * answer but HEAD alone resolved to the indexed commit.
22
+ * - `behind` — `rev-list` answered N > 0; `commitsBehind` is N.
23
+ * - `diverged` — `rev-list` could not answer, but HEAD resolved and is not the
24
+ * indexed commit. The index is provably not at HEAD; only the count is
25
+ * unknown. A branch-pinned `serve` clone reaches this once git prunes the
26
+ * commit a failed re-index left behind — the pinned update is a
27
+ * `fetch --depth 1`, which orphans it — and a rewritten history reaches it
28
+ * directly. It is the rule the Claude hook already applies:
29
+ * HEAD !== lastCommit.
30
+ * - `unknown` — HEAD could not be resolved at all: not a git repository, git
31
+ * timed out, or no commit was recorded.
32
+ *
33
+ * `isStale` and `commitsBehind` keep their historical values in every case, so
34
+ * no existing consumer changes behaviour unless it reads `status`.
35
+ */
36
+ export type StalenessStatus = 'current' | 'behind' | 'diverged' | 'unknown';
37
+ export interface StalenessInfo {
38
+ isStale: boolean;
39
+ commitsBehind: number;
40
+ hint?: string;
41
+ /**
42
+ * Always set by `checkStaleness` and `checkStalenessAsync`. Optional on the
43
+ * type so a hand-built info (tests, legacy literals) still compiles; read it
44
+ * through {@link stalenessStatus}, which derives it from `isStale` when absent.
45
+ */
46
+ status?: StalenessStatus;
47
+ }
48
+ /** `info.status`, or the answer `isStale` implies for an info built without one. */
49
+ export declare const stalenessStatus: (info: StalenessInfo) => StalenessStatus;
50
+ /**
51
+ * The wire shape for staleness on every surface: MCP `list_repos`, the hot read
52
+ * tools, and the `serve` repo routes. One builder so one fact has one shape
53
+ * (#3232 review: "same sentinel as MCP").
54
+ *
55
+ * Absent for `current`, as before. `commitsBehind` is present only when git
56
+ * actually counted it, so `diverged` carries `status` and `hint` but no number —
57
+ * inventing one would be the silent wrong answer this exists to remove.
58
+ */
59
+ export interface StalenessPayload {
60
+ status: Exclude<StalenessStatus, 'current'>;
61
+ commitsBehind?: number;
62
+ hint?: string;
63
+ }
64
+ /**
65
+ * Project a check into {@link StalenessPayload}, or `undefined` when there is
66
+ * nothing to report.
67
+ *
68
+ * `unknown` is emitted only when `includeUnknown` is set. A listing a monitor
69
+ * reads wants it; the hot read tools do not, because a `--skip-git` folder has
70
+ * no history to measure and would otherwise repeat that on every response.
71
+ */
72
+ export declare const stalenessPayload: (info: StalenessInfo | undefined, opts?: {
73
+ includeUnknown?: boolean;
74
+ }) => StalenessPayload | undefined;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The shape of a staleness answer, and the one wire payload every surface
3
+ * emits for it (#3256). Pure: no git, no I/O.
4
+ *
5
+ * Kept apart from `git-staleness.ts` on purpose. Tests across the suite stub
6
+ * that module with a fixed `vi.mock` factory so nothing shells out to git; a
7
+ * pure helper exported from it would come back `undefined` under every such
8
+ * stub. Here it is imported for real wherever the git probes are mocked.
9
+ */
10
+ /** `info.status`, or the answer `isStale` implies for an info built without one. */
11
+ export const stalenessStatus = (info) => info.status ?? (info.isStale ? 'behind' : 'current');
12
+ /**
13
+ * Project a check into {@link StalenessPayload}, or `undefined` when there is
14
+ * nothing to report.
15
+ *
16
+ * `unknown` is emitted only when `includeUnknown` is set. A listing a monitor
17
+ * reads wants it; the hot read tools do not, because a `--skip-git` folder has
18
+ * no history to measure and would otherwise repeat that on every response.
19
+ */
20
+ export const stalenessPayload = (info, opts = {}) => {
21
+ if (!info)
22
+ return undefined;
23
+ const status = stalenessStatus(info);
24
+ const hint = info.hint ? { hint: info.hint } : {};
25
+ if (status === 'current')
26
+ return undefined;
27
+ if (status === 'unknown')
28
+ return opts.includeUnknown ? { status } : undefined;
29
+ if (status === 'diverged')
30
+ return { status, ...hint };
31
+ return { status, commitsBehind: info.commitsBehind, ...hint };
32
+ };
@@ -8,7 +8,7 @@
8
8
  import { isTestFilePath } from '../../core/ingestion/utils/test-file-path.js';
9
9
  import { type RegistryEntry, type BranchSummary } from '../../storage/repo-manager.js';
10
10
  import { GroupService } from '../../core/group/service.js';
11
- import { type StalenessInfo } from '../../core/git-staleness.js';
11
+ import { type StalenessInfo, type StalenessPayload } from '../../core/staleness-status.js';
12
12
  /** Shared predicate; re-exported so MCP importers keep the old public name. */
13
13
  export { isTestFilePath };
14
14
  /** Valid LadybugDB node labels for safe Cypher query construction */
@@ -233,10 +233,7 @@ export interface RepoListing {
233
233
  lastCommit: string;
234
234
  remoteUrl?: string;
235
235
  stats?: any;
236
- staleness?: {
237
- commitsBehind: number;
238
- hint?: string;
239
- };
236
+ staleness?: StalenessPayload;
240
237
  siblings?: Array<{
241
238
  name: string;
242
239
  path: string;
@@ -264,11 +261,16 @@ export interface ListReposPagination {
264
261
  }
265
262
  /**
266
263
  * #2655: attach a non-blocking `staleness` signal to a tool result when the
267
- * index is behind HEAD, mirroring the `list_repos` `{commitsBehind, hint}`
268
- * shape. Only ever ADDS a field to a carryable object result (see
264
+ * index is not at HEAD, in the same {@link stalenessPayload} shape `list_repos`
265
+ * returns. Only ever ADDS a field to a carryable object result (see
269
266
  * {@link canCarryStaleness}) — it never changes an existing result's shape.
267
+ *
268
+ * `diverged` is attached: it is a positive finding that the index is not at
269
+ * HEAD, only uncountable. `unknown` is not — these are the hot read tools, and a
270
+ * `--skip-git` folder has no history to measure, so it would ride on every
271
+ * response as noise rather than signal (#3256).
270
272
  */
271
- export declare function attachToolStaleness(result: unknown, staleness: StalenessInfo | undefined): unknown;
273
+ export declare function attachToolStaleness(result: unknown, info: StalenessInfo | undefined): unknown;
272
274
  export declare class LocalBackend {
273
275
  private static readonly TOOL_STALENESS_TTL_MS;
274
276
  private repos;
@@ -553,9 +555,10 @@ export declare class LocalBackend {
553
555
  * one `git rev-list` per index per TTL window; the resolved value is cached
554
556
  * for TOOL_STALENESS_TTL_MS. Keyed by lbugPath so flat and branch handles
555
557
  * (same repoPath, different lastCommit) don't share an entry. Non-blocking by
556
- * construction: `checkStalenessAsync` swallows git failures to
557
- * `{ isStale: false }`, so a git error never fails the tool — it just omits
558
- * the `staleness` field.
558
+ * construction: `checkStalenessAsync` keeps `isStale: false` on every git
559
+ * failure and reports what it could still establish in `status` (`diverged`,
560
+ * `unknown`, or `current` when HEAD alone matches the index), so a git error
561
+ * never fails the tool — at most it attaches a `diverged` staleness field.
559
562
  */
560
563
  private stalenessForTool;
561
564
  callTool(method: string, params: any): Promise<any>;
@@ -49,7 +49,8 @@ import { getExactScanLimit } from '../../core/platform/capabilities.js';
49
49
  import { PhaseTimer } from '../../core/search/phase-timer.js';
50
50
  import { ftsDegradedWarning, ftsQueryFailedWarning } from '../../core/search/fts-indexes.js';
51
51
  import { cjkSegmentationModeMismatch, containsSegmentableCjkRun, getSearchFTSCjkSegmentation, isSupportedCjkSegmentationMode, MAX_CJK_SEGMENTATION_QUERY_LENGTH, } from '../../core/search/cjk-segmentation.js';
52
- import { checkStalenessAsync, checkCwdMatch, } from '../../core/git-staleness.js';
52
+ import { checkStalenessAsync, checkCwdMatch } from '../../core/git-staleness.js';
53
+ import { stalenessPayload, } from '../../core/staleness-status.js';
53
54
  import { logger } from '../../core/logger.js';
54
55
  import { isLocalEmbeddingRuntimeBlockerMessage, isMissingLocalEmbeddingStackMessage, } from '../../core/embeddings/runtime-support.js';
55
56
  import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT, EXPLAIN_DEFAULT_LIMIT, EXPLAIN_MAX_LIMIT, PDG_QUERY_DEFAULT_LIMIT, PDG_QUERY_MAX_LIMIT, } from '../tools.js';
@@ -900,18 +901,21 @@ function canCarryStaleness(result) {
900
901
  }
901
902
  /**
902
903
  * #2655: attach a non-blocking `staleness` signal to a tool result when the
903
- * index is behind HEAD, mirroring the `list_repos` `{commitsBehind, hint}`
904
- * shape. Only ever ADDS a field to a carryable object result (see
904
+ * index is not at HEAD, in the same {@link stalenessPayload} shape `list_repos`
905
+ * returns. Only ever ADDS a field to a carryable object result (see
905
906
  * {@link canCarryStaleness}) — it never changes an existing result's shape.
907
+ *
908
+ * `diverged` is attached: it is a positive finding that the index is not at
909
+ * HEAD, only uncountable. `unknown` is not — these are the hot read tools, and a
910
+ * `--skip-git` folder has no history to measure, so it would ride on every
911
+ * response as noise rather than signal (#3256).
906
912
  */
907
- export function attachToolStaleness(result, staleness) {
908
- if (!staleness?.isStale || !canCarryStaleness(result)) {
913
+ export function attachToolStaleness(result, info) {
914
+ const staleness = stalenessPayload(info);
915
+ if (!staleness || !canCarryStaleness(result)) {
909
916
  return result;
910
917
  }
911
- return {
912
- ...result,
913
- staleness: { commitsBehind: staleness.commitsBehind, hint: staleness.hint },
914
- };
918
+ return { ...result, staleness };
915
919
  }
916
920
  export class LocalBackend {
917
921
  static TOOL_STALENESS_TTL_MS = 5000;
@@ -1818,9 +1822,7 @@ export class LocalBackend {
1818
1822
  lastCommit: h.lastCommit,
1819
1823
  remoteUrl: h.remoteUrl,
1820
1824
  stats: h.stats,
1821
- staleness: stale.isStale
1822
- ? { commitsBehind: stale.commitsBehind, hint: stale.hint }
1823
- : undefined,
1825
+ staleness: stalenessPayload(stale, { includeUnknown: true }),
1824
1826
  siblings: siblings.length > 0
1825
1827
  ? siblings.map((s) => ({
1826
1828
  name: s.name,
@@ -1958,9 +1960,10 @@ export class LocalBackend {
1958
1960
  * one `git rev-list` per index per TTL window; the resolved value is cached
1959
1961
  * for TOOL_STALENESS_TTL_MS. Keyed by lbugPath so flat and branch handles
1960
1962
  * (same repoPath, different lastCommit) don't share an entry. Non-blocking by
1961
- * construction: `checkStalenessAsync` swallows git failures to
1962
- * `{ isStale: false }`, so a git error never fails the tool — it just omits
1963
- * the `staleness` field.
1963
+ * construction: `checkStalenessAsync` keeps `isStale: false` on every git
1964
+ * failure and reports what it could still establish in `status` (`diverged`,
1965
+ * `unknown`, or `current` when HEAD alone matches the index), so a git error
1966
+ * never fails the tool — at most it attaches a `diverged` staleness field.
1964
1967
  */
1965
1968
  stalenessForTool(repo) {
1966
1969
  const now = Date.now();
@@ -1,5 +1,7 @@
1
1
  /**
2
- * Staleness Check — re-export from core (see `core/git-staleness.ts`).
2
+ * Staleness Check — re-export from core (see `core/git-staleness.ts` and
3
+ * `core/staleness-status.ts`).
3
4
  */
4
- export type { StalenessInfo } from '../core/git-staleness.js';
5
+ export type { StalenessInfo, StalenessPayload, StalenessStatus } from '../core/staleness-status.js';
5
6
  export { checkStaleness } from '../core/git-staleness.js';
7
+ export { stalenessPayload, stalenessStatus } from '../core/staleness-status.js';
@@ -1,4 +1,6 @@
1
1
  /**
2
- * Staleness Check — re-export from core (see `core/git-staleness.ts`).
2
+ * Staleness Check — re-export from core (see `core/git-staleness.ts` and
3
+ * `core/staleness-status.ts`).
3
4
  */
4
5
  export { checkStaleness } from '../core/git-staleness.js';
6
+ export { stalenessPayload, stalenessStatus } from '../core/staleness-status.js';
@@ -9,41 +9,30 @@
9
9
  * These are pure: the caller resolves the registry entry, the on-disk metadata
10
10
  * and the staleness check, and passes the results in.
11
11
  */
12
- import type { StalenessInfo } from '../core/git-staleness.js';
12
+ import { type StalenessInfo, type StalenessPayload } from '../core/staleness-status.js';
13
13
  import type { RegistryEntry } from '../storage/repo-manager.js';
14
14
  import type { RepoMeta } from '../storage/repo-meta.js';
15
15
  /**
16
- * Staleness in the shape MCP `list_repos` already returns
17
- * (`mcp/local/local-backend.ts`): the key is present only when the index is
18
- * actually behind, so "fresh" stays the absence of a field rather than a second
19
- * thing for a client to interpret. Deliberately identical across the two
20
- * surfaces — the same fact should not have two shapes.
16
+ * Staleness through the shared {@link stalenessPayload} builder, so this route
17
+ * and MCP `list_repos` emit one shape for one fact (#3232 review, #3256).
21
18
  *
22
- * `checkStalenessAsync` self-catches and reports 0 commits behind when the
23
- * commit cannot be resolved, so an unanswerable check degrades to "not stale"
24
- * rather than failing the request that carries it.
19
+ * Absent when the index is current. Otherwise `staleness.status` says what git
20
+ * could establish: `behind` with the counted `commitsBehind`; `diverged` when
21
+ * HEAD has provably moved off the indexed commit but the history needed to
22
+ * count the gap is gone — the state a branch-pinned `url` clone reaches once
23
+ * git prunes the commit a failed re-index left behind; or `unknown` when the
24
+ * repository could not be measured at all. This is a listing a monitor reads,
25
+ * so `unknown` is included here, unlike on the hot read tools.
25
26
  *
26
- * That makes the field meaningful mainly for `path`-registered repositories,
27
- * where an operator commits into the working tree the index was built from.
28
- * A `url` repository is cloned `--depth 1` and is re-analyzed by the same run
29
- * that pulls it, so its recorded commit is HEAD; and were the two ever to
30
- * diverge, `git rev-list <old>..HEAD` cannot walk a shallow history and fails
31
- * closed to "not stale". Reporting fresh there is not a claim that the remote
32
- * has not moved — this measures the index against the local working tree, the
33
- * same thing `gitnexus status` and MCP `list_repos` measure.
27
+ * All of it measures the index against the local working tree — the same thing
28
+ * `gitnexus status` and MCP `list_repos` measure — not against the remote.
34
29
  */
35
30
  export declare const stalenessField: (info: StalenessInfo) => {
36
- staleness?: {
37
- commitsBehind: number;
38
- hint?: string;
39
- };
31
+ staleness?: StalenessPayload;
40
32
  };
41
33
  /** One entry of `GET /api/repos`. */
42
34
  export declare const projectRepoListEntry: (entry: RegistryEntry, staleness: StalenessInfo) => {
43
- staleness?: {
44
- commitsBehind: number;
45
- hint?: string;
46
- };
35
+ staleness?: StalenessPayload;
47
36
  name: string;
48
37
  path: string;
49
38
  repoPath: string;
@@ -66,10 +55,7 @@ export declare const projectRepoListEntry: (entry: RegistryEntry, staleness: Sta
66
55
  * disagree, and the entry is the fallback for a repo whose meta cannot be read.
67
56
  */
68
57
  export declare const projectRepoDetail: (entry: RegistryEntry, meta: RepoMeta | null | undefined, staleness: StalenessInfo) => {
69
- staleness?: {
70
- commitsBehind: number;
71
- hint?: string;
72
- };
58
+ staleness?: StalenessPayload;
73
59
  name: string;
74
60
  repoPath: string;
75
61
  indexedAt: string;
@@ -1,24 +1,34 @@
1
1
  /**
2
- * Staleness in the shape MCP `list_repos` already returns
3
- * (`mcp/local/local-backend.ts`): the key is present only when the index is
4
- * actually behind, so "fresh" stays the absence of a field rather than a second
5
- * thing for a client to interpret. Deliberately identical across the two
6
- * surfaces — the same fact should not have two shapes.
2
+ * Response projections for the `serve` repo routes.
7
3
  *
8
- * `checkStalenessAsync` self-catches and reports 0 commits behind when the
9
- * commit cannot be resolved, so an unanswerable check degrades to "not stale"
10
- * rather than failing the request that carries it.
4
+ * Extracted from the route bodies so the field list is assertable. Inline, the
5
+ * projections were only reachable by booting a server and indexing a real
6
+ * repository, which is how `branch` came to sit on `RegistryEntry` unexposed
7
+ * over HTTP while `gitnexus list` printed it, with no test to notice (#3226).
11
8
  *
12
- * That makes the field meaningful mainly for `path`-registered repositories,
13
- * where an operator commits into the working tree the index was built from.
14
- * A `url` repository is cloned `--depth 1` and is re-analyzed by the same run
15
- * that pulls it, so its recorded commit is HEAD; and were the two ever to
16
- * diverge, `git rev-list <old>..HEAD` cannot walk a shallow history and fails
17
- * closed to "not stale". Reporting fresh there is not a claim that the remote
18
- * has not moved — this measures the index against the local working tree, the
19
- * same thing `gitnexus status` and MCP `list_repos` measure.
9
+ * These are pure: the caller resolves the registry entry, the on-disk metadata
10
+ * and the staleness check, and passes the results in.
20
11
  */
21
- export const stalenessField = (info) => info.isStale ? { staleness: { commitsBehind: info.commitsBehind, hint: info.hint } } : {};
12
+ import { stalenessPayload, } from '../core/staleness-status.js';
13
+ /**
14
+ * Staleness through the shared {@link stalenessPayload} builder, so this route
15
+ * and MCP `list_repos` emit one shape for one fact (#3232 review, #3256).
16
+ *
17
+ * Absent when the index is current. Otherwise `staleness.status` says what git
18
+ * could establish: `behind` with the counted `commitsBehind`; `diverged` when
19
+ * HEAD has provably moved off the indexed commit but the history needed to
20
+ * count the gap is gone — the state a branch-pinned `url` clone reaches once
21
+ * git prunes the commit a failed re-index left behind; or `unknown` when the
22
+ * repository could not be measured at all. This is a listing a monitor reads,
23
+ * so `unknown` is included here, unlike on the hot read tools.
24
+ *
25
+ * All of it measures the index against the local working tree — the same thing
26
+ * `gitnexus status` and MCP `list_repos` measure — not against the remote.
27
+ */
28
+ export const stalenessField = (info) => {
29
+ const staleness = stalenessPayload(info, { includeUnknown: true });
30
+ return staleness ? { staleness } : {};
31
+ };
22
32
  /** One entry of `GET /api/repos`. */
23
33
  export const projectRepoListEntry = (entry, staleness) => ({
24
34
  name: entry.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.12-rc.30",
3
+ "version": "1.6.12-rc.32",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",