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.
- package/dist/cli/embeddings-sync.js +37 -18
- package/dist/cli/group-status-format.d.ts +20 -0
- package/dist/cli/group-status-format.js +21 -0
- package/dist/cli/group.js +2 -3
- package/dist/core/embeddings/http-client.js +7 -1
- package/dist/core/git-staleness.d.ts +2 -5
- package/dist/core/git-staleness.js +67 -23
- package/dist/core/group/service.js +3 -1
- package/dist/core/staleness-status.d.ts +74 -0
- package/dist/core/staleness-status.js +32 -0
- package/dist/mcp/local/local-backend.d.ts +14 -11
- package/dist/mcp/local/local-backend.js +18 -15
- package/dist/mcp/staleness.d.ts +4 -2
- package/dist/mcp/staleness.js +3 -1
- package/dist/server/repo-projection.d.ts +15 -29
- package/dist/server/repo-projection.js +27 -17
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
61
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
|
268
|
-
*
|
|
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,
|
|
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`
|
|
557
|
-
*
|
|
558
|
-
*
|
|
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
|
|
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
|
|
904
|
-
*
|
|
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,
|
|
908
|
-
|
|
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
|
|
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`
|
|
1962
|
-
*
|
|
1963
|
-
*
|
|
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();
|
package/dist/mcp/staleness.d.ts
CHANGED
|
@@ -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/
|
|
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';
|
package/dist/mcp/staleness.js
CHANGED
|
@@ -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
|
|
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
|
|
17
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
27
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
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