gitnexus 1.6.11-rc.7 → 1.6.11-rc.8

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.
@@ -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):";
@@ -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;
@@ -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;
@@ -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 索引:',
@@ -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
- // A matching HEAD is not enough: `analyze` re-indexes a dirty working tree,
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
- (currentCommit === '' || !isWorkingTreeDirty(repo.repoPath));
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
- * Phase 1: Scan repository — stat files to get paths + sizes, no content loaded.
12
- * Memory: ~10MB for 100K files vs ~1GB+ with content.
13
- */
14
- export declare const walkRepositoryPaths: (repoPath: string, onProgress?: (current: number, total: number, filePath: string) => void) => Promise<ScannedFile[]>;
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
- export const walkRepositoryPaths = async (repoPath, onProgress) => {
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
- const scannedFiles = await walkRepositoryPaths(ctx.repoPath, (current, total, filePath) => {
23
- const scanProgress = Math.round((current / total) * 15);
24
- ctx.onProgress({
25
- phase: 'extracting',
26
- percent: scanProgress,
27
- message: 'Scanning repository...',
28
- detail: filePath,
29
- stats: {
30
- filesProcessed: current,
31
- totalFiles: total,
32
- nodesCreated: ctx.graph.nodeCount,
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({
@@ -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
@@ -27,6 +27,11 @@ export declare const computeFileHash: (absPath: string) => Promise<string | null
27
27
  * fail to read are omitted from the result map.
28
28
  */
29
29
  export declare const computeFileHashes: (repoPath: string, relPaths: readonly string[]) => Promise<Map<string, string>>;
30
+ /** Like {@link computeFileHashes}, but keeps paths whose content could not be read. */
31
+ export declare const computeFileHashesDetailed: (repoPath: string, relPaths: readonly string[]) => Promise<{
32
+ hashes: Map<string, string>;
33
+ unreadable: string[];
34
+ }>;
30
35
  /** Result of comparing the current on-disk hashes against stored ones. */
31
36
  export interface FileHashDiff {
32
37
  /** Files whose content hash differs from stored. */
@@ -39,18 +39,28 @@ export const computeFileHash = async (absPath) => {
39
39
  * fail to read are omitted from the result map.
40
40
  */
41
41
  export const computeFileHashes = async (repoPath, relPaths) => {
42
- const out = new Map();
42
+ const { hashes } = await computeFileHashesDetailed(repoPath, relPaths);
43
+ return hashes;
44
+ };
45
+ /** Like {@link computeFileHashes}, but keeps paths whose content could not be read. */
46
+ export const computeFileHashesDetailed = async (repoPath, relPaths) => {
47
+ const hashes = new Map();
48
+ const unreadable = [];
43
49
  const BATCH = 100;
44
50
  for (const batch of chunk(relPaths, BATCH)) {
45
51
  const results = await Promise.all(batch.map(async (rel) => {
46
52
  const h = await computeFileHash(path.join(repoPath, rel));
47
- return h ? [rel, h] : null;
53
+ return { rel, h };
48
54
  }));
49
- for (const r of results)
50
- if (r)
51
- out.set(r[0], r[1]);
55
+ for (const { rel, h } of results) {
56
+ if (h)
57
+ hashes.set(rel, h);
58
+ else
59
+ unreadable.push(rel);
60
+ }
52
61
  }
53
- return out;
62
+ unreadable.sort();
63
+ return { hashes, unreadable };
54
64
  };
55
65
  /**
56
66
  * Diff a current hash map against a previously stored one.
@@ -1,15 +1,26 @@
1
1
  /**
2
2
  * True when the working tree has uncommitted changes that analyze would
3
- * re-index, even at a matching HEAD. Excludes the paths GitNexus writes during
4
- * analyze (.gitnexus/, .claude/, .cursor/, AGENTS.md, CLAUDE.md, and the
5
- * repo-local .agents/ mirror) so its own output never counts as dirty
6
- * (regression vs PR #1233 behavior). The entire .agents/ tree is excluded,
7
- * matching the .claude/ treatment, because the skill mirror writes across
8
- * .agents/skills/ and deeper paths. Conservative on any git failure. Shared
9
- * so `analyze`'s fast-path gate and `status`'s freshness report agree on what
10
- * "dirty" means.
3
+ * re-index, even at a matching HEAD. Excludes GITNEXUS_MANAGED_PATHS so
4
+ * GitNexus's own analyze output never counts as dirty (regression vs PR #1233
5
+ * behavior); whole directory trees are excluded, not just their root entries,
6
+ * because the skill mirror writes across .agents/skills/ and deeper paths.
7
+ * Conservative on any git failure.
8
+ *
9
+ * This drives `analyze`'s up-to-date fast path. It is deliberately coarse:
10
+ * a false "dirty" here costs only a hash diff that finds nothing. `status`
11
+ * reaches for the per-file comparison in core/index-content-drift.ts instead,
12
+ * because there the same false positive is a verdict the user cannot clear
13
+ * (#3077), and falls back to this only when that comparison cannot run.
11
14
  */
12
15
  export declare const isWorkingTreeDirty: (repoPath: string) => boolean;
16
+ /**
17
+ * Repo-relative paths `git status` reports as dirty or untracked, using the
18
+ * same managed-path excludes as {@link isWorkingTreeDirty}, plus tracked paths
19
+ * whose assume-unchanged or skip-worktree bits can hide content changes from
20
+ * porcelain. `null` means either query failed — callers must not treat that as
21
+ * a clean tree.
22
+ */
23
+ export declare const listWorkingTreeDirtyPaths: (repoPath: string) => string[] | null;
13
24
  /**
14
25
  * Snapshot, per candidate file, whether it is safe for `selfCommitContextFiles`
15
26
  * to auto-commit — call this BEFORE `analyze` writes AGENTS.md/CLAUDE.md.
@@ -4,37 +4,27 @@ import path from 'path';
4
4
  import os from 'os';
5
5
  import { logger } from '../core/logger.js';
6
6
  import { toZeroBasedLine } from '../core/ingestion/utils/line-base.js';
7
+ import { GITNEXUS_MANAGED_PATH_EXCLUDES, isGitNexusManagedPath } from './gitnexus-managed-paths.js';
7
8
  // Git utilities for repository detection, commit tracking, and diff analysis
8
9
  const chompGitOutput = (value) => value.toString().replace(/\r?\n$/, '');
10
+ const GIT_PATH_LIST_MAX_BUFFER = 64 * 1024 * 1024;
9
11
  /**
10
12
  * True when the working tree has uncommitted changes that analyze would
11
- * re-index, even at a matching HEAD. Excludes the paths GitNexus writes during
12
- * analyze (.gitnexus/, .claude/, .cursor/, AGENTS.md, CLAUDE.md, and the
13
- * repo-local .agents/ mirror) so its own output never counts as dirty
14
- * (regression vs PR #1233 behavior). The entire .agents/ tree is excluded,
15
- * matching the .claude/ treatment, because the skill mirror writes across
16
- * .agents/skills/ and deeper paths. Conservative on any git failure. Shared
17
- * so `analyze`'s fast-path gate and `status`'s freshness report agree on what
18
- * "dirty" means.
13
+ * re-index, even at a matching HEAD. Excludes GITNEXUS_MANAGED_PATHS so
14
+ * GitNexus's own analyze output never counts as dirty (regression vs PR #1233
15
+ * behavior); whole directory trees are excluded, not just their root entries,
16
+ * because the skill mirror writes across .agents/skills/ and deeper paths.
17
+ * Conservative on any git failure.
18
+ *
19
+ * This drives `analyze`'s up-to-date fast path. It is deliberately coarse:
20
+ * a false "dirty" here costs only a hash diff that finds nothing. `status`
21
+ * reaches for the per-file comparison in core/index-content-drift.ts instead,
22
+ * because there the same false positive is a verdict the user cannot clear
23
+ * (#3077), and falls back to this only when that comparison cannot run.
19
24
  */
20
25
  export const isWorkingTreeDirty = (repoPath) => {
21
26
  try {
22
- const out = execFileSync('git', [
23
- 'status',
24
- '--porcelain',
25
- '--',
26
- '.',
27
- ':(exclude).gitnexus',
28
- ':(exclude).gitnexus/**',
29
- ':(exclude).claude',
30
- ':(exclude).claude/**',
31
- ':(exclude).cursor',
32
- ':(exclude).cursor/**',
33
- ':(exclude)AGENTS.md',
34
- ':(exclude)CLAUDE.md',
35
- ':(exclude).agents',
36
- ':(exclude).agents/**',
37
- ], {
27
+ const out = execFileSync('git', ['status', '--porcelain', '--', '.', ...GITNEXUS_MANAGED_PATH_EXCLUDES], {
38
28
  cwd: repoPath,
39
29
  stdio: ['ignore', 'pipe', 'ignore'],
40
30
  windowsHide: true,
@@ -46,6 +36,75 @@ export const isWorkingTreeDirty = (repoPath) => {
46
36
  return true; // conservative on git failure
47
37
  }
48
38
  };
39
+ const parsePorcelainPaths = (porcelain) => {
40
+ const paths = new Set();
41
+ const records = porcelain.split('\0');
42
+ for (let i = 0; i < records.length; i++) {
43
+ const record = records[i];
44
+ if (record.length < 4)
45
+ continue;
46
+ const status = record.slice(0, 2);
47
+ paths.add(record.slice(3));
48
+ // In porcelain v1 `-z` mode, rename/copy source and destination paths are
49
+ // separate NUL records (with no human-facing ` -> ` delimiter). Keep both:
50
+ // either side may be present in the previous coverage set.
51
+ if (status.includes('R') || status.includes('C')) {
52
+ const pairedPath = records[++i];
53
+ if (pairedPath)
54
+ paths.add(pairedPath);
55
+ }
56
+ }
57
+ return [...paths];
58
+ };
59
+ const gitPathListExec = {
60
+ stdio: ['ignore', 'pipe', 'ignore'],
61
+ encoding: 'utf8',
62
+ maxBuffer: GIT_PATH_LIST_MAX_BUFFER,
63
+ };
64
+ const listHiddenIndexPaths = (repoPath) => {
65
+ const out = execFileSync('git', ['ls-files', '-v', '-z', '--'], {
66
+ cwd: repoPath,
67
+ windowsHide: true,
68
+ ...gitPathListExec,
69
+ });
70
+ const paths = [];
71
+ for (const record of out.split('\0')) {
72
+ if (record.length < 3 || record[1] !== ' ')
73
+ continue;
74
+ const tag = record[0];
75
+ // `S` marks skip-worktree. With `-v`, an assume-unchanged entry's
76
+ // ordinary tag is lower-cased (`H` -> `h`, `S` -> `s`, etc.).
77
+ if (tag === 'S' || (tag >= 'a' && tag <= 'z'))
78
+ paths.push(record.slice(2));
79
+ }
80
+ return paths;
81
+ };
82
+ /**
83
+ * Repo-relative paths `git status` reports as dirty or untracked, using the
84
+ * same managed-path excludes as {@link isWorkingTreeDirty}, plus tracked paths
85
+ * whose assume-unchanged or skip-worktree bits can hide content changes from
86
+ * porcelain. `null` means either query failed — callers must not treat that as
87
+ * a clean tree.
88
+ */
89
+ export const listWorkingTreeDirtyPaths = (repoPath) => {
90
+ try {
91
+ const out = execFileSync('git', [
92
+ 'status',
93
+ '--porcelain=v1',
94
+ '-z',
95
+ '--untracked-files=all',
96
+ '--',
97
+ '.',
98
+ ...GITNEXUS_MANAGED_PATH_EXCLUDES,
99
+ ], { cwd: repoPath, windowsHide: true, ...gitPathListExec });
100
+ return [
101
+ ...new Set([...parsePorcelainPaths(out), ...listHiddenIndexPaths(repoPath)].filter((rel) => !isGitNexusManagedPath(rel))),
102
+ ];
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ };
49
108
  /**
50
109
  * Snapshot, per candidate file, whether it is safe for `selfCommitContextFiles`
51
110
  * to auto-commit — call this BEFORE `analyze` writes AGENTS.md/CLAUDE.md.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The paths GitNexus itself writes during `analyze`.
3
+ *
4
+ * `analyze` rewrites the stats blocks in AGENTS.md/CLAUDE.md and refreshes the
5
+ * agent skill mirrors as its final step — after it has recorded the per-file
6
+ * hashes for the run. Counting its own output as a repository change makes
7
+ * every completed run look immediately out of date, which is the regression
8
+ * PR #1233 introduced and #1233's fix excluded these paths to prevent.
9
+ *
10
+ * Two freshness checks depend on this list agreeing: `isWorkingTreeDirty`
11
+ * (analyze's up-to-date fast-path gate) and the per-file comparison behind
12
+ * `status`. They used to hold separate copies of it, so a path added to one
13
+ * silently became a permanent "stale" verdict in the other. One list, imported
14
+ * by both.
15
+ */
16
+ /**
17
+ * Repository-root-relative. A directory entry covers everything beneath it;
18
+ * a file entry matches only itself. Prefix collisions are NOT matches —
19
+ * `.agentsrc` is an ordinary file, not part of the `.agents` tree.
20
+ */
21
+ export declare const GITNEXUS_MANAGED_PATHS: readonly [".gitnexus", ".claude", ".cursor", ".agents", "AGENTS.md", "CLAUDE.md"];
22
+ /**
23
+ * Git pathspecs excluding {@link GITNEXUS_MANAGED_PATHS} from a `git status`
24
+ * run rooted at the repository. Patterns include `./` so they match only at
25
+ * the repo root: a slash-free `:(exclude)AGENTS.md` would also drop
26
+ * `docs/AGENTS.md`, which {@link isGitNexusManagedPath} does not treat as
27
+ * managed. Both forms are emitted per entry: the root path itself, and `/**`
28
+ * for directory contents.
29
+ */
30
+ export declare const GITNEXUS_MANAGED_PATH_EXCLUDES: readonly string[];
31
+ /**
32
+ * True when a repository-relative path is GitNexus's own output. Mirrors the
33
+ * pathspec semantics above: root-relative, whole path segments only, so
34
+ * neither `.agentsrc` nor a nested `subdir/.agents/` is treated as managed.
35
+ */
36
+ export declare const isGitNexusManagedPath: (relPath: string) => boolean;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The paths GitNexus itself writes during `analyze`.
3
+ *
4
+ * `analyze` rewrites the stats blocks in AGENTS.md/CLAUDE.md and refreshes the
5
+ * agent skill mirrors as its final step — after it has recorded the per-file
6
+ * hashes for the run. Counting its own output as a repository change makes
7
+ * every completed run look immediately out of date, which is the regression
8
+ * PR #1233 introduced and #1233's fix excluded these paths to prevent.
9
+ *
10
+ * Two freshness checks depend on this list agreeing: `isWorkingTreeDirty`
11
+ * (analyze's up-to-date fast-path gate) and the per-file comparison behind
12
+ * `status`. They used to hold separate copies of it, so a path added to one
13
+ * silently became a permanent "stale" verdict in the other. One list, imported
14
+ * by both.
15
+ */
16
+ /**
17
+ * Repository-root-relative. A directory entry covers everything beneath it;
18
+ * a file entry matches only itself. Prefix collisions are NOT matches —
19
+ * `.agentsrc` is an ordinary file, not part of the `.agents` tree.
20
+ */
21
+ export const GITNEXUS_MANAGED_PATHS = [
22
+ '.gitnexus',
23
+ '.claude',
24
+ '.cursor',
25
+ '.agents',
26
+ 'AGENTS.md',
27
+ 'CLAUDE.md',
28
+ ];
29
+ /**
30
+ * Git pathspecs excluding {@link GITNEXUS_MANAGED_PATHS} from a `git status`
31
+ * run rooted at the repository. Patterns include `./` so they match only at
32
+ * the repo root: a slash-free `:(exclude)AGENTS.md` would also drop
33
+ * `docs/AGENTS.md`, which {@link isGitNexusManagedPath} does not treat as
34
+ * managed. Both forms are emitted per entry: the root path itself, and `/**`
35
+ * for directory contents.
36
+ */
37
+ export const GITNEXUS_MANAGED_PATH_EXCLUDES = GITNEXUS_MANAGED_PATHS.flatMap((managed) => [`:(exclude,glob)./${managed}`, `:(exclude,glob)./${managed}/**`]);
38
+ /**
39
+ * True when a repository-relative path is GitNexus's own output. Mirrors the
40
+ * pathspec semantics above: root-relative, whole path segments only, so
41
+ * neither `.agentsrc` nor a nested `subdir/.agents/` is treated as managed.
42
+ */
43
+ export const isGitNexusManagedPath = (relPath) => {
44
+ const normalized = relPath.replace(/\\/g, '/');
45
+ return GITNEXUS_MANAGED_PATHS.some((managed) => normalized === managed || normalized.startsWith(`${managed}/`));
46
+ };
@@ -279,6 +279,18 @@ export interface RepoMeta {
279
279
  * Map keys are repo-relative paths.
280
280
  */
281
281
  fileHashes?: Record<string, string>;
282
+ /**
283
+ * Coverage policy used when `fileHashes` was recorded. `status` replays it
284
+ * so analyze-time `--max-file-size` / `GITNEXUS_MAX_FILE_SIZE` cannot make
285
+ * a later default-cap walk drop a file the index actually covers.
286
+ * `dirtyPaths` are covered files that were dirty vs HEAD at that moment —
287
+ * status must re-hash those even after Git becomes clean (indexed-dirty then
288
+ * restore). Absent on indexes written before this field.
289
+ */
290
+ indexCoverage?: {
291
+ maxFileSizeBytes: number;
292
+ dirtyPaths?: string[];
293
+ };
282
294
  /**
283
295
  * Set when a run finished but the persisted edge count came back far short
284
296
  * of what the pipeline produced — the B2 "refresh reports SUCCESS while the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.11-rc.7",
3
+ "version": "1.6.11-rc.8",
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",