fix(analyze): revisit dirty snapshots after clean revert (#3389)

* fix(analyze): revisit dirty snapshots after clean revert

* test(shared-store): cover clean revert publication

* fix(analyze): clear hidden index flags after clean runs

* fix(analyze): reconcile hidden dirty paths

* fix(analyze): detect newly hidden edits

* test(bench): record hidden-index fixture calls

* fix(analyze): clear restored mode-only receipts

* test(bench): restore receiver baseline after mode fixture

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
Parafee41 2026-09-28 21:48:07 +08:00 • committed by GitHub
parent c744ce1dfd
commit 2925cfc024
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 180 additions and 6 deletions

View file

@ -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) {

View file

@ -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 !== '' &&

View file

@ -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);
});

View file

@ -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 {