diff --git a/CHANGELOG.md b/CHANGELOG.md index 75535a0bbd..1b28013cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -162,6 +162,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) diff --git a/__tests__/watcher.test.ts b/__tests__/watcher.test.ts index 6b87a55ca9..a457468b92 100644 --- a/__tests__/watcher.test.ts +++ b/__tests__/watcher.test.ts @@ -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(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(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[]) => { diff --git a/src/sync/watcher.ts b/src/sync/watcher.ts index 5ea6436b6c..920a8a69c2 100644 --- a/src/sync/watcher.ts +++ b/src/sync/watcher.ts @@ -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(