diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 15e1644e7..698e27d4e 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -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):', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 63e290e54..24a6a2ad6 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -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 索引:', diff --git a/gitnexus/src/cli/status.ts b/gitnexus/src/cli/status.ts index 09eb415d3..e0ee08bcc 100644 --- a/gitnexus/src/cli/status.ts +++ b/gitnexus/src/cli/status.ts @@ -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): 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')}`); }; diff --git a/gitnexus/src/core/index-content-drift.ts b/gitnexus/src/core/index-content-drift.ts new file mode 100644 index 000000000..3599db7b1 --- /dev/null +++ b/gitnexus/src/core/index-content-drift.ts @@ -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; + +const HASH_BATCH = 100; + +const collectUnreadablePaths = async ( + repoPath: string, + relPaths: readonly string[], +): Promise => { + 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> | undefined, + coverage?: IndexCoveragePolicy, +): Promise => { + 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(); + 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' }; + } +}; diff --git a/gitnexus/src/core/ingestion/filesystem-walker.ts b/gitnexus/src/core/ingestion/filesystem-walker.ts index 804921a62..7e41c07c2 100644 --- a/gitnexus/src/core/ingestion/filesystem-walker.ts +++ b/gitnexus/src/core/ingestion/filesystem-walker.ts @@ -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 => { + 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 => { + 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' : ''; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/scan.ts b/gitnexus/src/core/ingestion/pipeline-phases/scan.ts index 5a1353267..f8629ea0d 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/scan.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/scan.ts @@ -30,20 +30,31 @@ export const scanPhase: PipelinePhase = { 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); diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 94d53de16..018efc42a 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -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 diff --git a/gitnexus/src/storage/file-hash.ts b/gitnexus/src/storage/file-hash.ts index 2b2fc9491..2c002a83a 100644 --- a/gitnexus/src/storage/file-hash.ts +++ b/gitnexus/src/storage/file-hash.ts @@ -44,18 +44,32 @@ export const computeFileHashes = async ( repoPath: string, relPaths: readonly string[], ): Promise> => { - const out = new Map(); + 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; unreadable: string[] }> => { + const hashes = new Map(); + 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. */ diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index f4bf32366..792c1010b 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -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(); + 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. diff --git a/gitnexus/src/storage/gitnexus-managed-paths.ts b/gitnexus/src/storage/gitnexus-managed-paths.ts new file mode 100644 index 000000000..cbdd7b81b --- /dev/null +++ b/gitnexus/src/storage/gitnexus-managed-paths.ts @@ -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}/`), + ); +}; diff --git a/gitnexus/src/storage/repo-meta.ts b/gitnexus/src/storage/repo-meta.ts index 3cda63545..b7ab3b871 100644 --- a/gitnexus/src/storage/repo-meta.ts +++ b/gitnexus/src/storage/repo-meta.ts @@ -284,6 +284,18 @@ export interface RepoMeta { * Map keys are repo-relative paths. */ fileHashes?: Record; + /** + * 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 diff --git a/gitnexus/test/unit/git-utils.test.ts b/gitnexus/test/unit/git-utils.test.ts index feb49effd..a5cb94083 100644 --- a/gitnexus/test/unit/git-utils.test.ts +++ b/gitnexus/test/unit/git-utils.test.ts @@ -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 }); + } + }, + ); +}); diff --git a/gitnexus/test/unit/index-content-drift.test.ts b/gitnexus/test/unit/index-content-drift.test.ts new file mode 100644 index 000000000..04b26785b --- /dev/null +++ b/gitnexus/test/unit/index-content-drift.test.ts @@ -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 => { + 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[2], +): Promise> => { + 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; + } + }); +}); diff --git a/gitnexus/test/unit/list-status-branch.test.ts b/gitnexus/test/unit/list-status-branch.test.ts index f472397f8..2f0368614 100644 --- a/gitnexus/test/unit/list-status-branch.test.ts +++ b/gitnexus/test/unit/list-status-branch.test.ts @@ -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'; diff --git a/gitnexus/test/unit/status-content-drift.test.ts b/gitnexus/test/unit/status-content-drift.test.ts new file mode 100644 index 000000000..a773f7b80 --- /dev/null +++ b/gitnexus/test/unit/status-content-drift.test.ts @@ -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; +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); + }); +});