From 2977f063639c1f268175887c11db638d28fb51d8 Mon Sep 17 00:00:00 2001 From: JJordan0K <69581081+JJordan0C@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:35:38 +0200 Subject: [PATCH] fix Windows watcher false edits from NTFS atime --- CHANGELOG.md | 3 ++ __tests__/watcher.test.ts | 84 ++++++++++++++++++++++++++++++++++++++- src/index.ts | 28 ++++++++++++- src/sync/watcher.ts | 25 +++++++++++- 4 files changed, 136 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ae567d0..2fc980977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- On Windows systems with NTFS last-access updates enabled, reading indexed source files no longer makes CodeGraph report them as edited or trigger needless auto-syncs. (#1451) ## [1.5.0] - 2026-07-21 diff --git a/__tests__/watcher.test.ts b/__tests__/watcher.test.ts index f493a96cc..3e53bd03e 100644 --- a/__tests__/watcher.test.ts +++ b/__tests__/watcher.test.ts @@ -59,8 +59,16 @@ describe('FileWatcher', () => { // Inert by default — unit tests drive events via __emitWatchEventForTests // and never depend on real OS watch delivery. - const newWatcher = (syncFn: SyncFn, opts: WatchOptions = {}) => - new FileWatcher(testDir, syncFn, { inertForTests: true, ...opts }); + const newWatcher = ( + syncFn: SyncFn, + opts: WatchOptions = {}, + isFileStateCurrent?: (relativePath: string) => boolean + ) => new FileWatcher( + testDir, + syncFn, + { inertForTests: true, ...opts }, + isFileStateCurrent + ); beforeEach(() => { testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-watcher-')); @@ -546,6 +554,47 @@ describe('FileWatcher', () => { }); describe('pending file tracking (#403)', () => { + it('should ignore events whose filesystem metadata is already indexed (#1451)', async () => { + const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 }); + const isFileStateCurrent = vi.fn().mockReturnValue(true); + const watcher = newWatcher( + syncFn, + { debounceMs: 100 }, + isFileStateCurrent + ); + watcher.start(); + await watcher.waitUntilReady(); + + __emitWatchEventForTests(testDir, 'src/index.ts'); + + expect(isFileStateCurrent).toHaveBeenCalledWith('src/index.ts'); + expect(watcher.getPendingFiles()).toEqual([]); + await new Promise((r) => setTimeout(r, 200)); + expect(syncFn).not.toHaveBeenCalled(); + + watcher.stop(); + }); + + it('should keep an event when the metadata check fails open (#1451)', async () => { + const syncFn = vi.fn().mockResolvedValue({ filesChanged: 1, durationMs: 10 }); + const isFileStateCurrent = vi.fn(() => { + throw new Error('database busy'); + }); + const watcher = newWatcher( + syncFn, + { debounceMs: 2000 }, + isFileStateCurrent + ); + watcher.start(); + await watcher.waitUntilReady(); + + __emitWatchEventForTests(testDir, 'src/index.ts'); + + expect(watcher.getPendingFiles().map((p) => p.path)).toContain('src/index.ts'); + + watcher.stop(); + }); + it('should expose edited paths via getPendingFiles before sync fires', async () => { // Slow debounce — pending entries are visible until the debounce fires. // The synthetic event is synchronous, so we can assert immediately. @@ -769,6 +818,37 @@ describe('FileWatcher', () => { cg.unwatch(); }); + + it.runIf(process.platform === 'win32')( + 'should ignore an NTFS access-only event but retain a real edit (#1451)', + async () => { + const filePath = path.join(testDir, 'src', 'index.ts'); + cg = CodeGraph.initSync(testDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + + cg.watch({ debounceMs: 2000, inertForTests: true }); + await cg.waitUntilWatcherReady(); + + const before = fs.statSync(filePath); + fs.utimesSync( + filePath, + new Date(before.atimeMs + 2000), + new Date(before.mtimeMs) + ); + __emitWatchEventForTests(testDir, 'src/index.ts'); + + expect(cg.getPendingFiles()).toEqual([]); + + fs.appendFileSync(filePath, '\nexport const changed = true;\n'); + __emitWatchEventForTests(testDir, 'src/index.ts'); + + expect(cg.getPendingFiles().map((p) => p.path)).toContain('src/index.ts'); + + cg.unwatch(); + } + ); }); describe('scoped sync fast path (#watcher-scoped)', () => { diff --git a/src/index.ts b/src/index.ts index 461ff4797..b027829a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ * knowledge graph from any codebase. */ +import * as fs from 'fs'; import * as path from 'path'; import { Node, @@ -976,6 +977,28 @@ export class CodeGraph { // File Watching // =========================================================================== + /** + * Whether an OS watcher event points at a file whose index metadata is still + * current. Used on Windows to discard NTFS last-access notifications, which + * libuv reports through fs.watch as ordinary change events (#1451). + */ + private isIndexedFileStateCurrent(filePath: string): boolean { + const tracked = this.queries.getFileByPath(filePath); + if (!tracked) return false; + + try { + const stat = fs.statSync(path.join(this.projectRoot, filePath)); + return ( + stat.size === tracked.size && + Math.floor(stat.mtimeMs) === Math.floor(tracked.modifiedAt) + ); + } catch { + // Missing/inaccessible files must still reach sync so removals and + // transient filesystem failures are reconciled rather than hidden. + return false; + } + } + /** * Start watching for file changes and auto-syncing. * @@ -1003,7 +1026,10 @@ export class CodeGraph { const filesChanged = result.filesAdded + result.filesModified + result.filesRemoved; return { filesChanged, durationMs: result.durationMs }; }, - options + options, + process.platform === 'win32' + ? (filePath) => this.isIndexedFileStateCurrent(filePath) + : undefined ); return this.watcher.start(); diff --git a/src/sync/watcher.ts b/src/sync/watcher.ts index fed6ea608..3bfb7ed9c 100644 --- a/src/sync/watcher.ts +++ b/src/sync/watcher.ts @@ -342,11 +342,19 @@ export class FileWatcher { private readonly onSyncError?: WatchOptions['onSyncError']; private readonly onDegraded?: WatchOptions['onDegraded']; private readonly inertForTests: boolean; + /** + * Optional metadata guard supplied by the CodeGraph facade. Windows' + * ReadDirectoryChangesW stream includes last-access updates, so a read can + * otherwise look exactly like an edit. Returning true means the file's + * current size/mtime still match the indexed record and the event is noise. + */ + private readonly isFileStateCurrent?: (relativePath: string) => boolean; constructor( projectRoot: string, syncFn: (paths?: string[]) => Promise<{ filesChanged: number; durationMs: number }>, - options: WatchOptions = {} + options: WatchOptions = {}, + isFileStateCurrent?: (relativePath: string) => boolean ) { this.projectRoot = projectRoot; this.syncFn = syncFn; @@ -355,6 +363,7 @@ export class FileWatcher { this.onSyncError = options.onSyncError; this.onDegraded = options.onDegraded; this.inertForTests = options.inertForTests ?? false; + this.isFileStateCurrent = isFileStateCurrent; } /** @@ -579,6 +588,20 @@ export class FileWatcher { return; } + try { + if (this.isFileStateCurrent?.(rel)) { + logDebug('Ignoring file event with unchanged indexed metadata', { file: rel }); + return; + } + } catch (error) { + // The guard is an optimization, never a correctness boundary. If the + // stat/DB lookup fails, keep the event so a sync can reconcile it. + logDebug('Could not verify file event metadata; treating it as a change', { + file: rel, + error: String(error), + }); + } + logDebug('File change detected', { file: rel }); if (this.ready) { const now = Date.now();