diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 490e2e871..8d302c913 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -2369,6 +2369,39 @@ async function runFullAnalysisInner( // fast path because the previous analyze just wrote them // (regression vs PR #1233 behavior). const dirty = isWorkingTreeDirty(repoPath); + // A clean porcelain status is not enough when the previous index captured + // uncommitted content at this same HEAD: assume-unchanged and skip-worktree + // paths are deliberately absent from porcelain. Re-hash only the paths + // recorded dirty by the previous run. Matching hashes mean the index still + // describes disk and may take the fast path; a mismatch (including an + // unreadable/deleted file) must fall through to incremental reconciliation. + const indexedDirtyPaths = existingMeta.indexCoverage?.dirtyPaths ?? []; + let indexedContentChanged = indexedDirtyPaths.length > 0; + let reconciledCleanCoverage = false; + if (!dirty && existingMeta.fileHashes) { + // Porcelain also hides newly edited assume-unchanged/skip-worktree + // paths that were absent from the previous receipt. Include the live + // hidden-path set in the bounded comparison so those edits cannot take + // the fast path and publish stale content for HEAD. + const liveDirtyPaths = listWorkingTreeDirtyPaths(repoPath); + if (liveDirtyPaths === null) { + indexedContentChanged = true; + } else { + const pathsToCheck = [...new Set([...indexedDirtyPaths, ...liveDirtyPaths])].filter( + (rel) => existingMeta.fileHashes?.[rel] !== undefined, + ); + const currentDirtyHashes = await computeFileHashes(repoPath, pathsToCheck); + indexedContentChanged = pathsToCheck.some( + (rel) => currentDirtyHashes.get(rel) !== existingMeta.fileHashes?.[rel], + ); + // A mode-only dirty snapshot can leave a coverage receipt even though + // restoring the mode makes porcelain clean and content hashes equal. + // Clear that receipt before taking the fast path; otherwise shared + // publication remains blocked forever despite a clean checkout. + reconciledCleanCoverage = + !indexedContentChanged && indexedDirtyPaths.length > 0 && liveDirtyPaths.length === 0; + } + } // Registration wrinkle around the fast path (#2264). A prior // `analyze --name X` that hit a name collision writes meta.json (meta-save // runs before registerRepo) then fails before registering, leaving the @@ -2383,6 +2416,19 @@ async function runFullAnalysisInner( // opt-in branch so the common fast path keeps its single-stat cost. const healUnregistered = options.allowDuplicateName === true && !(await isRepoRegistered(repoPath)); + if (reconciledCleanCoverage) { + try { + existingMeta.indexCoverage = { + ...existingMeta.indexCoverage, + dirtyPaths: [], + }; + await saveMeta(metaDir, existingMeta); + } catch { + // If the receipt cannot be persisted, fall through to the normal + // reconciliation path instead of returning with stale metadata. + indexedContentChanged = true; + } + } // §5.C is deliberately NOT self-healed here. An #2841 FTS-forced rebuild // stamps `lastCommit`, so a plain rerun lands on this fast path and the // search indexes stay missing until the next content change. The fix for @@ -2398,7 +2444,7 @@ async function runFullAnalysisInner( // re-analysis whenever an index authored where FTS was unavailable was // later read on a host where it loads — which is a legitimate, common // state, and the invariant `analyzer-identity-cli.test.ts` pins. - if (!dirty && !healUnregistered) { + if (!dirty && !indexedContentChanged && !healUnregistered) { const processDetectionStamp = existingMeta.processDetection ?? toProcessDetectionStamp(processDetectionBudget); if (options.registryName) { diff --git a/gitnexus/src/core/shared-store-analyze.ts b/gitnexus/src/core/shared-store-analyze.ts index f51f58785..1754cc338 100644 --- a/gitnexus/src/core/shared-store-analyze.ts +++ b/gitnexus/src/core/shared-store-analyze.ts @@ -378,9 +378,8 @@ export const publishSharedGraph = async ( if (!meta) return; const own = path.join(slot, LBUG_DIRECTORY); - // A graph built while files were dirty still holds those edits even after - // they are reverted (the up-to-date path does not re-diff a clean tree), so - // only a graph whose build saw no dirty covered file may become shared. + // A graph built while files were dirty must not become shared until analyze + // has reconciled those paths with disk and cleared them from the coverage receipt. const builtClean = (meta.indexCoverage?.dirtyPaths ?? []).length === 0; const shareable = currentCommit !== '' && diff --git a/gitnexus/test/integration/shared-store-analyze.test.ts b/gitnexus/test/integration/shared-store-analyze.test.ts index 5c5df84f9..2c08b47a3 100644 --- a/gitnexus/test/integration/shared-store-analyze.test.ts +++ b/gitnexus/test/integration/shared-store-analyze.test.ts @@ -220,12 +220,20 @@ describe('shared sibling store analyze (#3352)', () => { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await fs.writeFile(path.join(wtA, 'a.ts'), 'export function uncommitted() { return 9; }\n'); await runFullAnalysis(wtA, {}, { onProgress: () => {} }); - git(wtA, 'checkout', '--', 'a.ts'); - await runFullAnalysis(wtA, {}, { onProgress: () => {} }); const layout = layoutOf(wtA); expect(await listCommitDirs(layout)).toEqual([]); expect(existsSync(path.join(layout.checkoutSlot, 'lbug'))).toBe(true); + + git(wtA, 'checkout', '--', 'a.ts'); + await runFullAnalysis(wtA, {}, { onProgress: () => {} }); + + const meta = await loadMeta(layout.checkoutSlot); + expect(meta?.indexCoverage?.dirtyPaths).toEqual([]); + expect(await listCommitDirs(layout)).toEqual([ + path.basename(path.dirname(meta?.graphPath as string)), + ]); + expect(existsSync(path.join(layout.checkoutSlot, 'lbug'))).toBe(false); }, 180_000); }); diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts index 4825406bb..7263c46bb 100644 --- a/gitnexus/test/unit/incremental-orchestration.test.ts +++ b/gitnexus/test/unit/incremental-orchestration.test.ts @@ -1167,6 +1167,127 @@ describe('runFullAnalysis — incremental orchestration', () => { } }, 300_000); + it('re-indexes a dirty snapshot after the file is restored at the same HEAD', async () => { + const repo = await setupMiniRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const target = path.join(repo.dbPath, 'src', 'logger.ts'); + const clean = await readFile(target, 'utf-8'); + + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + await writeFile(target, `${clean}\n// temporary dirty snapshot\n`, 'utf-8'); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + + const { storagePath } = getStoragePaths(repo.dbPath); + const dirtyMeta = await loadMeta(storagePath); + expect(dirtyMeta?.indexCoverage?.dirtyPaths).toContain('src/logger.ts'); + const dirtyHash = dirtyMeta?.fileHashes?.['src/logger.ts']; + + await writeFile(target, clean, 'utf-8'); + const restored = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { onProgress: () => {} }, + ); + + expect(restored.alreadyUpToDate).toBeUndefined(); + const restoredMeta = await loadMeta(storagePath); + expect(restoredMeta?.indexCoverage?.dirtyPaths).toEqual([]); + expect(restoredMeta?.fileHashes?.['src/logger.ts']).not.toBe(dirtyHash); + + const steady = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { onProgress: () => {} }, + ); + expect(steady.alreadyUpToDate).toBe(true); + } finally { + await repo.cleanup(); + } + }, 300_000); + + it('re-indexes a restored assume-unchanged edit, then returns to the fast path', async () => { + const repo = await setupMiniRepo(); + try { + const target = path.join(repo.dbPath, 'src', 'logger.ts'); + const clean = await readFile(target, 'utf-8'); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + execSync('git update-index --assume-unchanged src/logger.ts', { + cwd: repo.dbPath, + stdio: 'pipe', + }); + + await writeFile(target, `${clean}\n// hidden dirty snapshot\n`, 'utf-8'); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + + const { storagePath } = getStoragePaths(repo.dbPath); + const dirtyMeta = await loadMeta(storagePath); + expect(dirtyMeta?.indexCoverage?.dirtyPaths).toContain('src/logger.ts'); + const dirtyHash = dirtyMeta?.fileHashes?.['src/logger.ts']; + + await writeFile(target, clean, 'utf-8'); + execSync('git update-index --no-assume-unchanged src/logger.ts', { + cwd: repo.dbPath, + stdio: 'pipe', + }); + const restored = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { onProgress: () => {} }, + ); + expect(restored.alreadyUpToDate).toBeUndefined(); + + const restoredMeta = await loadMeta(storagePath); + expect(restoredMeta?.indexCoverage?.dirtyPaths).toEqual([]); + expect(restoredMeta?.fileHashes?.['src/logger.ts']).not.toBe(dirtyHash); + + const steady = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { onProgress: () => {} }, + ); + expect(steady.alreadyUpToDate).toBe(true); + } finally { + await repo.cleanup(); + } + }, 300_000); + + it('clears a mode-only dirty receipt when the clean content still matches', async () => { + const repo = await setupMiniRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + + execSync('git update-index --chmod=+x src/logger.ts', { + cwd: repo.dbPath, + stdio: 'pipe', + }); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + + const { storagePath } = getStoragePaths(repo.dbPath); + const dirtyMeta = await loadMeta(storagePath); + expect(dirtyMeta?.indexCoverage?.dirtyPaths).toContain('src/logger.ts'); + + execSync('git update-index --chmod=-x src/logger.ts', { + cwd: repo.dbPath, + stdio: 'pipe', + }); + const restored = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { onProgress: () => {} }, + ); + + expect(restored.alreadyUpToDate).toBe(true); + const restoredMeta = await loadMeta(storagePath); + expect(restoredMeta?.indexCoverage?.dirtyPaths).toEqual([]); + } finally { + await repo.cleanup(); + } + }, 300_000); + it('skips the framework annotation drift query when no Bean source changed', async () => { const repo = await setupMiniRepo(); try {