mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +00:00
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:
parent
c744ce1dfd
commit
2925cfc024
4 changed files with 180 additions and 6 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 !== '' &&
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue