gitnexus 1.6.11-rc.7 → 1.6.11-rc.9
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/README.md +1 -1
- package/dist/cli/i18n/en.d.ts +8 -0
- package/dist/cli/i18n/en.js +8 -0
- package/dist/cli/i18n/resources.d.ts +16 -0
- package/dist/cli/i18n/zh-CN.d.ts +8 -0
- package/dist/cli/i18n/zh-CN.js +8 -0
- package/dist/cli/status.js +91 -6
- package/dist/core/index-content-drift.d.ts +54 -0
- package/dist/core/index-content-drift.js +127 -0
- package/dist/core/ingestion/filesystem-walker.d.ts +15 -5
- package/dist/core/ingestion/filesystem-walker.js +20 -3
- package/dist/core/ingestion/pipeline-phases/scan.js +25 -13
- package/dist/core/run-analyze.js +9 -1
- package/dist/mcp/local/local-backend.d.ts +24 -7
- package/dist/mcp/local/local-backend.js +78 -31
- package/dist/mcp/repository-policy.d.ts +5 -1
- package/dist/mcp/repository-policy.js +48 -4
- package/dist/mcp/resources.js +2 -1
- package/dist/mcp/server.js +6 -5
- package/dist/mcp/tools.js +26 -16
- package/dist/storage/file-hash.d.ts +5 -0
- package/dist/storage/file-hash.js +16 -6
- package/dist/storage/git.d.ts +19 -8
- package/dist/storage/git.js +83 -24
- package/dist/storage/gitnexus-managed-paths.d.ts +36 -0
- package/dist/storage/gitnexus-managed-paths.js +46 -0
- package/dist/storage/repo-meta.d.ts +12 -0
- package/package.json +1 -1
- package/scripts/cross-platform-tests.ts +3 -0
package/README.md
CHANGED
|
@@ -204,7 +204,7 @@ Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically:
|
|
|
204
204
|
| `group_list` | List configured repository groups |
|
|
205
205
|
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
|
|
206
206
|
|
|
207
|
-
>
|
|
207
|
+
> Read-only tools can omit `repo` when one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Otherwise—and for mutating tools with multiple indexed repos and no MCP default—specify it explicitly: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`; omitting it queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
|
|
208
208
|
|
|
209
209
|
## MCP Resources
|
|
210
210
|
|
package/dist/cli/i18n/en.d.ts
CHANGED
|
@@ -32,6 +32,14 @@ export declare const en: {
|
|
|
32
32
|
readonly 'status.detached': "(detached HEAD)";
|
|
33
33
|
readonly 'status.workspaceIndexLabel': "Workspace index: last analyzed on '{{primary}}' (re-run gitnexus analyze to follow the current branch)";
|
|
34
34
|
readonly 'status.status': "Status";
|
|
35
|
+
readonly 'status.indexContentCurrent': "Index content: matches all {{count}} covered file(s)";
|
|
36
|
+
readonly 'status.indexContentDrifted': "Index content: {{changed}} changed, {{added}} added, {{deleted}} deleted";
|
|
37
|
+
readonly 'status.indexContentMore': " ...and {{count}} more {{label}}";
|
|
38
|
+
readonly 'status.indexContentUnmeasurable': "Index content: not comparable ({{reason}}); fell back to the working-tree check";
|
|
39
|
+
readonly 'status.indexContentScanFailed': "Index content: coverage scan failed; treating the index as stale";
|
|
40
|
+
readonly 'status.driftChanged': "changed";
|
|
41
|
+
readonly 'status.driftAdded': "added";
|
|
42
|
+
readonly 'status.driftDeleted': "deleted";
|
|
35
43
|
readonly 'status.upToDate': "✅ up-to-date";
|
|
36
44
|
readonly 'status.stale': "⚠️ stale (re-run gitnexus analyze)";
|
|
37
45
|
readonly 'clean.deleteAll': "This will delete GitNexus indexes for {{count}} repo(s):";
|
package/dist/cli/i18n/en.js
CHANGED
|
@@ -32,6 +32,14 @@ export const en = {
|
|
|
32
32
|
'status.detached': '(detached HEAD)',
|
|
33
33
|
'status.workspaceIndexLabel': "Workspace index: last analyzed on '{{primary}}' (re-run gitnexus analyze to follow the current branch)",
|
|
34
34
|
'status.status': 'Status',
|
|
35
|
+
'status.indexContentCurrent': 'Index content: matches all {{count}} covered file(s)',
|
|
36
|
+
'status.indexContentDrifted': 'Index content: {{changed}} changed, {{added}} added, {{deleted}} deleted',
|
|
37
|
+
'status.indexContentMore': ' ...and {{count}} more {{label}}',
|
|
38
|
+
'status.indexContentUnmeasurable': 'Index content: not comparable ({{reason}}); fell back to the working-tree check',
|
|
39
|
+
'status.indexContentScanFailed': 'Index content: coverage scan failed; treating the index as stale',
|
|
40
|
+
'status.driftChanged': 'changed',
|
|
41
|
+
'status.driftAdded': 'added',
|
|
42
|
+
'status.driftDeleted': 'deleted',
|
|
35
43
|
'status.upToDate': '✅ up-to-date',
|
|
36
44
|
'status.stale': '⚠️ stale (re-run gitnexus analyze)',
|
|
37
45
|
'clean.deleteAll': 'This will delete GitNexus indexes for {{count}} repo(s):',
|
|
@@ -33,6 +33,14 @@ export declare const cliResources: {
|
|
|
33
33
|
readonly 'status.detached': "(detached HEAD)";
|
|
34
34
|
readonly 'status.workspaceIndexLabel': "Workspace index: last analyzed on '{{primary}}' (re-run gitnexus analyze to follow the current branch)";
|
|
35
35
|
readonly 'status.status': "Status";
|
|
36
|
+
readonly 'status.indexContentCurrent': "Index content: matches all {{count}} covered file(s)";
|
|
37
|
+
readonly 'status.indexContentDrifted': "Index content: {{changed}} changed, {{added}} added, {{deleted}} deleted";
|
|
38
|
+
readonly 'status.indexContentMore': " ...and {{count}} more {{label}}";
|
|
39
|
+
readonly 'status.indexContentUnmeasurable': "Index content: not comparable ({{reason}}); fell back to the working-tree check";
|
|
40
|
+
readonly 'status.indexContentScanFailed': "Index content: coverage scan failed; treating the index as stale";
|
|
41
|
+
readonly 'status.driftChanged': "changed";
|
|
42
|
+
readonly 'status.driftAdded': "added";
|
|
43
|
+
readonly 'status.driftDeleted': "deleted";
|
|
36
44
|
readonly 'status.upToDate': "✅ up-to-date";
|
|
37
45
|
readonly 'status.stale': "⚠️ stale (re-run gitnexus analyze)";
|
|
38
46
|
readonly 'clean.deleteAll': "This will delete GitNexus indexes for {{count}} repo(s):";
|
|
@@ -289,6 +297,14 @@ export declare const cliResources: {
|
|
|
289
297
|
'status.detached': string;
|
|
290
298
|
'status.workspaceIndexLabel': string;
|
|
291
299
|
'status.status': string;
|
|
300
|
+
'status.indexContentCurrent': string;
|
|
301
|
+
'status.indexContentDrifted': string;
|
|
302
|
+
'status.indexContentMore': string;
|
|
303
|
+
'status.indexContentUnmeasurable': string;
|
|
304
|
+
'status.indexContentScanFailed': string;
|
|
305
|
+
'status.driftChanged': string;
|
|
306
|
+
'status.driftAdded': string;
|
|
307
|
+
'status.driftDeleted': string;
|
|
292
308
|
'status.upToDate': string;
|
|
293
309
|
'status.stale': string;
|
|
294
310
|
'clean.deleteAll': string;
|
package/dist/cli/i18n/zh-CN.d.ts
CHANGED
|
@@ -32,6 +32,14 @@ export declare const zhCN: {
|
|
|
32
32
|
'status.detached': string;
|
|
33
33
|
'status.workspaceIndexLabel': string;
|
|
34
34
|
'status.status': string;
|
|
35
|
+
'status.indexContentCurrent': string;
|
|
36
|
+
'status.indexContentDrifted': string;
|
|
37
|
+
'status.indexContentMore': string;
|
|
38
|
+
'status.indexContentUnmeasurable': string;
|
|
39
|
+
'status.indexContentScanFailed': string;
|
|
40
|
+
'status.driftChanged': string;
|
|
41
|
+
'status.driftAdded': string;
|
|
42
|
+
'status.driftDeleted': string;
|
|
35
43
|
'status.upToDate': string;
|
|
36
44
|
'status.stale': string;
|
|
37
45
|
'clean.deleteAll': string;
|
package/dist/cli/i18n/zh-CN.js
CHANGED
|
@@ -32,6 +32,14 @@ export const zhCN = {
|
|
|
32
32
|
'status.detached': '(分离 HEAD)',
|
|
33
33
|
'status.workspaceIndexLabel': "工作区索引:最近在 '{{primary}}' 分支上分析(重新运行 gitnexus analyze 以跟随当前分支)",
|
|
34
34
|
'status.status': '状态',
|
|
35
|
+
'status.indexContentCurrent': '索引内容:与覆盖的全部 {{count}} 个文件一致',
|
|
36
|
+
'status.indexContentDrifted': '索引内容:{{changed}} 个已修改,{{added}} 个新增,{{deleted}} 个已删除',
|
|
37
|
+
'status.indexContentMore': ' ……另有 {{count}} 个 {{label}}',
|
|
38
|
+
'status.indexContentUnmeasurable': '索引内容:无法比对({{reason}}),已回退到工作区检查',
|
|
39
|
+
'status.indexContentScanFailed': '索引内容:覆盖扫描失败,按过期处理',
|
|
40
|
+
'status.driftChanged': '已修改',
|
|
41
|
+
'status.driftAdded': '新增',
|
|
42
|
+
'status.driftDeleted': '已删除',
|
|
35
43
|
'status.upToDate': '✅ 已是最新',
|
|
36
44
|
'status.stale': '⚠️ 已过期(重新运行 gitnexus analyze)',
|
|
37
45
|
'clean.deleteAll': '将删除 {{count}} 个仓库的 GitNexus 索引:',
|
package/dist/cli/status.js
CHANGED
|
@@ -8,7 +8,64 @@ import { findRepo, getStoragePaths, loadMeta, hasKuzuIndex } from '../storage/re
|
|
|
8
8
|
import { getCurrentCommit, getCurrentBranch, isGitRepo, getGitRoot, isWorkingTreeDirty, } from '../storage/git.js';
|
|
9
9
|
import { analyzerRunnerIdentitiesEqual, resolveAnalyzerRunnerIdentity, } from '../core/analyzer-identity.js';
|
|
10
10
|
import { getIndexIncompleteReasons } from '../core/index-freshness.js';
|
|
11
|
+
import { detectIndexContentDrift } from '../core/index-content-drift.js';
|
|
11
12
|
import { t } from './i18n/index.js';
|
|
13
|
+
/** How many drifted paths the report names before summarizing the rest. */
|
|
14
|
+
const DRIFT_SAMPLE_LIMIT = 10;
|
|
15
|
+
/**
|
|
16
|
+
* Machine-readable form of the per-file comparison. `'not-checked'` is its own
|
|
17
|
+
* value rather than a silent omission: it says the index was already stale on
|
|
18
|
+
* metadata alone, so the scan was skipped, which is not the same claim as a
|
|
19
|
+
* scan that ran and found nothing.
|
|
20
|
+
*/
|
|
21
|
+
const describeContentDrift = (drift) => {
|
|
22
|
+
if (!drift)
|
|
23
|
+
return { status: 'not-checked' };
|
|
24
|
+
if (drift.kind === 'current') {
|
|
25
|
+
return { status: 'current', coveredFiles: drift.coveredFileCount };
|
|
26
|
+
}
|
|
27
|
+
if (drift.kind === 'unmeasurable') {
|
|
28
|
+
return { status: 'unmeasurable', reason: drift.reason };
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
status: 'drifted',
|
|
32
|
+
counts: {
|
|
33
|
+
changed: drift.changed.length,
|
|
34
|
+
added: drift.added.length,
|
|
35
|
+
deleted: drift.deleted.length,
|
|
36
|
+
},
|
|
37
|
+
changed: drift.changed.slice(0, DRIFT_SAMPLE_LIMIT),
|
|
38
|
+
added: drift.added.slice(0, DRIFT_SAMPLE_LIMIT),
|
|
39
|
+
deleted: drift.deleted.slice(0, DRIFT_SAMPLE_LIMIT),
|
|
40
|
+
truncated: {
|
|
41
|
+
changed: drift.changed.length > DRIFT_SAMPLE_LIMIT,
|
|
42
|
+
added: drift.added.length > DRIFT_SAMPLE_LIMIT,
|
|
43
|
+
deleted: drift.deleted.length > DRIFT_SAMPLE_LIMIT,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
/** Escape control characters in repo-relative paths before printing. */
|
|
48
|
+
const formatDriftPath = (rel) => /[\u0000-\u001f\u007f]/.test(rel) ? JSON.stringify(rel) : rel;
|
|
49
|
+
const printDriftDetail = (drift) => {
|
|
50
|
+
console.log(t('status.indexContentDrifted', {
|
|
51
|
+
changed: drift.changed.length,
|
|
52
|
+
added: drift.added.length,
|
|
53
|
+
deleted: drift.deleted.length,
|
|
54
|
+
}));
|
|
55
|
+
const labelled = [
|
|
56
|
+
[t('status.driftChanged'), drift.changed],
|
|
57
|
+
[t('status.driftAdded'), drift.added],
|
|
58
|
+
[t('status.driftDeleted'), drift.deleted],
|
|
59
|
+
];
|
|
60
|
+
for (const [label, paths] of labelled) {
|
|
61
|
+
for (const p of paths.slice(0, DRIFT_SAMPLE_LIMIT)) {
|
|
62
|
+
console.log(` ${label}: ${formatDriftPath(p)}`);
|
|
63
|
+
}
|
|
64
|
+
const remaining = paths.length - DRIFT_SAMPLE_LIMIT;
|
|
65
|
+
if (remaining > 0)
|
|
66
|
+
console.log(t('status.indexContentMore', { count: remaining, label }));
|
|
67
|
+
}
|
|
68
|
+
};
|
|
12
69
|
export const statusCommand = async (options = {}) => {
|
|
13
70
|
const cwd = process.cwd();
|
|
14
71
|
if (!isGitRepo(cwd)) {
|
|
@@ -63,13 +120,26 @@ export const statusCommand = async (options = {}) => {
|
|
|
63
120
|
const currentRunnerIdentity = resolveAnalyzerRunnerIdentity(import.meta.url);
|
|
64
121
|
const runnerIdentityIsCurrent = analyzerRunnerIdentitiesEqual(activeMeta.runnerIdentity, currentRunnerIdentity);
|
|
65
122
|
const incompleteReasons = getIndexIncompleteReasons(activeMeta);
|
|
66
|
-
|
|
67
|
-
// so a repo with uncommitted source changes is stale even at the same commit.
|
|
68
|
-
// Skip the check for non-git folders (currentCommit === '') to match analyze.
|
|
69
|
-
const isUpToDate = currentCommit === activeMeta.lastCommit &&
|
|
123
|
+
const metadataIsCurrent = currentCommit === activeMeta.lastCommit &&
|
|
70
124
|
runnerIdentityIsCurrent &&
|
|
71
|
-
incompleteReasons.length === 0
|
|
72
|
-
|
|
125
|
+
incompleteReasons.length === 0;
|
|
126
|
+
// A matching HEAD is not enough: `analyze` re-indexes changed content at the
|
|
127
|
+
// same commit, so the files the index covers must still be compared against
|
|
128
|
+
// disk. Only worth the scan once the cheap metadata checks agree, and skipped
|
|
129
|
+
// for non-git folders (currentCommit === '') to match analyze.
|
|
130
|
+
const contentDrift = metadataIsCurrent && currentCommit !== ''
|
|
131
|
+
? await detectIndexContentDrift(repo.repoPath, activeMeta.fileHashes, activeMeta.indexCoverage)
|
|
132
|
+
: undefined;
|
|
133
|
+
// The repo-wide dirty flag survives only as the fallback for metadata written
|
|
134
|
+
// before `fileHashes` existed. Where the per-file comparison can run it
|
|
135
|
+
// decides, so a file the index does not cover no longer pins a byte-current
|
|
136
|
+
// index to a "stale" verdict that `analyze` is powerless to clear (#3077).
|
|
137
|
+
const contentIsCurrent = contentDrift === undefined ||
|
|
138
|
+
contentDrift.kind === 'current' ||
|
|
139
|
+
(contentDrift.kind === 'unmeasurable' &&
|
|
140
|
+
contentDrift.reason === 'no-file-hashes' &&
|
|
141
|
+
!isWorkingTreeDirty(repo.repoPath));
|
|
142
|
+
const isUpToDate = metadataIsCurrent && contentIsCurrent;
|
|
73
143
|
if (options.json) {
|
|
74
144
|
console.log(JSON.stringify({
|
|
75
145
|
schemaVersion: 1,
|
|
@@ -87,6 +157,7 @@ export const statusCommand = async (options = {}) => {
|
|
|
87
157
|
commit: currentCommit,
|
|
88
158
|
runnerIdentity: currentRunnerIdentity,
|
|
89
159
|
},
|
|
160
|
+
contentDrift: describeContentDrift(contentDrift),
|
|
90
161
|
status: isUpToDate ? 'up-to-date' : 'stale',
|
|
91
162
|
}));
|
|
92
163
|
return;
|
|
@@ -107,5 +178,19 @@ export const statusCommand = async (options = {}) => {
|
|
|
107
178
|
console.log(`Index incomplete reasons: ${JSON.stringify(incompleteReasons)}`);
|
|
108
179
|
}
|
|
109
180
|
console.log(`${t('status.currentRunnerIdentity')}: ${JSON.stringify(currentRunnerIdentity)}`);
|
|
181
|
+
if (contentDrift?.kind === 'current') {
|
|
182
|
+
console.log(t('status.indexContentCurrent', { count: contentDrift.coveredFileCount }));
|
|
183
|
+
}
|
|
184
|
+
else if (contentDrift?.kind === 'drifted') {
|
|
185
|
+
printDriftDetail(contentDrift);
|
|
186
|
+
}
|
|
187
|
+
else if (contentDrift?.kind === 'unmeasurable') {
|
|
188
|
+
if (contentDrift.reason === 'scan-failed') {
|
|
189
|
+
console.log(t('status.indexContentScanFailed'));
|
|
190
|
+
}
|
|
191
|
+
else if (!isUpToDate) {
|
|
192
|
+
console.log(t('status.indexContentUnmeasurable', { reason: contentDrift.reason }));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
110
195
|
console.log(`${t('status.status')}: ${isUpToDate ? t('status.upToDate') : t('status.stale')}`);
|
|
111
196
|
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does the index still reflect the files it actually covers?
|
|
3
|
+
*
|
|
4
|
+
* `status` used to answer this with a repo-wide `git status --porcelain`
|
|
5
|
+
* boolean, which says something different: whether the working tree differs
|
|
6
|
+
* from HEAD. Those two questions diverge in both directions. A scratch file,
|
|
7
|
+
* a build artifact, or a tracked file under a tool directory the indexer
|
|
8
|
+
* never reads makes the tree dirty while every indexed file is byte-current —
|
|
9
|
+
* and because `analyze` cannot commit or delete that file, the resulting
|
|
10
|
+
* "stale (re-run gitnexus analyze)" verdict was unclearable (#3077). It also
|
|
11
|
+
* misses the reverse case: reverting a file that was indexed while dirty
|
|
12
|
+
* leaves a clean tree over an index holding the pre-revert content.
|
|
13
|
+
*
|
|
14
|
+
* `meta.fileHashes` already records the exact set of files the last run
|
|
15
|
+
* covered, so the question can be answered directly. This module recomputes
|
|
16
|
+
* the coverage set with the same `walkRepositoryPaths` scan (ignore rules and
|
|
17
|
+
* dotfile handling stay shared) and the large-file cap recorded in
|
|
18
|
+
* `meta.indexCoverage`, hashes only the paths that can actually have changed
|
|
19
|
+
* since that run, and diffs against what was recorded.
|
|
20
|
+
*/
|
|
21
|
+
import type { RepoMeta } from '../storage/repo-meta.js';
|
|
22
|
+
/** Why the recorded coverage set could not be compared against disk at all. */
|
|
23
|
+
export type IndexContentUnmeasurableReason =
|
|
24
|
+
/** Metadata predates per-file hashes, or the run recorded none (non-git). */
|
|
25
|
+
'no-file-hashes'
|
|
26
|
+
/** The repository scan or hashing pass threw. */
|
|
27
|
+
| 'scan-failed';
|
|
28
|
+
/**
|
|
29
|
+
* A three-way verdict. `'unmeasurable'` is kept apart from `'current'` on
|
|
30
|
+
* purpose: it means the comparison never ran, which is not evidence the index
|
|
31
|
+
* is fresh. Legacy metadata without hashes still falls back to the working-tree
|
|
32
|
+
* check; a failed scan must not.
|
|
33
|
+
*/
|
|
34
|
+
export type IndexContentDrift = {
|
|
35
|
+
kind: 'current';
|
|
36
|
+
coveredFileCount: number;
|
|
37
|
+
} | {
|
|
38
|
+
kind: 'drifted';
|
|
39
|
+
changed: string[];
|
|
40
|
+
added: string[];
|
|
41
|
+
deleted: string[];
|
|
42
|
+
} | {
|
|
43
|
+
kind: 'unmeasurable';
|
|
44
|
+
reason: IndexContentUnmeasurableReason;
|
|
45
|
+
};
|
|
46
|
+
export type IndexCoveragePolicy = NonNullable<RepoMeta['indexCoverage']>;
|
|
47
|
+
/**
|
|
48
|
+
* Compare the files recorded in `fileHashes` against the current working tree.
|
|
49
|
+
*
|
|
50
|
+
* `added` covers files the index would pick up but has never seen, so a new
|
|
51
|
+
* source file still reports stale — the index is genuinely incomplete then,
|
|
52
|
+
* and comparing only the recorded entries would wave that through.
|
|
53
|
+
*/
|
|
54
|
+
export declare const detectIndexContentDrift: (repoPath: string, fileHashes: Readonly<Record<string, string>> | undefined, coverage?: IndexCoveragePolicy) => Promise<IndexContentDrift>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does the index still reflect the files it actually covers?
|
|
3
|
+
*
|
|
4
|
+
* `status` used to answer this with a repo-wide `git status --porcelain`
|
|
5
|
+
* boolean, which says something different: whether the working tree differs
|
|
6
|
+
* from HEAD. Those two questions diverge in both directions. A scratch file,
|
|
7
|
+
* a build artifact, or a tracked file under a tool directory the indexer
|
|
8
|
+
* never reads makes the tree dirty while every indexed file is byte-current —
|
|
9
|
+
* and because `analyze` cannot commit or delete that file, the resulting
|
|
10
|
+
* "stale (re-run gitnexus analyze)" verdict was unclearable (#3077). It also
|
|
11
|
+
* misses the reverse case: reverting a file that was indexed while dirty
|
|
12
|
+
* leaves a clean tree over an index holding the pre-revert content.
|
|
13
|
+
*
|
|
14
|
+
* `meta.fileHashes` already records the exact set of files the last run
|
|
15
|
+
* covered, so the question can be answered directly. This module recomputes
|
|
16
|
+
* the coverage set with the same `walkRepositoryPaths` scan (ignore rules and
|
|
17
|
+
* dotfile handling stay shared) and the large-file cap recorded in
|
|
18
|
+
* `meta.indexCoverage`, hashes only the paths that can actually have changed
|
|
19
|
+
* since that run, and diffs against what was recorded.
|
|
20
|
+
*/
|
|
21
|
+
import { constants as fsConstants } from 'node:fs';
|
|
22
|
+
import { access } from 'node:fs/promises';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { walkRepositoryPaths } from './ingestion/filesystem-walker.js';
|
|
25
|
+
import { computeFileHashesDetailed } from '../storage/file-hash.js';
|
|
26
|
+
import { listWorkingTreeDirtyPaths } from '../storage/git.js';
|
|
27
|
+
import { isGitNexusManagedPath } from '../storage/gitnexus-managed-paths.js';
|
|
28
|
+
import { chunk } from '../lib/utils.js';
|
|
29
|
+
import { logger } from './logger.js';
|
|
30
|
+
const HASH_BATCH = 100;
|
|
31
|
+
const collectUnreadablePaths = async (repoPath, relPaths) => {
|
|
32
|
+
const unreadable = [];
|
|
33
|
+
for (const batch of chunk(relPaths, HASH_BATCH)) {
|
|
34
|
+
await Promise.all(batch.map(async (rel) => {
|
|
35
|
+
try {
|
|
36
|
+
await access(path.join(repoPath, rel), fsConstants.R_OK);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
unreadable.push(rel);
|
|
40
|
+
}
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
unreadable.sort();
|
|
44
|
+
return unreadable;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Compare the files recorded in `fileHashes` against the current working tree.
|
|
48
|
+
*
|
|
49
|
+
* `added` covers files the index would pick up but has never seen, so a new
|
|
50
|
+
* source file still reports stale — the index is genuinely incomplete then,
|
|
51
|
+
* and comparing only the recorded entries would wave that through.
|
|
52
|
+
*/
|
|
53
|
+
export const detectIndexContentDrift = async (repoPath, fileHashes, coverage) => {
|
|
54
|
+
if (!fileHashes || Object.keys(fileHashes).length === 0) {
|
|
55
|
+
return { kind: 'unmeasurable', reason: 'no-file-hashes' };
|
|
56
|
+
}
|
|
57
|
+
// Excluded from BOTH sides, or GitNexus's own output guarantees a mismatch:
|
|
58
|
+
// analyze rewrites AGENTS.md/CLAUDE.md after recording hashes, so they read
|
|
59
|
+
// as `added` on a first run and `changed` on every run after that — a fresh
|
|
60
|
+
// index would report itself stale forever.
|
|
61
|
+
const recorded = Object.fromEntries(Object.entries(fileHashes).filter(([rel]) => !isGitNexusManagedPath(rel)));
|
|
62
|
+
if (Object.keys(recorded).length === 0) {
|
|
63
|
+
return { kind: 'unmeasurable', reason: 'no-file-hashes' };
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const scanned = await walkRepositoryPaths(repoPath, undefined, {
|
|
67
|
+
quiet: true,
|
|
68
|
+
maxFileSizeBytes: coverage?.maxFileSizeBytes,
|
|
69
|
+
});
|
|
70
|
+
const scannedPaths = scanned.map((file) => file.path).filter((p) => !isGitNexusManagedPath(p));
|
|
71
|
+
const scannedSet = new Set(scannedPaths);
|
|
72
|
+
const recordedSet = new Set(Object.keys(recorded));
|
|
73
|
+
// Legacy indexes have `fileHashes` but no `indexCoverage`. A later default
|
|
74
|
+
// cap would omit a still-present hashed file and call it deleted. Recorded
|
|
75
|
+
// paths that still exist stay in the coverage set even if this walk skipped
|
|
76
|
+
// them for size.
|
|
77
|
+
const recovered = new Set();
|
|
78
|
+
for (const rel of recordedSet) {
|
|
79
|
+
if (scannedSet.has(rel))
|
|
80
|
+
continue;
|
|
81
|
+
try {
|
|
82
|
+
await access(path.join(repoPath, rel), fsConstants.R_OK);
|
|
83
|
+
recovered.add(rel);
|
|
84
|
+
scannedSet.add(rel);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Missing or unreadable: stays deleted / changed below.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const added = scannedPaths.filter((p) => !recordedSet.has(p)).sort();
|
|
91
|
+
const deleted = [...recordedSet].filter((p) => !scannedSet.has(p)).sort();
|
|
92
|
+
const intersection = [...recordedSet].filter((p) => scannedSet.has(p));
|
|
93
|
+
const dirtyNow = listWorkingTreeDirtyPaths(repoPath);
|
|
94
|
+
const dirtyAtIndex = coverage?.dirtyPaths;
|
|
95
|
+
const dirtyNowSet = dirtyNow === null ? null : new Set(dirtyNow);
|
|
96
|
+
const dirtyAtIndexSet = dirtyAtIndex === undefined ? undefined : new Set(dirtyAtIndex);
|
|
97
|
+
const hashCandidates = dirtyNowSet === null || dirtyAtIndexSet === undefined
|
|
98
|
+
? intersection
|
|
99
|
+
: intersection.filter((p) => dirtyAtIndexSet.has(p) || dirtyNowSet.has(p) || recovered.has(p));
|
|
100
|
+
const hashCandidateSet = new Set(hashCandidates);
|
|
101
|
+
const skipHash = intersection.filter((p) => !hashCandidateSet.has(p));
|
|
102
|
+
const unreadableFromAccess = await collectUnreadablePaths(repoPath, skipHash);
|
|
103
|
+
const unreadableSet = new Set(unreadableFromAccess);
|
|
104
|
+
const { hashes: hashed, unreadable: unreadableFromHash } = await computeFileHashesDetailed(repoPath, hashCandidates);
|
|
105
|
+
for (const p of unreadableFromHash)
|
|
106
|
+
unreadableSet.add(p);
|
|
107
|
+
const changed = [];
|
|
108
|
+
for (const p of intersection) {
|
|
109
|
+
if (unreadableSet.has(p)) {
|
|
110
|
+
changed.push(p);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const currentHash = hashed.get(p) ?? recorded[p];
|
|
114
|
+
if (currentHash !== recorded[p])
|
|
115
|
+
changed.push(p);
|
|
116
|
+
}
|
|
117
|
+
changed.sort();
|
|
118
|
+
if (changed.length === 0 && added.length === 0 && deleted.length === 0) {
|
|
119
|
+
return { kind: 'current', coveredFileCount: scannedSet.size };
|
|
120
|
+
}
|
|
121
|
+
return { kind: 'drifted', changed, added, deleted };
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
logger.warn({ err, repoPath }, 'index content drift scan failed');
|
|
125
|
+
return { kind: 'unmeasurable', reason: 'scan-failed' };
|
|
126
|
+
}
|
|
127
|
+
};
|
|
@@ -7,11 +7,21 @@ export interface ScannedFile {
|
|
|
7
7
|
export interface FilePath {
|
|
8
8
|
path: string;
|
|
9
9
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
export interface WalkRepositoryOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Suppress the operator-facing large-file notice. Set by read-only callers
|
|
13
|
+
* such as `status`, which reuse this scan purely to learn which files the
|
|
14
|
+
* index covers and must not emit analyze's progress commentary.
|
|
15
|
+
*/
|
|
16
|
+
quiet?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Override the large-file cap. `status` replays the bytes recorded at
|
|
19
|
+
* analyze time so `--max-file-size` / `GITNEXUS_MAX_FILE_SIZE` cannot
|
|
20
|
+
* silently drop a file that the index actually covers.
|
|
21
|
+
*/
|
|
22
|
+
maxFileSizeBytes?: number;
|
|
23
|
+
}
|
|
24
|
+
export declare const walkRepositoryPaths: (repoPath: string, onProgress?: (current: number, total: number, filePath: string) => void, options?: WalkRepositoryOptions) => Promise<ScannedFile[]>;
|
|
15
25
|
/**
|
|
16
26
|
* Phase 2: Read file contents for a specific set of relative paths.
|
|
17
27
|
* Returns a Map for O(1) lookup. Silently skips files that fail to read.
|
|
@@ -39,9 +39,26 @@ const warnLargeFileSkip = (message) => {
|
|
|
39
39
|
* Phase 1: Scan repository — stat files to get paths + sizes, no content loaded.
|
|
40
40
|
* Memory: ~10MB for 100K files vs ~1GB+ with content.
|
|
41
41
|
*/
|
|
42
|
-
|
|
42
|
+
const assertWalkRootIsDirectory = async (repoPath) => {
|
|
43
|
+
let st;
|
|
44
|
+
try {
|
|
45
|
+
st = await fs.stat(repoPath);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
const code = err.code;
|
|
49
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
50
|
+
throw new Error(`walkRepositoryPaths: path does not exist: ${repoPath}`);
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
if (!st.isDirectory()) {
|
|
55
|
+
throw new Error(`walkRepositoryPaths: not a directory: ${repoPath}`);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
export const walkRepositoryPaths = async (repoPath, onProgress, options = {}) => {
|
|
59
|
+
await assertWalkRootIsDirectory(repoPath);
|
|
43
60
|
const ignoreFilter = await createIgnoreFilter(repoPath);
|
|
44
|
-
const maxFileSizeBytes = getMaxFileSizeBytes();
|
|
61
|
+
const maxFileSizeBytes = options.maxFileSizeBytes ?? getMaxFileSizeBytes();
|
|
45
62
|
const filtered = await glob('**/*', {
|
|
46
63
|
cwd: repoPath,
|
|
47
64
|
nodir: true,
|
|
@@ -81,7 +98,7 @@ export const walkRepositoryPaths = async (repoPath, onProgress) => {
|
|
|
81
98
|
// scans. Canonicalize once at the scan boundary so every downstream phase sees
|
|
82
99
|
// the same repository order.
|
|
83
100
|
deduplicatedEntries.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
84
|
-
if (skippedLarge > 0) {
|
|
101
|
+
if (skippedLarge > 0 && !options.quiet) {
|
|
85
102
|
const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES;
|
|
86
103
|
const isOverrideUnset = !process.env.GITNEXUS_MAX_FILE_SIZE;
|
|
87
104
|
const suffix = isDefault ? ', likely generated/vendored' : '';
|
|
@@ -19,20 +19,32 @@ export const scanPhase = {
|
|
|
19
19
|
percent: 0,
|
|
20
20
|
message: 'Scanning repository...',
|
|
21
21
|
});
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
ctx.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
22
|
+
let scannedFiles;
|
|
23
|
+
try {
|
|
24
|
+
scannedFiles = await walkRepositoryPaths(ctx.repoPath, (current, total, filePath) => {
|
|
25
|
+
const scanProgress = Math.round((current / total) * 15);
|
|
26
|
+
ctx.onProgress({
|
|
27
|
+
phase: 'extracting',
|
|
28
|
+
percent: scanProgress,
|
|
29
|
+
message: 'Scanning repository...',
|
|
30
|
+
detail: filePath,
|
|
31
|
+
stats: {
|
|
32
|
+
filesProcessed: current,
|
|
33
|
+
totalFiles: total,
|
|
34
|
+
nodesCreated: ctx.graph.nodeCount,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
34
37
|
});
|
|
35
|
-
}
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
// Missing roots throw so status cannot treat an empty glob as "every
|
|
41
|
+
// covered file was deleted". The pipeline still reports an empty scan
|
|
42
|
+
// for a path that is not a directory, matching analyze of a bad cwd.
|
|
43
|
+
if (err instanceof Error && err.message.startsWith('walkRepositoryPaths:')) {
|
|
44
|
+
return { scannedFiles: [], allPaths: [], totalFiles: 0 };
|
|
45
|
+
}
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
36
48
|
const totalFiles = scannedFiles.length;
|
|
37
49
|
const allPaths = scannedFiles.map((f) => f.path);
|
|
38
50
|
ctx.onProgress({
|
package/dist/core/run-analyze.js
CHANGED
|
@@ -45,7 +45,9 @@ import { shadowCandidatesFor } from './incremental/shadow-candidates.js';
|
|
|
45
45
|
import { shouldEscalateIncrementalWrite } from './incremental/escalation-gate.js';
|
|
46
46
|
import { loadParseCache, saveParseCache, pruneCache, PARSE_CACHE_VERSION, } from '../storage/parse-cache.js';
|
|
47
47
|
import { getDurableParsedFileDir, pruneAndSaveDurableParsedFileStore, } from '../storage/parsedfile-store.js';
|
|
48
|
-
import { getCurrentCommit, getCurrentBranch, getRemoteUrl, hasGitDir, getInferredRepoName, isWorkingTreeDirty, resolveRepoIdentityRoot, } from '../storage/git.js';
|
|
48
|
+
import { getCurrentCommit, getCurrentBranch, getRemoteUrl, hasGitDir, getInferredRepoName, isWorkingTreeDirty, listWorkingTreeDirtyPaths, resolveRepoIdentityRoot, } from '../storage/git.js';
|
|
49
|
+
import { isGitNexusManagedPath } from '../storage/gitnexus-managed-paths.js';
|
|
50
|
+
import { getMaxFileSizeBytes } from './ingestion/utils/max-file-size.js';
|
|
49
51
|
import { generateAIContextFiles } from '../cli/ai-context.js';
|
|
50
52
|
import { sanitizeDetectedBranch } from '../cli/analyze-config.js';
|
|
51
53
|
import { EMBEDDING_TABLE_NAME, EMBEDDING_DIMS, STALE_HASH_SENTINEL, SCHEMA_FINGERPRINT, schemaFingerprintMismatch, isSchemaFingerprintShaped, embeddingDimsMismatch, } from './lbug/schema.js';
|
|
@@ -2878,6 +2880,12 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
|
|
|
2878
2880
|
// absence has exactly one meaning — an index older than the field.
|
|
2879
2881
|
embeddingDims: EMBEDDING_DIMS,
|
|
2880
2882
|
fileHashes: hasGitDir(repoPath) ? newFileHashesRecord : undefined,
|
|
2883
|
+
indexCoverage: hasGitDir(repoPath)
|
|
2884
|
+
? {
|
|
2885
|
+
maxFileSizeBytes: getMaxFileSizeBytes(),
|
|
2886
|
+
dirtyPaths: (listWorkingTreeDirtyPaths(repoPath) ?? Object.keys(newFileHashesRecord)).filter((rel) => newFileHashesRecord[rel] !== undefined && !isGitNexusManagedPath(rel)),
|
|
2887
|
+
}
|
|
2888
|
+
: undefined,
|
|
2881
2889
|
// This branch's full live chunk-key set (#2106 R6). `usedKeys` is every
|
|
2882
2890
|
// chunk hash touched in this scan — cache HITS included (see parse-impl
|
|
2883
2891
|
// usedKeys.add) — so it's complete even on an incremental run. Persisted
|
|
@@ -371,10 +371,26 @@ export declare class LocalBackend {
|
|
|
371
371
|
* - If only 1 repo, use it
|
|
372
372
|
* - If 0 or multiple without param, throw with helpful message
|
|
373
373
|
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
374
|
+
* Re-reads the registry before an omitted implicit target or after an
|
|
375
|
+
* explicit miss, so long-running servers see newly indexed repositories.
|
|
376
376
|
*/
|
|
377
377
|
resolveRepo(repoParam?: string, branch?: string): Promise<RepoHandle>;
|
|
378
|
+
/**
|
|
379
|
+
* Internal resolver variant for CLI/MCP tool routing and discovery.
|
|
380
|
+
* - If repoParam is given, match by name or path
|
|
381
|
+
* - If only 1 repo, use it
|
|
382
|
+
* - If multiple repos exist and repoParam is omitted, callers may opt in to
|
|
383
|
+
* the registered repo containing process.cwd()
|
|
384
|
+
* - If 0 repos exist, or cwd cannot disambiguate multiple repos, throw
|
|
385
|
+
*
|
|
386
|
+
* Omitted-repo resolution re-reads the registry before accepting any
|
|
387
|
+
* implicit target, including a cached singleton. A caller that just obtained
|
|
388
|
+
* a fresh registry snapshot may disable that refresh explicitly.
|
|
389
|
+
*/
|
|
390
|
+
selectToolRepository(repoParam?: string, branch?: string, options?: {
|
|
391
|
+
allowCwdDefault?: boolean;
|
|
392
|
+
refreshRegistry?: boolean;
|
|
393
|
+
}): Promise<RepoHandle>;
|
|
378
394
|
/**
|
|
379
395
|
* Re-point a resolved repo handle at a specific branch index (#2106).
|
|
380
396
|
*
|
|
@@ -409,12 +425,13 @@ export declare class LocalBackend {
|
|
|
409
425
|
*/
|
|
410
426
|
private resolveRepoFromCache;
|
|
411
427
|
/**
|
|
412
|
-
*
|
|
428
|
+
* Match process.cwd() against indexed repositories.
|
|
413
429
|
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
430
|
+
* Explicit duplicate aliases use exact Git-root matching only. Omitted
|
|
431
|
+
* read-only calls opt into deepest containing-path selection. In that mode a
|
|
432
|
+
* candidate must not sit above cwd's Git root, so an unindexed nested checkout
|
|
433
|
+
* cannot fall through to an indexed ancestor. The `.git` ancestor fallback
|
|
434
|
+
* preserves that boundary when the git executable is unavailable.
|
|
418
435
|
*/
|
|
419
436
|
private pickRepoHandleForCwd;
|
|
420
437
|
private handleToRegistryEntry;
|