mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
fix(status): judge freshness by covered files, not a dirty working tree (#3083)
`gitnexus status` reported "stale (re-run gitnexus analyze)" whenever the working tree held any modified or untracked file, including files the index never reads. Because `analyze` cannot commit, stash or delete such a file, the remedy it prescribed could not clear the verdict — the only way back to up-to-date was to remove the file. `meta.fileHashes` already records the exact set of files a run covered, so answer the question directly: compare those hashes against disk, reusing analyze's own scan, hash and diff helpers so the two cannot disagree about what "changed" means. A new coverable file still counts as stale (the index is genuinely incomplete then), but one `analyze` now settles it. The repo-wide dirty flag survives only as the fallback for metadata written before `fileHashes` existed. Both freshness checks now read GitNexus's own analyze output (AGENTS.md, CLAUDE.md, the agent skill mirrors) from one shared list. They previously held separate copies, and since analyze rewrites those files after recording hashes, a per-file comparison that missed them would report a freshly indexed repository as permanently stale. Closes #3077 Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
54f97c86c7
commit
170eefd4a0
15 changed files with 1429 additions and 49 deletions
|
|
@ -33,6 +33,17 @@ export const en = {
|
|||
'status.workspaceIndexLabel':
|
||||
"Workspace index: last analyzed on '{{primary}}' (re-run gitnexus analyze to follow the current branch)",
|
||||
'status.status': 'Status',
|
||||
'status.indexContentCurrent': 'Index content: matches all {{count}} covered file(s)',
|
||||
'status.indexContentDrifted':
|
||||
'Index content: {{changed}} changed, {{added}} added, {{deleted}} deleted',
|
||||
'status.indexContentMore': ' ...and {{count}} more {{label}}',
|
||||
'status.indexContentUnmeasurable':
|
||||
'Index content: not comparable ({{reason}}); fell back to the working-tree check',
|
||||
'status.indexContentScanFailed':
|
||||
'Index content: coverage scan failed; treating the index as stale',
|
||||
'status.driftChanged': 'changed',
|
||||
'status.driftAdded': 'added',
|
||||
'status.driftDeleted': 'deleted',
|
||||
'status.upToDate': '✅ up-to-date',
|
||||
'status.stale': '⚠️ stale (re-run gitnexus analyze)',
|
||||
'clean.deleteAll': 'This will delete GitNexus indexes for {{count}} repo(s):',
|
||||
|
|
|
|||
|
|
@ -37,6 +37,15 @@ export const zhCN = {
|
|||
'status.workspaceIndexLabel':
|
||||
"工作区索引:最近在 '{{primary}}' 分支上分析(重新运行 gitnexus analyze 以跟随当前分支)",
|
||||
'status.status': '状态',
|
||||
'status.indexContentCurrent': '索引内容:与覆盖的全部 {{count}} 个文件一致',
|
||||
'status.indexContentDrifted':
|
||||
'索引内容:{{changed}} 个已修改,{{added}} 个新增,{{deleted}} 个已删除',
|
||||
'status.indexContentMore': ' ……另有 {{count}} 个 {{label}}',
|
||||
'status.indexContentUnmeasurable': '索引内容:无法比对({{reason}}),已回退到工作区检查',
|
||||
'status.indexContentScanFailed': '索引内容:覆盖扫描失败,按过期处理',
|
||||
'status.driftChanged': '已修改',
|
||||
'status.driftAdded': '新增',
|
||||
'status.driftDeleted': '已删除',
|
||||
'status.upToDate': '✅ 已是最新',
|
||||
'status.stale': '⚠️ 已过期(重新运行 gitnexus analyze)',
|
||||
'clean.deleteAll': '将删除 {{count}} 个仓库的 GitNexus 索引:',
|
||||
|
|
|
|||
|
|
@ -18,8 +18,69 @@ import {
|
|||
resolveAnalyzerRunnerIdentity,
|
||||
} from '../core/analyzer-identity.js';
|
||||
import { getIndexIncompleteReasons } from '../core/index-freshness.js';
|
||||
import { detectIndexContentDrift, type IndexContentDrift } from '../core/index-content-drift.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
/** How many drifted paths the report names before summarizing the rest. */
|
||||
const DRIFT_SAMPLE_LIMIT = 10;
|
||||
|
||||
/**
|
||||
* Machine-readable form of the per-file comparison. `'not-checked'` is its own
|
||||
* value rather than a silent omission: it says the index was already stale on
|
||||
* metadata alone, so the scan was skipped, which is not the same claim as a
|
||||
* scan that ran and found nothing.
|
||||
*/
|
||||
const describeContentDrift = (drift: IndexContentDrift | undefined) => {
|
||||
if (!drift) return { status: 'not-checked' as const };
|
||||
if (drift.kind === 'current') {
|
||||
return { status: 'current' as const, coveredFiles: drift.coveredFileCount };
|
||||
}
|
||||
if (drift.kind === 'unmeasurable') {
|
||||
return { status: 'unmeasurable' as const, reason: drift.reason };
|
||||
}
|
||||
return {
|
||||
status: 'drifted' as const,
|
||||
counts: {
|
||||
changed: drift.changed.length,
|
||||
added: drift.added.length,
|
||||
deleted: drift.deleted.length,
|
||||
},
|
||||
changed: drift.changed.slice(0, DRIFT_SAMPLE_LIMIT),
|
||||
added: drift.added.slice(0, DRIFT_SAMPLE_LIMIT),
|
||||
deleted: drift.deleted.slice(0, DRIFT_SAMPLE_LIMIT),
|
||||
truncated: {
|
||||
changed: drift.changed.length > DRIFT_SAMPLE_LIMIT,
|
||||
added: drift.added.length > DRIFT_SAMPLE_LIMIT,
|
||||
deleted: drift.deleted.length > DRIFT_SAMPLE_LIMIT,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Escape control characters in repo-relative paths before printing. */
|
||||
const formatDriftPath = (rel: string): string =>
|
||||
/[\u0000-\u001f\u007f]/.test(rel) ? JSON.stringify(rel) : rel;
|
||||
const printDriftDetail = (drift: Extract<IndexContentDrift, { kind: 'drifted' }>): void => {
|
||||
console.log(
|
||||
t('status.indexContentDrifted', {
|
||||
changed: drift.changed.length,
|
||||
added: drift.added.length,
|
||||
deleted: drift.deleted.length,
|
||||
}),
|
||||
);
|
||||
const labelled: [string, readonly string[]][] = [
|
||||
[t('status.driftChanged'), drift.changed],
|
||||
[t('status.driftAdded'), drift.added],
|
||||
[t('status.driftDeleted'), drift.deleted],
|
||||
];
|
||||
for (const [label, paths] of labelled) {
|
||||
for (const p of paths.slice(0, DRIFT_SAMPLE_LIMIT)) {
|
||||
console.log(` ${label}: ${formatDriftPath(p)}`);
|
||||
}
|
||||
const remaining = paths.length - DRIFT_SAMPLE_LIMIT;
|
||||
if (remaining > 0) console.log(t('status.indexContentMore', { count: remaining, label }));
|
||||
}
|
||||
};
|
||||
|
||||
export interface StatusOptions {
|
||||
json?: boolean;
|
||||
}
|
||||
|
|
@ -85,14 +146,36 @@ export const statusCommand = async (options: StatusOptions = {}) => {
|
|||
currentRunnerIdentity,
|
||||
);
|
||||
const incompleteReasons = getIndexIncompleteReasons(activeMeta);
|
||||
// A matching HEAD is not enough: `analyze` re-indexes a dirty working tree,
|
||||
// so a repo with uncommitted source changes is stale even at the same commit.
|
||||
// Skip the check for non-git folders (currentCommit === '') to match analyze.
|
||||
const isUpToDate =
|
||||
const metadataIsCurrent =
|
||||
currentCommit === activeMeta.lastCommit &&
|
||||
runnerIdentityIsCurrent &&
|
||||
incompleteReasons.length === 0 &&
|
||||
(currentCommit === '' || !isWorkingTreeDirty(repo.repoPath));
|
||||
incompleteReasons.length === 0;
|
||||
|
||||
// A matching HEAD is not enough: `analyze` re-indexes changed content at the
|
||||
// same commit, so the files the index covers must still be compared against
|
||||
// disk. Only worth the scan once the cheap metadata checks agree, and skipped
|
||||
// for non-git folders (currentCommit === '') to match analyze.
|
||||
const contentDrift: IndexContentDrift | undefined =
|
||||
metadataIsCurrent && currentCommit !== ''
|
||||
? await detectIndexContentDrift(
|
||||
repo.repoPath,
|
||||
activeMeta.fileHashes,
|
||||
activeMeta.indexCoverage,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// The repo-wide dirty flag survives only as the fallback for metadata written
|
||||
// before `fileHashes` existed. Where the per-file comparison can run it
|
||||
// decides, so a file the index does not cover no longer pins a byte-current
|
||||
// index to a "stale" verdict that `analyze` is powerless to clear (#3077).
|
||||
const contentIsCurrent =
|
||||
contentDrift === undefined ||
|
||||
contentDrift.kind === 'current' ||
|
||||
(contentDrift.kind === 'unmeasurable' &&
|
||||
contentDrift.reason === 'no-file-hashes' &&
|
||||
!isWorkingTreeDirty(repo.repoPath));
|
||||
|
||||
const isUpToDate = metadataIsCurrent && contentIsCurrent;
|
||||
if (options.json) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
|
|
@ -111,6 +194,7 @@ export const statusCommand = async (options: StatusOptions = {}) => {
|
|||
commit: currentCommit,
|
||||
runnerIdentity: currentRunnerIdentity,
|
||||
},
|
||||
contentDrift: describeContentDrift(contentDrift),
|
||||
status: isUpToDate ? 'up-to-date' : 'stale',
|
||||
}),
|
||||
);
|
||||
|
|
@ -137,5 +221,16 @@ export const statusCommand = async (options: StatusOptions = {}) => {
|
|||
console.log(`Index incomplete reasons: ${JSON.stringify(incompleteReasons)}`);
|
||||
}
|
||||
console.log(`${t('status.currentRunnerIdentity')}: ${JSON.stringify(currentRunnerIdentity)}`);
|
||||
if (contentDrift?.kind === 'current') {
|
||||
console.log(t('status.indexContentCurrent', { count: contentDrift.coveredFileCount }));
|
||||
} else if (contentDrift?.kind === 'drifted') {
|
||||
printDriftDetail(contentDrift);
|
||||
} else if (contentDrift?.kind === 'unmeasurable') {
|
||||
if (contentDrift.reason === 'scan-failed') {
|
||||
console.log(t('status.indexContentScanFailed'));
|
||||
} else if (!isUpToDate) {
|
||||
console.log(t('status.indexContentUnmeasurable', { reason: contentDrift.reason }));
|
||||
}
|
||||
}
|
||||
console.log(`${t('status.status')}: ${isUpToDate ? t('status.upToDate') : t('status.stale')}`);
|
||||
};
|
||||
|
|
|
|||
170
gitnexus/src/core/index-content-drift.ts
Normal file
170
gitnexus/src/core/index-content-drift.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Does the index still reflect the files it actually covers?
|
||||
*
|
||||
* `status` used to answer this with a repo-wide `git status --porcelain`
|
||||
* boolean, which says something different: whether the working tree differs
|
||||
* from HEAD. Those two questions diverge in both directions. A scratch file,
|
||||
* a build artifact, or a tracked file under a tool directory the indexer
|
||||
* never reads makes the tree dirty while every indexed file is byte-current —
|
||||
* and because `analyze` cannot commit or delete that file, the resulting
|
||||
* "stale (re-run gitnexus analyze)" verdict was unclearable (#3077). It also
|
||||
* misses the reverse case: reverting a file that was indexed while dirty
|
||||
* leaves a clean tree over an index holding the pre-revert content.
|
||||
*
|
||||
* `meta.fileHashes` already records the exact set of files the last run
|
||||
* covered, so the question can be answered directly. This module recomputes
|
||||
* the coverage set with the same `walkRepositoryPaths` scan (ignore rules and
|
||||
* dotfile handling stay shared) and the large-file cap recorded in
|
||||
* `meta.indexCoverage`, hashes only the paths that can actually have changed
|
||||
* since that run, and diffs against what was recorded.
|
||||
*/
|
||||
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import { access } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { walkRepositoryPaths } from './ingestion/filesystem-walker.js';
|
||||
import { computeFileHashesDetailed } from '../storage/file-hash.js';
|
||||
import { listWorkingTreeDirtyPaths } from '../storage/git.js';
|
||||
import { isGitNexusManagedPath } from '../storage/gitnexus-managed-paths.js';
|
||||
import { chunk } from '../lib/utils.js';
|
||||
import { logger } from './logger.js';
|
||||
import type { RepoMeta } from '../storage/repo-meta.js';
|
||||
|
||||
/** Why the recorded coverage set could not be compared against disk at all. */
|
||||
export type IndexContentUnmeasurableReason =
|
||||
/** Metadata predates per-file hashes, or the run recorded none (non-git). */
|
||||
| 'no-file-hashes'
|
||||
/** The repository scan or hashing pass threw. */
|
||||
| 'scan-failed';
|
||||
|
||||
/**
|
||||
* A three-way verdict. `'unmeasurable'` is kept apart from `'current'` on
|
||||
* purpose: it means the comparison never ran, which is not evidence the index
|
||||
* is fresh. Legacy metadata without hashes still falls back to the working-tree
|
||||
* check; a failed scan must not.
|
||||
*/
|
||||
export type IndexContentDrift =
|
||||
| { kind: 'current'; coveredFileCount: number }
|
||||
| { kind: 'drifted'; changed: string[]; added: string[]; deleted: string[] }
|
||||
| { kind: 'unmeasurable'; reason: IndexContentUnmeasurableReason };
|
||||
|
||||
export type IndexCoveragePolicy = NonNullable<RepoMeta['indexCoverage']>;
|
||||
|
||||
const HASH_BATCH = 100;
|
||||
|
||||
const collectUnreadablePaths = async (
|
||||
repoPath: string,
|
||||
relPaths: readonly string[],
|
||||
): Promise<string[]> => {
|
||||
const unreadable: string[] = [];
|
||||
for (const batch of chunk(relPaths, HASH_BATCH)) {
|
||||
await Promise.all(
|
||||
batch.map(async (rel) => {
|
||||
try {
|
||||
await access(path.join(repoPath, rel), fsConstants.R_OK);
|
||||
} catch {
|
||||
unreadable.push(rel);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
unreadable.sort();
|
||||
return unreadable;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compare the files recorded in `fileHashes` against the current working tree.
|
||||
*
|
||||
* `added` covers files the index would pick up but has never seen, so a new
|
||||
* source file still reports stale — the index is genuinely incomplete then,
|
||||
* and comparing only the recorded entries would wave that through.
|
||||
*/
|
||||
export const detectIndexContentDrift = async (
|
||||
repoPath: string,
|
||||
fileHashes: Readonly<Record<string, string>> | undefined,
|
||||
coverage?: IndexCoveragePolicy,
|
||||
): Promise<IndexContentDrift> => {
|
||||
if (!fileHashes || Object.keys(fileHashes).length === 0) {
|
||||
return { kind: 'unmeasurable', reason: 'no-file-hashes' };
|
||||
}
|
||||
|
||||
// Excluded from BOTH sides, or GitNexus's own output guarantees a mismatch:
|
||||
// analyze rewrites AGENTS.md/CLAUDE.md after recording hashes, so they read
|
||||
// as `added` on a first run and `changed` on every run after that — a fresh
|
||||
// index would report itself stale forever.
|
||||
const recorded = Object.fromEntries(
|
||||
Object.entries(fileHashes).filter(([rel]) => !isGitNexusManagedPath(rel)),
|
||||
);
|
||||
if (Object.keys(recorded).length === 0) {
|
||||
return { kind: 'unmeasurable', reason: 'no-file-hashes' };
|
||||
}
|
||||
|
||||
try {
|
||||
const scanned = await walkRepositoryPaths(repoPath, undefined, {
|
||||
quiet: true,
|
||||
maxFileSizeBytes: coverage?.maxFileSizeBytes,
|
||||
});
|
||||
const scannedPaths = scanned.map((file) => file.path).filter((p) => !isGitNexusManagedPath(p));
|
||||
const scannedSet = new Set(scannedPaths);
|
||||
const recordedSet = new Set(Object.keys(recorded));
|
||||
|
||||
// Legacy indexes have `fileHashes` but no `indexCoverage`. A later default
|
||||
// cap would omit a still-present hashed file and call it deleted. Recorded
|
||||
// paths that still exist stay in the coverage set even if this walk skipped
|
||||
// them for size.
|
||||
const recovered = new Set<string>();
|
||||
for (const rel of recordedSet) {
|
||||
if (scannedSet.has(rel)) continue;
|
||||
try {
|
||||
await access(path.join(repoPath, rel), fsConstants.R_OK);
|
||||
recovered.add(rel);
|
||||
scannedSet.add(rel);
|
||||
} catch {
|
||||
// Missing or unreadable: stays deleted / changed below.
|
||||
}
|
||||
}
|
||||
|
||||
const added = scannedPaths.filter((p) => !recordedSet.has(p)).sort();
|
||||
const deleted = [...recordedSet].filter((p) => !scannedSet.has(p)).sort();
|
||||
const intersection = [...recordedSet].filter((p) => scannedSet.has(p));
|
||||
|
||||
const dirtyNow = listWorkingTreeDirtyPaths(repoPath);
|
||||
const dirtyAtIndex = coverage?.dirtyPaths;
|
||||
const dirtyNowSet = dirtyNow === null ? null : new Set(dirtyNow);
|
||||
const dirtyAtIndexSet = dirtyAtIndex === undefined ? undefined : new Set(dirtyAtIndex);
|
||||
const hashCandidates =
|
||||
dirtyNowSet === null || dirtyAtIndexSet === undefined
|
||||
? intersection
|
||||
: intersection.filter(
|
||||
(p) => dirtyAtIndexSet.has(p) || dirtyNowSet.has(p) || recovered.has(p),
|
||||
);
|
||||
|
||||
const hashCandidateSet = new Set(hashCandidates);
|
||||
const skipHash = intersection.filter((p) => !hashCandidateSet.has(p));
|
||||
const unreadableFromAccess = await collectUnreadablePaths(repoPath, skipHash);
|
||||
const unreadableSet = new Set(unreadableFromAccess);
|
||||
const { hashes: hashed, unreadable: unreadableFromHash } = await computeFileHashesDetailed(
|
||||
repoPath,
|
||||
hashCandidates,
|
||||
);
|
||||
for (const p of unreadableFromHash) unreadableSet.add(p);
|
||||
const changed: string[] = [];
|
||||
for (const p of intersection) {
|
||||
if (unreadableSet.has(p)) {
|
||||
changed.push(p);
|
||||
continue;
|
||||
}
|
||||
const currentHash = hashed.get(p) ?? recorded[p];
|
||||
if (currentHash !== recorded[p]) changed.push(p);
|
||||
}
|
||||
changed.sort();
|
||||
|
||||
if (changed.length === 0 && added.length === 0 && deleted.length === 0) {
|
||||
return { kind: 'current', coveredFileCount: scannedSet.size };
|
||||
}
|
||||
return { kind: 'drifted', changed, added, deleted };
|
||||
} catch (err) {
|
||||
logger.warn({ err, repoPath }, 'index content drift scan failed');
|
||||
return { kind: 'unmeasurable', reason: 'scan-failed' };
|
||||
}
|
||||
};
|
||||
|
|
@ -57,16 +57,49 @@ const warnLargeFileSkip = (message: string): void => {
|
|||
logger.warn(message);
|
||||
};
|
||||
|
||||
export interface WalkRepositoryOptions {
|
||||
/**
|
||||
* Suppress the operator-facing large-file notice. Set by read-only callers
|
||||
* such as `status`, which reuse this scan purely to learn which files the
|
||||
* index covers and must not emit analyze's progress commentary.
|
||||
*/
|
||||
quiet?: boolean;
|
||||
/**
|
||||
* Override the large-file cap. `status` replays the bytes recorded at
|
||||
* analyze time so `--max-file-size` / `GITNEXUS_MAX_FILE_SIZE` cannot
|
||||
* silently drop a file that the index actually covers.
|
||||
*/
|
||||
maxFileSizeBytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1: Scan repository — stat files to get paths + sizes, no content loaded.
|
||||
* Memory: ~10MB for 100K files vs ~1GB+ with content.
|
||||
*/
|
||||
const assertWalkRootIsDirectory = async (repoPath: string): Promise<void> => {
|
||||
let st;
|
||||
try {
|
||||
st = await fs.stat(repoPath);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
throw new Error(`walkRepositoryPaths: path does not exist: ${repoPath}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!st.isDirectory()) {
|
||||
throw new Error(`walkRepositoryPaths: not a directory: ${repoPath}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const walkRepositoryPaths = async (
|
||||
repoPath: string,
|
||||
onProgress?: (current: number, total: number, filePath: string) => void,
|
||||
options: WalkRepositoryOptions = {},
|
||||
): Promise<ScannedFile[]> => {
|
||||
await assertWalkRootIsDirectory(repoPath);
|
||||
const ignoreFilter = await createIgnoreFilter(repoPath);
|
||||
const maxFileSizeBytes = getMaxFileSizeBytes();
|
||||
const maxFileSizeBytes = options.maxFileSizeBytes ?? getMaxFileSizeBytes();
|
||||
|
||||
const filtered = await glob('**/*', {
|
||||
cwd: repoPath,
|
||||
|
|
@ -117,7 +150,7 @@ export const walkRepositoryPaths = async (
|
|||
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
|
||||
);
|
||||
|
||||
if (skippedLarge > 0) {
|
||||
if (skippedLarge > 0 && !options.quiet) {
|
||||
const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES;
|
||||
const isOverrideUnset = !process.env.GITNEXUS_MAX_FILE_SIZE;
|
||||
const suffix = isDefault ? ', likely generated/vendored' : '';
|
||||
|
|
|
|||
|
|
@ -30,20 +30,31 @@ export const scanPhase: PipelinePhase<ScanOutput> = {
|
|||
message: 'Scanning repository...',
|
||||
});
|
||||
|
||||
const scannedFiles = await walkRepositoryPaths(ctx.repoPath, (current, total, filePath) => {
|
||||
const scanProgress = Math.round((current / total) * 15);
|
||||
ctx.onProgress({
|
||||
phase: 'extracting',
|
||||
percent: scanProgress,
|
||||
message: 'Scanning repository...',
|
||||
detail: filePath,
|
||||
stats: {
|
||||
filesProcessed: current,
|
||||
totalFiles: total,
|
||||
nodesCreated: ctx.graph.nodeCount,
|
||||
},
|
||||
let scannedFiles;
|
||||
try {
|
||||
scannedFiles = await walkRepositoryPaths(ctx.repoPath, (current, total, filePath) => {
|
||||
const scanProgress = Math.round((current / total) * 15);
|
||||
ctx.onProgress({
|
||||
phase: 'extracting',
|
||||
percent: scanProgress,
|
||||
message: 'Scanning repository...',
|
||||
detail: filePath,
|
||||
stats: {
|
||||
filesProcessed: current,
|
||||
totalFiles: total,
|
||||
nodesCreated: ctx.graph.nodeCount,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
// Missing roots throw so status cannot treat an empty glob as "every
|
||||
// covered file was deleted". The pipeline still reports an empty scan
|
||||
// for a path that is not a directory, matching analyze of a bad cwd.
|
||||
if (err instanceof Error && err.message.startsWith('walkRepositoryPaths:')) {
|
||||
return { scannedFiles: [], allPaths: [], totalFiles: 0 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const totalFiles = scannedFiles.length;
|
||||
const allPaths = scannedFiles.map((f) => f.path);
|
||||
|
|
|
|||
|
|
@ -153,8 +153,11 @@ import {
|
|||
hasGitDir,
|
||||
getInferredRepoName,
|
||||
isWorkingTreeDirty,
|
||||
listWorkingTreeDirtyPaths,
|
||||
resolveRepoIdentityRoot,
|
||||
} from '../storage/git.js';
|
||||
import { isGitNexusManagedPath } from '../storage/gitnexus-managed-paths.js';
|
||||
import { getMaxFileSizeBytes } from './ingestion/utils/max-file-size.js';
|
||||
import type { CachedEmbedding } from './embeddings/types.js';
|
||||
import { generateAIContextFiles } from '../cli/ai-context.js';
|
||||
import { sanitizeDetectedBranch } from '../cli/analyze-config.js';
|
||||
|
|
@ -3642,6 +3645,16 @@ async function runFullAnalysisInner(
|
|||
// absence has exactly one meaning — an index older than the field.
|
||||
embeddingDims: EMBEDDING_DIMS,
|
||||
fileHashes: hasGitDir(repoPath) ? newFileHashesRecord : undefined,
|
||||
indexCoverage: hasGitDir(repoPath)
|
||||
? {
|
||||
maxFileSizeBytes: getMaxFileSizeBytes(),
|
||||
dirtyPaths: (
|
||||
listWorkingTreeDirtyPaths(repoPath) ?? Object.keys(newFileHashesRecord)
|
||||
).filter(
|
||||
(rel) => newFileHashesRecord[rel] !== undefined && !isGitNexusManagedPath(rel),
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
// This branch's full live chunk-key set (#2106 R6). `usedKeys` is every
|
||||
// chunk hash touched in this scan — cache HITS included (see parse-impl
|
||||
// usedKeys.add) — so it's complete even on an incremental run. Persisted
|
||||
|
|
|
|||
|
|
@ -44,18 +44,32 @@ export const computeFileHashes = async (
|
|||
repoPath: string,
|
||||
relPaths: readonly string[],
|
||||
): Promise<Map<string, string>> => {
|
||||
const out = new Map<string, string>();
|
||||
const { hashes } = await computeFileHashesDetailed(repoPath, relPaths);
|
||||
return hashes;
|
||||
};
|
||||
|
||||
/** Like {@link computeFileHashes}, but keeps paths whose content could not be read. */
|
||||
export const computeFileHashesDetailed = async (
|
||||
repoPath: string,
|
||||
relPaths: readonly string[],
|
||||
): Promise<{ hashes: Map<string, string>; unreadable: string[] }> => {
|
||||
const hashes = new Map<string, string>();
|
||||
const unreadable: string[] = [];
|
||||
const BATCH = 100;
|
||||
for (const batch of chunk(relPaths, BATCH)) {
|
||||
const results = await Promise.all(
|
||||
batch.map(async (rel) => {
|
||||
const h = await computeFileHash(path.join(repoPath, rel));
|
||||
return h ? ([rel, h] as const) : null;
|
||||
return { rel, h };
|
||||
}),
|
||||
);
|
||||
for (const r of results) if (r) out.set(r[0], r[1]);
|
||||
for (const { rel, h } of results) {
|
||||
if (h) hashes.set(rel, h);
|
||||
else unreadable.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
unreadable.sort();
|
||||
return { hashes, unreadable };
|
||||
};
|
||||
|
||||
/** Result of comparing the current on-disk hashes against stored ones. */
|
||||
|
|
|
|||
|
|
@ -4,42 +4,32 @@ import path from 'path';
|
|||
import os from 'os';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { toZeroBasedLine } from '../core/ingestion/utils/line-base.js';
|
||||
import { GITNEXUS_MANAGED_PATH_EXCLUDES, isGitNexusManagedPath } from './gitnexus-managed-paths.js';
|
||||
|
||||
// Git utilities for repository detection, commit tracking, and diff analysis
|
||||
|
||||
const chompGitOutput = (value: Buffer): string => value.toString().replace(/\r?\n$/, '');
|
||||
const GIT_PATH_LIST_MAX_BUFFER = 64 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* True when the working tree has uncommitted changes that analyze would
|
||||
* re-index, even at a matching HEAD. Excludes the paths GitNexus writes during
|
||||
* analyze (.gitnexus/, .claude/, .cursor/, AGENTS.md, CLAUDE.md, and the
|
||||
* repo-local .agents/ mirror) so its own output never counts as dirty
|
||||
* (regression vs PR #1233 behavior). The entire .agents/ tree is excluded,
|
||||
* matching the .claude/ treatment, because the skill mirror writes across
|
||||
* .agents/skills/ and deeper paths. Conservative on any git failure. Shared
|
||||
* so `analyze`'s fast-path gate and `status`'s freshness report agree on what
|
||||
* "dirty" means.
|
||||
* re-index, even at a matching HEAD. Excludes GITNEXUS_MANAGED_PATHS so
|
||||
* GitNexus's own analyze output never counts as dirty (regression vs PR #1233
|
||||
* behavior); whole directory trees are excluded, not just their root entries,
|
||||
* because the skill mirror writes across .agents/skills/ and deeper paths.
|
||||
* Conservative on any git failure.
|
||||
*
|
||||
* This drives `analyze`'s up-to-date fast path. It is deliberately coarse:
|
||||
* a false "dirty" here costs only a hash diff that finds nothing. `status`
|
||||
* reaches for the per-file comparison in core/index-content-drift.ts instead,
|
||||
* because there the same false positive is a verdict the user cannot clear
|
||||
* (#3077), and falls back to this only when that comparison cannot run.
|
||||
*/
|
||||
export const isWorkingTreeDirty = (repoPath: string): boolean => {
|
||||
try {
|
||||
const out = execFileSync(
|
||||
'git',
|
||||
[
|
||||
'status',
|
||||
'--porcelain',
|
||||
'--',
|
||||
'.',
|
||||
':(exclude).gitnexus',
|
||||
':(exclude).gitnexus/**',
|
||||
':(exclude).claude',
|
||||
':(exclude).claude/**',
|
||||
':(exclude).cursor',
|
||||
':(exclude).cursor/**',
|
||||
':(exclude)AGENTS.md',
|
||||
':(exclude)CLAUDE.md',
|
||||
':(exclude).agents',
|
||||
':(exclude).agents/**',
|
||||
],
|
||||
['status', '--porcelain', '--', '.', ...GITNEXUS_MANAGED_PATH_EXCLUDES],
|
||||
{
|
||||
cwd: repoPath,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
|
|
@ -53,6 +43,84 @@ export const isWorkingTreeDirty = (repoPath: string): boolean => {
|
|||
}
|
||||
};
|
||||
|
||||
const parsePorcelainPaths = (porcelain: string): string[] => {
|
||||
const paths = new Set<string>();
|
||||
const records = porcelain.split('\0');
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const record = records[i];
|
||||
if (record.length < 4) continue;
|
||||
|
||||
const status = record.slice(0, 2);
|
||||
paths.add(record.slice(3));
|
||||
|
||||
// In porcelain v1 `-z` mode, rename/copy source and destination paths are
|
||||
// separate NUL records (with no human-facing ` -> ` delimiter). Keep both:
|
||||
// either side may be present in the previous coverage set.
|
||||
if (status.includes('R') || status.includes('C')) {
|
||||
const pairedPath = records[++i];
|
||||
if (pairedPath) paths.add(pairedPath);
|
||||
}
|
||||
}
|
||||
return [...paths];
|
||||
};
|
||||
|
||||
const gitPathListExec = {
|
||||
stdio: ['ignore', 'pipe', 'ignore'] as ['ignore', 'pipe', 'ignore'],
|
||||
encoding: 'utf8' as const,
|
||||
maxBuffer: GIT_PATH_LIST_MAX_BUFFER,
|
||||
};
|
||||
|
||||
const listHiddenIndexPaths = (repoPath: string): string[] => {
|
||||
const out = execFileSync('git', ['ls-files', '-v', '-z', '--'], {
|
||||
cwd: repoPath,
|
||||
windowsHide: true,
|
||||
...gitPathListExec,
|
||||
});
|
||||
const paths: string[] = [];
|
||||
for (const record of out.split('\0')) {
|
||||
if (record.length < 3 || record[1] !== ' ') continue;
|
||||
const tag = record[0];
|
||||
// `S` marks skip-worktree. With `-v`, an assume-unchanged entry's
|
||||
// ordinary tag is lower-cased (`H` -> `h`, `S` -> `s`, etc.).
|
||||
if (tag === 'S' || (tag >= 'a' && tag <= 'z')) paths.push(record.slice(2));
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
|
||||
/**
|
||||
* Repo-relative paths `git status` reports as dirty or untracked, using the
|
||||
* same managed-path excludes as {@link isWorkingTreeDirty}, plus tracked paths
|
||||
* whose assume-unchanged or skip-worktree bits can hide content changes from
|
||||
* porcelain. `null` means either query failed — callers must not treat that as
|
||||
* a clean tree.
|
||||
*/
|
||||
export const listWorkingTreeDirtyPaths = (repoPath: string): string[] | null => {
|
||||
try {
|
||||
const out = execFileSync(
|
||||
'git',
|
||||
[
|
||||
'status',
|
||||
'--porcelain=v1',
|
||||
'-z',
|
||||
'--untracked-files=all',
|
||||
'--',
|
||||
'.',
|
||||
...GITNEXUS_MANAGED_PATH_EXCLUDES,
|
||||
],
|
||||
{ cwd: repoPath, windowsHide: true, ...gitPathListExec },
|
||||
);
|
||||
return [
|
||||
...new Set(
|
||||
[...parsePorcelainPaths(out), ...listHiddenIndexPaths(repoPath)].filter(
|
||||
(rel) => !isGitNexusManagedPath(rel),
|
||||
),
|
||||
),
|
||||
];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Snapshot, per candidate file, whether it is safe for `selfCommitContextFiles`
|
||||
* to auto-commit — call this BEFORE `analyze` writes AGENTS.md/CLAUDE.md.
|
||||
|
|
|
|||
53
gitnexus/src/storage/gitnexus-managed-paths.ts
Normal file
53
gitnexus/src/storage/gitnexus-managed-paths.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* The paths GitNexus itself writes during `analyze`.
|
||||
*
|
||||
* `analyze` rewrites the stats blocks in AGENTS.md/CLAUDE.md and refreshes the
|
||||
* agent skill mirrors as its final step — after it has recorded the per-file
|
||||
* hashes for the run. Counting its own output as a repository change makes
|
||||
* every completed run look immediately out of date, which is the regression
|
||||
* PR #1233 introduced and #1233's fix excluded these paths to prevent.
|
||||
*
|
||||
* Two freshness checks depend on this list agreeing: `isWorkingTreeDirty`
|
||||
* (analyze's up-to-date fast-path gate) and the per-file comparison behind
|
||||
* `status`. They used to hold separate copies of it, so a path added to one
|
||||
* silently became a permanent "stale" verdict in the other. One list, imported
|
||||
* by both.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Repository-root-relative. A directory entry covers everything beneath it;
|
||||
* a file entry matches only itself. Prefix collisions are NOT matches —
|
||||
* `.agentsrc` is an ordinary file, not part of the `.agents` tree.
|
||||
*/
|
||||
export const GITNEXUS_MANAGED_PATHS = [
|
||||
'.gitnexus',
|
||||
'.claude',
|
||||
'.cursor',
|
||||
'.agents',
|
||||
'AGENTS.md',
|
||||
'CLAUDE.md',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Git pathspecs excluding {@link GITNEXUS_MANAGED_PATHS} from a `git status`
|
||||
* run rooted at the repository. Patterns include `./` so they match only at
|
||||
* the repo root: a slash-free `:(exclude)AGENTS.md` would also drop
|
||||
* `docs/AGENTS.md`, which {@link isGitNexusManagedPath} does not treat as
|
||||
* managed. Both forms are emitted per entry: the root path itself, and `/**`
|
||||
* for directory contents.
|
||||
*/
|
||||
export const GITNEXUS_MANAGED_PATH_EXCLUDES: readonly string[] = GITNEXUS_MANAGED_PATHS.flatMap(
|
||||
(managed) => [`:(exclude,glob)./${managed}`, `:(exclude,glob)./${managed}/**`],
|
||||
);
|
||||
|
||||
/**
|
||||
* True when a repository-relative path is GitNexus's own output. Mirrors the
|
||||
* pathspec semantics above: root-relative, whole path segments only, so
|
||||
* neither `.agentsrc` nor a nested `subdir/.agents/` is treated as managed.
|
||||
*/
|
||||
export const isGitNexusManagedPath = (relPath: string): boolean => {
|
||||
const normalized = relPath.replace(/\\/g, '/');
|
||||
return GITNEXUS_MANAGED_PATHS.some(
|
||||
(managed) => normalized === managed || normalized.startsWith(`${managed}/`),
|
||||
);
|
||||
};
|
||||
|
|
@ -284,6 +284,18 @@ export interface RepoMeta {
|
|||
* Map keys are repo-relative paths.
|
||||
*/
|
||||
fileHashes?: Record<string, string>;
|
||||
/**
|
||||
* Coverage policy used when `fileHashes` was recorded. `status` replays it
|
||||
* so analyze-time `--max-file-size` / `GITNEXUS_MAX_FILE_SIZE` cannot make
|
||||
* a later default-cap walk drop a file the index actually covers.
|
||||
* `dirtyPaths` are covered files that were dirty vs HEAD at that moment —
|
||||
* status must re-hash those even after Git becomes clean (indexed-dirty then
|
||||
* restore). Absent on indexes written before this field.
|
||||
*/
|
||||
indexCoverage?: {
|
||||
maxFileSizeBytes: number;
|
||||
dirtyPaths?: string[];
|
||||
};
|
||||
/**
|
||||
* Set when a run finished but the persisted edge count came back far short
|
||||
* of what the pipeline produced — the B2 "refresh reports SUCCESS while the
|
||||
|
|
|
|||
|
|
@ -928,3 +928,210 @@ describe('isWorkingTreeDirty', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('listWorkingTreeDirtyPaths', () => {
|
||||
it('returns an empty list for a clean repository', async () => {
|
||||
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
|
||||
const repo = makeIsolatedGitRepo();
|
||||
try {
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), 'hi');
|
||||
execFileSync(gitExecutable, ['add', '--', 'README.md'], { cwd: repo, stdio: 'ignore' });
|
||||
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
expect(listWorkingTreeDirtyPaths(repo)).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns dirty source paths and omits GitNexus-managed writes', async () => {
|
||||
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
|
||||
const repo = makeIsolatedGitRepo();
|
||||
try {
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), 'hi');
|
||||
execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' });
|
||||
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, 'src', 'foo.ts'), 'export const x = 1;');
|
||||
fs.mkdirSync(path.join(repo, '.gitnexus'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, '.gitnexus', 'meta.json'), '{}');
|
||||
fs.writeFileSync(path.join(repo, 'AGENTS.md'), 'x');
|
||||
|
||||
expect(listWorkingTreeDirtyPaths(repo)).toEqual(['src/foo.ts']);
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('still reports nested lookalikes that are not GitNexus-managed', async () => {
|
||||
const { isWorkingTreeDirty, listWorkingTreeDirtyPaths } =
|
||||
await import('../../src/storage/git.js');
|
||||
const repo = makeIsolatedGitRepo();
|
||||
try {
|
||||
fs.mkdirSync(path.join(repo, 'docs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, 'docs', 'AGENTS.md'), 'project notes');
|
||||
execFileSync(gitExecutable, ['add', '--', 'docs/AGENTS.md'], { cwd: repo, stdio: 'ignore' });
|
||||
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
fs.writeFileSync(path.join(repo, 'docs', 'AGENTS.md'), 'edited notes');
|
||||
|
||||
expect(isWorkingTreeDirty(repo)).toBe(true);
|
||||
expect(listWorkingTreeDirtyPaths(repo)).toEqual(['docs/AGENTS.md']);
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null (not an empty list) outside a git repository', async () => {
|
||||
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
|
||||
const dir = makeIsolatedTempDir('gn-nongit-paths-');
|
||||
try {
|
||||
expect(listWorkingTreeDirtyPaths(dir)).toBeNull();
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves non-ASCII, newline, and arrow-shaped filenames exactly', async () => {
|
||||
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
|
||||
const repo = makeIsolatedGitRepo();
|
||||
const names = ['src/ä.ts'];
|
||||
if (process.platform !== 'win32') {
|
||||
names.push('src/a -> b.ts', 'src/line\nbreak.ts', 'src/tab\tname.ts', 'src/back\\slash.ts');
|
||||
}
|
||||
try {
|
||||
for (const name of names) {
|
||||
fs.mkdirSync(path.dirname(path.join(repo, name)), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, name), 'before');
|
||||
}
|
||||
execFileSync(gitExecutable, ['add', '--', ...names], { cwd: repo, stdio: 'ignore' });
|
||||
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], { cwd: repo, stdio: 'ignore' });
|
||||
for (const name of names) fs.writeFileSync(path.join(repo, name), 'after');
|
||||
|
||||
expect(listWorkingTreeDirtyPaths(repo)?.sort()).toEqual([...names].sort());
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'returns both paths for a rename without parsing filename text',
|
||||
async () => {
|
||||
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
|
||||
const repo = makeIsolatedGitRepo();
|
||||
const before = 'src/before -> literal.ts';
|
||||
const after = 'src/after -> literal.ts';
|
||||
try {
|
||||
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, before), 'content');
|
||||
execFileSync(gitExecutable, ['add', '--', before], { cwd: repo, stdio: 'ignore' });
|
||||
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], { cwd: repo, stdio: 'ignore' });
|
||||
execFileSync(gitExecutable, ['mv', '--', before, after], { cwd: repo, stdio: 'ignore' });
|
||||
|
||||
expect(listWorkingTreeDirtyPaths(repo)?.sort()).toEqual([after, before].sort());
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['--assume-unchanged', '--skip-worktree'])(
|
||||
'includes paths hidden by git update-index %s',
|
||||
async (flag) => {
|
||||
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
|
||||
const repo = makeIsolatedGitRepo();
|
||||
try {
|
||||
fs.writeFileSync(path.join(repo, 'hidden.ts'), 'before');
|
||||
execFileSync(gitExecutable, ['add', '--', 'hidden.ts'], { cwd: repo, stdio: 'ignore' });
|
||||
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
execFileSync(gitExecutable, ['update-index', flag, '--', 'hidden.ts'], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
fs.writeFileSync(path.join(repo, 'hidden.ts'), 'after');
|
||||
|
||||
expect(listWorkingTreeDirtyPaths(repo)).toContain('hidden.ts');
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['--assume-unchanged', '--skip-worktree'])(
|
||||
'omits GitNexus-managed paths hidden by git update-index %s',
|
||||
async (flag) => {
|
||||
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
|
||||
const repo = makeIsolatedGitRepo();
|
||||
try {
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), 'hi');
|
||||
fs.writeFileSync(path.join(repo, 'AGENTS.md'), 'before');
|
||||
execFileSync(gitExecutable, ['add', '--', 'README.md', 'AGENTS.md'], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
execFileSync(gitExecutable, ['update-index', flag, '--', 'AGENTS.md'], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
fs.writeFileSync(path.join(repo, 'AGENTS.md'), 'after');
|
||||
|
||||
expect(listWorkingTreeDirtyPaths(repo)).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'preserves exact unusual names hidden by index bits, including both bits',
|
||||
async () => {
|
||||
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
|
||||
const repo = makeIsolatedGitRepo();
|
||||
const names = ['ä.ts', 'a -> b.ts', 'tab\tname.ts', 'line\nbreak.ts'];
|
||||
try {
|
||||
for (const name of names) fs.writeFileSync(path.join(repo, name), 'before');
|
||||
execFileSync(gitExecutable, ['add', '--', ...names], { cwd: repo, stdio: 'ignore' });
|
||||
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
execFileSync(gitExecutable, ['update-index', '--assume-unchanged', '--', names[0]], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
execFileSync(gitExecutable, ['update-index', '--skip-worktree', '--', names[1]], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
execFileSync(gitExecutable, ['update-index', '--assume-unchanged', '--', names[2]], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
execFileSync(gitExecutable, ['update-index', '--skip-worktree', '--', names[2]], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
execFileSync(gitExecutable, ['update-index', '--skip-worktree', '--', names[3]], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
for (const name of names) fs.writeFileSync(path.join(repo, name), 'after');
|
||||
|
||||
expect(listWorkingTreeDirtyPaths(repo)?.sort()).toEqual([...names].sort());
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
404
gitnexus/test/unit/index-content-drift.test.ts
Normal file
404
gitnexus/test/unit/index-content-drift.test.ts
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
/**
|
||||
* Unit Tests: per-file index freshness (core/index-content-drift.ts)
|
||||
*
|
||||
* Issue #3077: `status` answered "is the index fresh?" with a repo-wide
|
||||
* `git status --porcelain` boolean, so a modified or untracked file the index
|
||||
* never reads pinned the verdict to "stale" — and because `analyze` cannot
|
||||
* commit or delete that file, the advice it printed could never clear it.
|
||||
*
|
||||
* These tests use real temporary directories rather than mocks: the whole
|
||||
* point of the helper is that it reuses analyze's own scan, so the ignore
|
||||
* rules and the large-file cap are exactly what the assertions are about.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
import { execFileSync } from 'child_process';
|
||||
|
||||
import { detectIndexContentDrift } from '../../src/core/index-content-drift.js';
|
||||
import { walkRepositoryPaths } from '../../src/core/ingestion/filesystem-walker.js';
|
||||
import { computeFileHashes } from '../../src/storage/file-hash.js';
|
||||
import { listWorkingTreeDirtyPaths } from '../../src/storage/git.js';
|
||||
import {
|
||||
GITNEXUS_MANAGED_PATH_EXCLUDES,
|
||||
isGitNexusManagedPath,
|
||||
} from '../../src/storage/gitnexus-managed-paths.js';
|
||||
|
||||
const gitExecutable = (() => {
|
||||
if (process.platform !== 'win32') return 'git';
|
||||
try {
|
||||
return (
|
||||
execFileSync('where.exe', ['git'], { encoding: 'utf8' }).split(/\r?\n/).find(Boolean) ?? 'git'
|
||||
);
|
||||
} catch {
|
||||
return 'git';
|
||||
}
|
||||
})();
|
||||
|
||||
const isolatedTmpRoot = (() => {
|
||||
const root =
|
||||
process.platform === 'win32'
|
||||
? path.join(path.parse(os.tmpdir()).root, 'gitnexus-drift')
|
||||
: path.join(os.tmpdir(), 'gitnexus-drift');
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
return root;
|
||||
})();
|
||||
|
||||
const createdRepos: string[] = [];
|
||||
|
||||
const makeRepo = (files: Record<string, string>): string => {
|
||||
const dir = fs.mkdtempSync(path.join(isolatedTmpRoot, 'repo-'));
|
||||
createdRepos.push(dir);
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const abs = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
/** Reproduce what `analyze` records in `meta.fileHashes` for a repository. */
|
||||
const recordCoverage = async (
|
||||
repoPath: string,
|
||||
walkOptions?: Parameters<typeof walkRepositoryPaths>[2],
|
||||
): Promise<Record<string, string>> => {
|
||||
const scanned = await walkRepositoryPaths(repoPath, undefined, walkOptions);
|
||||
const hashes = await computeFileHashes(
|
||||
repoPath,
|
||||
scanned.map((f) => f.path),
|
||||
);
|
||||
return Object.fromEntries(hashes);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
while (createdRepos.length > 0) {
|
||||
fs.rmSync(createdRepos.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('detectIndexContentDrift', () => {
|
||||
it('reports current when every covered file still matches disk', async () => {
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
const recorded = await recordCoverage(repo);
|
||||
|
||||
const drift = await detectIndexContentDrift(repo, recorded);
|
||||
|
||||
expect(drift).toEqual({ kind: 'current', coveredFileCount: Object.keys(recorded).length });
|
||||
});
|
||||
|
||||
it('stays current when a file the index does not cover is modified (#3077)', async () => {
|
||||
// `.lock` is an ignored extension, so the indexer never reads this file.
|
||||
// Under the old repo-wide dirty check its edit forced an unclearable
|
||||
// "stale" verdict on an index that was byte-current with its own coverage.
|
||||
const repo = makeRepo({
|
||||
'a.js': 'export const a = 1;\n',
|
||||
'toolingdir/state.lock': 'before\n',
|
||||
});
|
||||
const recorded = await recordCoverage(repo);
|
||||
expect(Object.keys(recorded)).not.toContain('toolingdir/state.lock');
|
||||
|
||||
fs.writeFileSync(path.join(repo, 'toolingdir/state.lock'), 'after\n');
|
||||
|
||||
expect(await detectIndexContentDrift(repo, recorded)).toMatchObject({ kind: 'current' });
|
||||
});
|
||||
|
||||
it('stays current when an ignored directory changes', async () => {
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
const recorded = await recordCoverage(repo);
|
||||
|
||||
fs.mkdirSync(path.join(repo, 'node_modules', 'left-pad'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repo, 'node_modules', 'left-pad', 'index.js'),
|
||||
'module.exports=1;\n',
|
||||
);
|
||||
|
||||
expect(await detectIndexContentDrift(repo, recorded)).toMatchObject({ kind: 'current' });
|
||||
});
|
||||
|
||||
it('reports the covered file that changed', async () => {
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n', 'b.js': 'export const b = 2;\n' });
|
||||
const recorded = await recordCoverage(repo);
|
||||
|
||||
fs.writeFileSync(path.join(repo, 'b.js'), 'export const b = 3;\n');
|
||||
|
||||
const drift = await detectIndexContentDrift(repo, recorded);
|
||||
expect(drift).toMatchObject({ kind: 'drifted', changed: ['b.js'], added: [], deleted: [] });
|
||||
});
|
||||
|
||||
it('reports a new coverable file as added rather than certifying the index', async () => {
|
||||
// The index is missing a file `analyze` would pick up, so "up-to-date"
|
||||
// would be a false all-clear even though every recorded hash matches.
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
const recorded = await recordCoverage(repo);
|
||||
|
||||
fs.writeFileSync(path.join(repo, 'new-source.js'), 'export const n = 1;\n');
|
||||
|
||||
expect(await detectIndexContentDrift(repo, recorded)).toMatchObject({
|
||||
kind: 'drifted',
|
||||
added: ['new-source.js'],
|
||||
changed: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a removed covered file as deleted', async () => {
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n', 'b.js': 'export const b = 2;\n' });
|
||||
const recorded = await recordCoverage(repo);
|
||||
|
||||
fs.rmSync(path.join(repo, 'b.js'));
|
||||
|
||||
expect(await detectIndexContentDrift(repo, recorded)).toMatchObject({
|
||||
kind: 'drifted',
|
||||
deleted: ['b.js'],
|
||||
changed: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('clears back to current once the coverage set is re-recorded', async () => {
|
||||
// The loop the issue reports: `analyze` ran, reported success, and the
|
||||
// verdict did not move. Re-recording coverage must settle the verdict.
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
const stale = await recordCoverage(repo);
|
||||
fs.writeFileSync(path.join(repo, 'notes.txt'), 'scratch\n');
|
||||
expect(await detectIndexContentDrift(repo, stale)).toMatchObject({ kind: 'drifted' });
|
||||
|
||||
const reanalyzed = await recordCoverage(repo);
|
||||
|
||||
expect(await detectIndexContentDrift(repo, reanalyzed)).toMatchObject({ kind: 'current' });
|
||||
});
|
||||
|
||||
it("ignores GitNexus's own analyze output on both sides", async () => {
|
||||
// analyze rewrites AGENTS.md/CLAUDE.md after recording hashes. Counting
|
||||
// them made a freshly indexed repo report itself stale: absent from the
|
||||
// first run's coverage, then rewritten on every run after that.
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
const firstRun = await recordCoverage(repo);
|
||||
fs.writeFileSync(path.join(repo, 'AGENTS.md'), 'stats block\n');
|
||||
fs.writeFileSync(path.join(repo, 'CLAUDE.md'), 'stats block\n');
|
||||
|
||||
expect(await detectIndexContentDrift(repo, firstRun)).toMatchObject({ kind: 'current' });
|
||||
|
||||
const secondRun = await recordCoverage(repo);
|
||||
expect(Object.keys(secondRun)).toContain('AGENTS.md');
|
||||
fs.writeFileSync(path.join(repo, 'AGENTS.md'), 'refreshed stats block\n');
|
||||
|
||||
expect(await detectIndexContentDrift(repo, secondRun)).toMatchObject({ kind: 'current' });
|
||||
});
|
||||
|
||||
it('is unmeasurable, not current, when metadata carries no file hashes', async () => {
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
|
||||
expect(await detectIndexContentDrift(repo, undefined)).toEqual({
|
||||
kind: 'unmeasurable',
|
||||
reason: 'no-file-hashes',
|
||||
});
|
||||
expect(await detectIndexContentDrift(repo, {})).toEqual({
|
||||
kind: 'unmeasurable',
|
||||
reason: 'no-file-hashes',
|
||||
});
|
||||
});
|
||||
|
||||
it('is unmeasurable when the repository scan throws', async () => {
|
||||
const drift = await detectIndexContentDrift('/no-such-gitnexus-drift-repo', {
|
||||
'a.js': 'deadbeef',
|
||||
});
|
||||
expect(drift).toEqual({ kind: 'unmeasurable', reason: 'scan-failed' });
|
||||
});
|
||||
|
||||
it('replays a recorded max-file-size so a later default cap cannot drop coverage', async () => {
|
||||
// `.bin` is a hardcoded ignore; a large source file is what analyze would
|
||||
// actually hash once `--max-file-size` / GITNEXUS_MAX_FILE_SIZE is raised.
|
||||
const raisedCap = 1024 * 1024;
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
fs.writeFileSync(path.join(repo, 'payload.js'), Buffer.alloc(700 * 1024, 1));
|
||||
const recorded = await recordCoverage(repo, { maxFileSizeBytes: raisedCap, quiet: true });
|
||||
expect(Object.keys(recorded)).toContain('payload.js');
|
||||
|
||||
const withPolicy = await detectIndexContentDrift(repo, recorded, {
|
||||
maxFileSizeBytes: raisedCap,
|
||||
});
|
||||
expect(withPolicy).toMatchObject({ kind: 'current' });
|
||||
|
||||
// No persisted policy (indexes from before `indexCoverage`): the file is
|
||||
// still on disk and hashed, so a later default cap must not call it deleted.
|
||||
expect(await detectIndexContentDrift(repo, recorded)).toMatchObject({ kind: 'current' });
|
||||
});
|
||||
|
||||
it('treats a covered file that can no longer be read as changed, not current', async () => {
|
||||
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
||||
return;
|
||||
}
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
const recorded = await recordCoverage(repo);
|
||||
const target = path.join(repo, 'a.js');
|
||||
fs.chmodSync(target, 0);
|
||||
try {
|
||||
expect(await detectIndexContentDrift(repo, recorded)).toMatchObject({
|
||||
kind: 'drifted',
|
||||
changed: ['a.js'],
|
||||
});
|
||||
} finally {
|
||||
fs.chmodSync(target, 0o644);
|
||||
}
|
||||
});
|
||||
|
||||
it('re-hashes a path that was dirty at index time even after Git is clean', async () => {
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
execFileSync(gitExecutable, ['init'], { cwd: repo });
|
||||
execFileSync(gitExecutable, ['add', '.'], { cwd: repo });
|
||||
execFileSync(
|
||||
gitExecutable,
|
||||
['-c', 'user.email=t@t.test', '-c', 'user.name=t', 'commit', '-m', 'i'],
|
||||
{ cwd: repo },
|
||||
);
|
||||
fs.writeFileSync(path.join(repo, 'a.js'), 'export const a = 2;\n');
|
||||
const recorded = await recordCoverage(repo);
|
||||
execFileSync(gitExecutable, ['checkout', '--', 'a.js'], { cwd: repo });
|
||||
|
||||
const skipped = await detectIndexContentDrift(repo, recorded, {
|
||||
maxFileSizeBytes: 512 * 1024,
|
||||
dirtyPaths: [],
|
||||
});
|
||||
expect(skipped).toMatchObject({ kind: 'current' });
|
||||
|
||||
const restored = await detectIndexContentDrift(repo, recorded, {
|
||||
maxFileSizeBytes: 512 * 1024,
|
||||
dirtyPaths: ['a.js'],
|
||||
});
|
||||
expect(restored).toMatchObject({ kind: 'drifted', changed: ['a.js'] });
|
||||
});
|
||||
|
||||
it.each(['--assume-unchanged', '--skip-worktree'])(
|
||||
'does not let git update-index %s hide covered-file drift',
|
||||
async (flag) => {
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
execFileSync(gitExecutable, ['init', '-q'], { cwd: repo });
|
||||
execFileSync(gitExecutable, ['add', '--', 'a.js'], { cwd: repo });
|
||||
execFileSync(
|
||||
gitExecutable,
|
||||
['-c', 'user.email=t@t.test', '-c', 'user.name=t', 'commit', '-q', '-m', 'init'],
|
||||
{ cwd: repo },
|
||||
);
|
||||
const recorded = await recordCoverage(repo);
|
||||
execFileSync(gitExecutable, ['update-index', flag, '--', 'a.js'], { cwd: repo });
|
||||
fs.writeFileSync(path.join(repo, 'a.js'), 'export const a = 2;\n');
|
||||
const listed = listWorkingTreeDirtyPaths(repo);
|
||||
expect(listed).not.toBeNull();
|
||||
expect(listed).toContain('a.js');
|
||||
|
||||
expect(
|
||||
await detectIndexContentDrift(repo, recorded, {
|
||||
maxFileSizeBytes: 512 * 1024,
|
||||
dirtyPaths: [],
|
||||
}),
|
||||
).toMatchObject({ kind: 'drifted', changed: ['a.js'] });
|
||||
},
|
||||
);
|
||||
|
||||
it('hashes the full intersection when the Git path query fails', async () => {
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
execFileSync(gitExecutable, ['init', '-q'], { cwd: repo });
|
||||
execFileSync(gitExecutable, ['add', '--', 'a.js'], { cwd: repo });
|
||||
execFileSync(
|
||||
gitExecutable,
|
||||
['-c', 'user.email=t@t.test', '-c', 'user.name=t', 'commit', '-q', '-m', 'init'],
|
||||
{ cwd: repo },
|
||||
);
|
||||
const recorded = await recordCoverage(repo);
|
||||
fs.writeFileSync(path.join(repo, 'a.js'), 'export const a = 2;\n');
|
||||
|
||||
const savedPath = process.env.PATH;
|
||||
try {
|
||||
process.env.PATH = '';
|
||||
expect(listWorkingTreeDirtyPaths(repo)).toBeNull();
|
||||
expect(
|
||||
await detectIndexContentDrift(repo, recorded, {
|
||||
maxFileSizeBytes: 512 * 1024,
|
||||
dirtyPaths: [],
|
||||
}),
|
||||
).toMatchObject({ kind: 'drifted', changed: ['a.js'] });
|
||||
} finally {
|
||||
process.env.PATH = savedPath;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(process.platform === 'win32' ? ['ä.js'] : ['ä.js', 'a -> b.js', 'line\nbreak.js'])(
|
||||
'detects drift for porcelain-sensitive filename %j',
|
||||
async (fileName) => {
|
||||
const repo = makeRepo({ [fileName]: 'export const a = 1;\n' });
|
||||
execFileSync(gitExecutable, ['init', '-q'], { cwd: repo });
|
||||
execFileSync(gitExecutable, ['add', '--', fileName], { cwd: repo });
|
||||
execFileSync(
|
||||
gitExecutable,
|
||||
['-c', 'user.email=t@t.test', '-c', 'user.name=t', 'commit', '-q', '-m', 'init'],
|
||||
{ cwd: repo },
|
||||
);
|
||||
const recorded = await recordCoverage(repo);
|
||||
fs.writeFileSync(path.join(repo, fileName), 'export const a = 2;\n');
|
||||
|
||||
expect(
|
||||
await detectIndexContentDrift(repo, recorded, {
|
||||
maxFileSizeBytes: 512 * 1024,
|
||||
dirtyPaths: [],
|
||||
}),
|
||||
).toMatchObject({ kind: 'drifted', changed: [fileName] });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('isGitNexusManagedPath', () => {
|
||||
it('matches managed files and whole managed trees', () => {
|
||||
expect(isGitNexusManagedPath('AGENTS.md')).toBe(true);
|
||||
expect(isGitNexusManagedPath('CLAUDE.md')).toBe(true);
|
||||
expect(isGitNexusManagedPath('.agents/skills/gitnexus-area-auth/SKILL.md')).toBe(true);
|
||||
expect(isGitNexusManagedPath('.gitnexus/meta.json')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match prefix collisions or nested lookalikes', () => {
|
||||
// Same boundaries the `:(exclude)` pathspecs enforce for isWorkingTreeDirty.
|
||||
expect(isGitNexusManagedPath('.agentsrc')).toBe(false);
|
||||
expect(isGitNexusManagedPath('.claudefoo')).toBe(false);
|
||||
expect(isGitNexusManagedPath('subdir/.agents/x')).toBe(false);
|
||||
expect(isGitNexusManagedPath('docs/AGENTS.md')).toBe(false);
|
||||
});
|
||||
|
||||
it('emits root-anchored recursive pathspecs for every managed path', () => {
|
||||
expect(GITNEXUS_MANAGED_PATH_EXCLUDES).toContain(':(exclude,glob)./AGENTS.md');
|
||||
expect(GITNEXUS_MANAGED_PATH_EXCLUDES).toContain(':(exclude,glob)./.agents');
|
||||
expect(GITNEXUS_MANAGED_PATH_EXCLUDES).toContain(':(exclude,glob)./.agents/**');
|
||||
});
|
||||
});
|
||||
|
||||
describe('walkRepositoryPaths quiet option', () => {
|
||||
const savedMaxFileSize = process.env.GITNEXUS_MAX_FILE_SIZE;
|
||||
const savedProgressActive = process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedMaxFileSize === undefined) delete process.env.GITNEXUS_MAX_FILE_SIZE;
|
||||
else process.env.GITNEXUS_MAX_FILE_SIZE = savedMaxFileSize;
|
||||
if (savedProgressActive === undefined) delete process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE;
|
||||
else process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = savedProgressActive;
|
||||
});
|
||||
|
||||
it('suppresses the large-file notice so read-only callers stay silent', async () => {
|
||||
const repo = makeRepo({ 'big.js': `// ${'x'.repeat(4096)}\n` });
|
||||
process.env.GITNEXUS_MAX_FILE_SIZE = '1'; // 1KB cap — big.js is skipped
|
||||
process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = '1'; // routes the notice to console.warn
|
||||
|
||||
const warnings: unknown[][] = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => void warnings.push(args);
|
||||
try {
|
||||
const noisy = await walkRepositoryPaths(repo);
|
||||
const noisyCount = warnings.length;
|
||||
warnings.length = 0;
|
||||
const quiet = await walkRepositoryPaths(repo, undefined, { quiet: true });
|
||||
|
||||
expect(noisyCount).toBeGreaterThan(0);
|
||||
expect(warnings).toEqual([]);
|
||||
expect(quiet).toEqual(noisy);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -66,6 +66,7 @@ vi.mock('../../src/storage/git.js', () => ({
|
|||
getCurrentBranch: vi.fn().mockReturnValue('main'),
|
||||
getGitRoot: vi.fn((p: string) => p),
|
||||
isWorkingTreeDirty: vi.fn().mockReturnValue(false),
|
||||
listWorkingTreeDirtyPaths: vi.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
import { listCommand } from '../../src/cli/list.js';
|
||||
|
|
|
|||
279
gitnexus/test/unit/status-content-drift.test.ts
Normal file
279
gitnexus/test/unit/status-content-drift.test.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
/**
|
||||
* Unit Tests: `status` freshness verdict from per-file drift (#3077)
|
||||
*
|
||||
* The reported defect was a verdict nobody could clear: any modified or
|
||||
* untracked file in the working tree — including files the index never reads —
|
||||
* made `status` print "stale (re-run gitnexus analyze)", and running `analyze`
|
||||
* left it unchanged. These tests pin the new decision order: the per-file
|
||||
* comparison decides when it can run, and the repo-wide dirty flag survives
|
||||
* only as the fallback for metadata written before `fileHashes` existed.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const { runnerIdentity } = vi.hoisted(() => ({
|
||||
runnerIdentity: {
|
||||
schemaVersion: 4 as const,
|
||||
runtime: {
|
||||
executablePath: '/usr/bin/node',
|
||||
version: 'v22.0.0',
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
modulesAbi: '127',
|
||||
libc: 'glibc:2.39',
|
||||
},
|
||||
cliVersion: '1.6.10',
|
||||
invokedArtifact: { path: '/opt/gitnexus/dist/cli/index.js', digest: 'sha256:entry' },
|
||||
build: {
|
||||
kind: 'distribution' as const,
|
||||
rootPath: '/opt/gitnexus/dist',
|
||||
canonicalization: 'gitnexus-analyzer-build-v2' as const,
|
||||
digest: 'sha256:build',
|
||||
},
|
||||
dependencyRuntime: {
|
||||
manifestPath: '/opt/gitnexus/package.json',
|
||||
lockfilePath: '/opt/package-lock.json',
|
||||
canonicalization: 'gitnexus-analyzer-dependency-runtime-v4' as const,
|
||||
packageCount: 42,
|
||||
artifactCount: 12,
|
||||
digest: 'sha256:dependencies',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
listRegisteredRepos: vi.fn(),
|
||||
findRepo: vi.fn(),
|
||||
getStoragePaths: vi.fn((repoPath: string) => ({
|
||||
storagePath: `${repoPath}/.gitnexus`,
|
||||
lbugPath: `${repoPath}/.gitnexus/lbug`,
|
||||
metaPath: `${repoPath}/.gitnexus/meta.json`,
|
||||
})),
|
||||
loadMeta: vi.fn(),
|
||||
hasKuzuIndex: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/analyzer-identity.js', () => ({
|
||||
resolveAnalyzerRunnerIdentity: vi.fn(() => runnerIdentity),
|
||||
analyzerRunnerIdentitiesEqual: vi.fn((indexed: unknown, current: unknown) => indexed === current),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/git.js', () => ({
|
||||
isGitRepo: vi.fn().mockReturnValue(true),
|
||||
getCurrentCommit: vi.fn().mockReturnValue('headsha0'),
|
||||
getCurrentBranch: vi.fn().mockReturnValue('main'),
|
||||
getGitRoot: vi.fn((p: string) => p),
|
||||
isWorkingTreeDirty: vi.fn().mockReturnValue(false),
|
||||
listWorkingTreeDirtyPaths: vi.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/index-content-drift.js', () => ({
|
||||
detectIndexContentDrift: vi.fn(),
|
||||
}));
|
||||
|
||||
import { statusCommand } from '../../src/cli/status.js';
|
||||
import { setCliLanguage } from '../../src/cli/i18n/index.js';
|
||||
import { findRepo } from '../../src/storage/repo-manager.js';
|
||||
import { getCurrentCommit, isWorkingTreeDirty } from '../../src/storage/git.js';
|
||||
import { detectIndexContentDrift } from '../../src/core/index-content-drift.js';
|
||||
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
|
||||
const repoWithCoverage = {
|
||||
repoPath: '/repo',
|
||||
storagePath: '/repo/.gitnexus',
|
||||
lbugPath: '/repo/.gitnexus/lbug',
|
||||
metaPath: '/repo/.gitnexus/meta.json',
|
||||
meta: {
|
||||
repoPath: '/repo',
|
||||
lastCommit: 'headsha0',
|
||||
indexedAt: '2026-08-28T12:00:00.000Z',
|
||||
branch: 'main',
|
||||
runnerIdentity,
|
||||
fileHashes: { 'a.js': 'sha-a' },
|
||||
scopeExtractionReceipt: 1 as const,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
(findRepo as any).mockResolvedValue(repoWithCoverage);
|
||||
(getCurrentCommit as any).mockReturnValue('headsha0');
|
||||
(isWorkingTreeDirty as any).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setCliLanguage(null);
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('status freshness from per-file drift (#3077)', () => {
|
||||
it('is up-to-date when every covered file matches, despite a dirty working tree', async () => {
|
||||
// The reported case: one modified file outside the index's coverage. The
|
||||
// old repo-wide check called this stale and `analyze` could not clear it.
|
||||
(isWorkingTreeDirty as any).mockReturnValue(true);
|
||||
(detectIndexContentDrift as any).mockResolvedValue({ kind: 'current', coveredFileCount: 210 });
|
||||
|
||||
await statusCommand({ json: true });
|
||||
|
||||
expect(JSON.parse(output())).toMatchObject({
|
||||
status: 'up-to-date',
|
||||
contentDrift: { status: 'current', coveredFiles: 210 },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports covered-file drift as stale and names the files', async () => {
|
||||
(detectIndexContentDrift as any).mockResolvedValue({
|
||||
kind: 'drifted',
|
||||
changed: ['src/app.ts'],
|
||||
added: [],
|
||||
deleted: [],
|
||||
});
|
||||
|
||||
await statusCommand();
|
||||
|
||||
const out = output();
|
||||
expect(out).not.toContain('up-to-date');
|
||||
expect(out).toContain('1 changed, 0 added, 0 deleted');
|
||||
expect(out).toContain('changed: src/app.ts');
|
||||
});
|
||||
|
||||
it('escapes control characters in drifted path names', async () => {
|
||||
(detectIndexContentDrift as any).mockResolvedValue({
|
||||
kind: 'drifted',
|
||||
changed: ['src/\u001b[31mevil.ts'],
|
||||
added: [],
|
||||
deleted: [],
|
||||
});
|
||||
|
||||
await statusCommand();
|
||||
|
||||
const out = output();
|
||||
expect(out).toContain(JSON.stringify('src/\u001b[31mevil.ts'));
|
||||
expect(out).not.toContain('\u001b[31m');
|
||||
});
|
||||
|
||||
it('localizes overflow category labels in zh-CN', async () => {
|
||||
setCliLanguage('zh-CN');
|
||||
const changed = Array.from({ length: 12 }, (_, i) => `src/file-${i}.ts`);
|
||||
(detectIndexContentDrift as any).mockResolvedValue({
|
||||
kind: 'drifted',
|
||||
changed,
|
||||
added: [],
|
||||
deleted: [],
|
||||
});
|
||||
|
||||
await statusCommand();
|
||||
|
||||
const out = output();
|
||||
expect(out).toContain('已修改: src/file-0.ts');
|
||||
expect(out).toContain('另有 2 个 已修改');
|
||||
expect(out).not.toMatch(/\bchanged\b/);
|
||||
});
|
||||
|
||||
it('names a failed coverage scan in human output instead of falling back', async () => {
|
||||
(detectIndexContentDrift as any).mockResolvedValue({
|
||||
kind: 'unmeasurable',
|
||||
reason: 'scan-failed',
|
||||
});
|
||||
|
||||
await statusCommand();
|
||||
|
||||
const out = output();
|
||||
expect(out).toContain('coverage scan failed');
|
||||
expect(out).toContain('stale');
|
||||
expect(out).not.toContain('fell back to the working-tree check');
|
||||
});
|
||||
|
||||
it('exposes drift counts and a capped sample in --json', async () => {
|
||||
const changed = Array.from({ length: 25 }, (_, i) => `src/file-${i}.ts`);
|
||||
(detectIndexContentDrift as any).mockResolvedValue({
|
||||
kind: 'drifted',
|
||||
changed,
|
||||
added: [],
|
||||
deleted: [],
|
||||
});
|
||||
|
||||
await statusCommand({ json: true });
|
||||
|
||||
const parsed = JSON.parse(output());
|
||||
expect(parsed.status).toBe('stale');
|
||||
expect(parsed.contentDrift.counts).toEqual({ changed: 25, added: 0, deleted: 0 });
|
||||
expect(parsed.contentDrift.changed).toHaveLength(10);
|
||||
expect(parsed.contentDrift.truncated).toEqual({
|
||||
changed: true,
|
||||
added: false,
|
||||
deleted: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the working-tree check when coverage cannot be compared', async () => {
|
||||
(detectIndexContentDrift as any).mockResolvedValue({
|
||||
kind: 'unmeasurable',
|
||||
reason: 'no-file-hashes',
|
||||
});
|
||||
(isWorkingTreeDirty as any).mockReturnValue(true);
|
||||
|
||||
await statusCommand({ json: true });
|
||||
|
||||
expect(JSON.parse(output())).toMatchObject({
|
||||
status: 'stale',
|
||||
contentDrift: { status: 'unmeasurable', reason: 'no-file-hashes' },
|
||||
});
|
||||
});
|
||||
|
||||
it('is stale when coverage cannot be compared because the scan failed', async () => {
|
||||
(detectIndexContentDrift as any).mockResolvedValue({
|
||||
kind: 'unmeasurable',
|
||||
reason: 'scan-failed',
|
||||
});
|
||||
|
||||
await statusCommand({ json: true });
|
||||
|
||||
expect(JSON.parse(output())).toMatchObject({
|
||||
status: 'stale',
|
||||
contentDrift: { status: 'unmeasurable', reason: 'scan-failed' },
|
||||
});
|
||||
});
|
||||
|
||||
it('is up-to-date on a clean tree when hashes are missing (legacy metadata)', async () => {
|
||||
(detectIndexContentDrift as any).mockResolvedValue({
|
||||
kind: 'unmeasurable',
|
||||
reason: 'no-file-hashes',
|
||||
});
|
||||
|
||||
await statusCommand({ json: true });
|
||||
|
||||
expect(JSON.parse(output())).toMatchObject({
|
||||
status: 'up-to-date',
|
||||
contentDrift: { status: 'unmeasurable', reason: 'no-file-hashes' },
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the scan when the index is already stale on metadata alone', async () => {
|
||||
// A moved HEAD is decided without paying for a repository-wide hash pass.
|
||||
(getCurrentCommit as any).mockReturnValue('othersha');
|
||||
|
||||
await statusCommand({ json: true });
|
||||
|
||||
expect(detectIndexContentDrift).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(output())).toMatchObject({
|
||||
status: 'stale',
|
||||
contentDrift: { status: 'not-checked' },
|
||||
});
|
||||
});
|
||||
|
||||
it('replays persisted indexCoverage into the drift check', async () => {
|
||||
const coverage = { maxFileSizeBytes: 1024 * 1024, dirtyPaths: ['a.js'] };
|
||||
(findRepo as any).mockResolvedValue({
|
||||
...repoWithCoverage,
|
||||
meta: { ...repoWithCoverage.meta, indexCoverage: coverage },
|
||||
});
|
||||
(detectIndexContentDrift as any).mockResolvedValue({ kind: 'current', coveredFileCount: 1 });
|
||||
|
||||
await statusCommand({ json: true });
|
||||
|
||||
expect(detectIndexContentDrift).toHaveBeenCalledWith('/repo', { 'a.js': 'sha-a' }, coverage);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue