mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
A directory removal adds no pending file, only needsFullScan. The retry after a failed sync (lock contention or a generic failure) was gated on pending files alone, so the owed full reconcile was dropped and the removed directory's files kept their nodes. Reschedule while needsFullScan is set as well. A regression test also pins the mid-sync scope-change case, which already follows a scoped pass with a full scan. Co-authored-by: danusha2345 <ewidusoc498@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -163,6 +163,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
#### MCP / indexing
|
||||
|
||||
- File watching no longer drops the full re-scan a removed directory asks for when that sync fails, so the deleted files leave the index instead of lingering. (#1964)
|
||||
- Daemon startup and cleanup now preserve live legacy PID-only locks while still reclaiming dead or identity-disproved records, preventing two writers from serving the same project.
|
||||
- Incremental sync now keeps edge rebinding crash-safe: replacing a resolved edge with its recovery reference commits atomically, so an interruption cannot permanently remove the relationship.
|
||||
- Status now detects committed but unindexed changes and restored edits without scanning every source file; thanks @inth3shadows. (#1829)
|
||||
|
||||
@@ -968,6 +968,78 @@ describe('FileWatcher', () => {
|
||||
expect(calls[0]).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['lock contention', () => new LockUnavailableError()],
|
||||
['sync failure', () => new Error('injected sync failure')],
|
||||
])('retries a full-only directory removal after %s (#1964)', async (_case, failure) => {
|
||||
const syncFn = vi.fn()
|
||||
.mockRejectedValueOnce(failure())
|
||||
.mockResolvedValue({ filesChanged: 0, durationMs: 5 });
|
||||
const watcher = newWatcher(syncFn, { debounceMs: 25 });
|
||||
watcher.start();
|
||||
try {
|
||||
await watcher.waitUntilReady();
|
||||
__emitWatchEventForTests(testDir, 'src/removed-dir');
|
||||
await waitFor(() => syncFn.mock.calls.length >= 2, 4000);
|
||||
expect(syncFn.mock.calls.map(call => call[0])).toEqual([undefined, undefined]);
|
||||
} finally {
|
||||
watcher.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('follows a scoped sync with a full scan when a directory is removed mid-sync (#1964)', async () => {
|
||||
let release!: () => void;
|
||||
const firstRun = new Promise<void>(resolve => { release = resolve; });
|
||||
const calls: (string[] | undefined)[] = [];
|
||||
const syncFn: SyncFn = async paths => {
|
||||
calls.push(paths);
|
||||
if (calls.length === 1) await firstRun;
|
||||
return { filesChanged: 0, durationMs: 5 };
|
||||
};
|
||||
const watcher = newWatcher(syncFn, { debounceMs: 25 });
|
||||
watcher.start();
|
||||
try {
|
||||
await watcher.waitUntilReady();
|
||||
fs.writeFileSync(path.join(testDir, 'src', 'a.ts'), 'export const a = 1;');
|
||||
__emitWatchEventForTests(testDir, 'src/a.ts');
|
||||
await waitFor(() => calls.length === 1, 4000);
|
||||
__emitWatchEventForTests(testDir, 'src/removed-dir');
|
||||
release();
|
||||
await waitFor(() => calls.length >= 2, 4000);
|
||||
expect(calls).toEqual([['src/a.ts'], undefined]);
|
||||
} finally {
|
||||
release();
|
||||
watcher.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('follows a scoped sync with a full scan when scope changes mid-sync (#1964)', async () => {
|
||||
let release!: () => void;
|
||||
const firstRun = new Promise<void>(resolve => { release = resolve; });
|
||||
const calls: (string[] | undefined)[] = [];
|
||||
const syncFn: SyncFn = async paths => {
|
||||
calls.push(paths);
|
||||
if (calls.length === 1) await firstRun;
|
||||
return { filesChanged: 0, durationMs: 5 };
|
||||
};
|
||||
const watcher = newWatcher(syncFn, { debounceMs: 25 });
|
||||
watcher.start();
|
||||
try {
|
||||
await watcher.waitUntilReady();
|
||||
fs.writeFileSync(path.join(testDir, 'src', 'a.ts'), 'export const a = 1;');
|
||||
__emitWatchEventForTests(testDir, 'src/a.ts');
|
||||
await waitFor(() => calls.length === 1, 4000);
|
||||
fs.writeFileSync(path.join(testDir, '.gitignore'), 'src/ignored/\n');
|
||||
__emitWatchEventForTests(testDir, '.gitignore');
|
||||
release();
|
||||
await waitFor(() => calls.length >= 2, 4000);
|
||||
expect(calls).toEqual([['src/a.ts'], undefined]);
|
||||
} finally {
|
||||
release();
|
||||
watcher.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('a lone file event fires on the quick window, well before the full debounce', async () => {
|
||||
const calls: (string[] | undefined)[] = [];
|
||||
const syncFn: SyncFn = async (paths?: string[]) => {
|
||||
|
||||
+4
-2
@@ -986,8 +986,10 @@ export class FileWatcher {
|
||||
// sync resets both counters so normal edits keep the fast debounce. Use
|
||||
// the larger streak so interleaved failures still back off. A degrade()
|
||||
// above already set `stopped`, so this won't reschedule a watcher that
|
||||
// has given up.
|
||||
if (this.pendingFiles.size > 0 && !this.stopped) {
|
||||
// has given up. A directory removal whose full sync failed adds no
|
||||
// pending file, only `needsFullScan` — it still owes the full reconcile
|
||||
// it asked for (#1964).
|
||||
if ((this.pendingFiles.size > 0 || this.needsFullScan) && !this.stopped) {
|
||||
const retryCount = Math.max(this.lockRetryCount, this.syncFailureRetryCount);
|
||||
if (retryCount > 0) {
|
||||
const retryDelayMs = Math.min(
|
||||
|
||||
Reference in New Issue
Block a user