@@ -136,7 +136,29 @@ function logRebuildResults(updates: RebuildResult[]): void {
136136 }
137137}
138138
139- export async function watchProject ( rootDir : string , opts : { engine ?: string } = { } ) : Promise < void > {
139+ /** Recursively collect tracked source files for stat-based polling. */
140+ function collectTrackedFiles ( dir : string , result : string [ ] ) : void {
141+ let entries : fs . Dirent [ ] ;
142+ try {
143+ entries = fs . readdirSync ( dir , { withFileTypes : true } ) ;
144+ } catch {
145+ return ;
146+ }
147+ for ( const entry of entries ) {
148+ if ( IGNORE_DIRS . has ( entry . name ) || entry . name . startsWith ( '.' ) ) continue ;
149+ const full = path . join ( dir , entry . name ) ;
150+ if ( entry . isDirectory ( ) ) {
151+ collectTrackedFiles ( full , result ) ;
152+ } else if ( EXTENSIONS . has ( path . extname ( entry . name ) ) ) {
153+ result . push ( full ) ;
154+ }
155+ }
156+ }
157+
158+ export async function watchProject (
159+ rootDir : string ,
160+ opts : { engine ?: string ; poll ?: boolean ; pollInterval ?: number } = { } ,
161+ ) : Promise < void > {
140162 const dbPath = path . join ( rootDir , '.codegraph' , 'graph.db' ) ;
141163 if ( ! fs . existsSync ( dbPath ) ) {
142164 throw new DbError ( 'No graph.db found. Run `codegraph build` first.' , { file : dbPath } ) ;
@@ -165,28 +187,94 @@ export async function watchProject(rootDir: string, opts: { engine?: string } =
165187 let timer : ReturnType < typeof setTimeout > | null = null ;
166188 const DEBOUNCE_MS = 300 ;
167189
168- info ( `Watching ${ rootDir } for changes...` ) ;
190+ const usePoll = opts . poll ?? process . platform === 'win32' ;
191+ const POLL_INTERVAL_MS = opts . pollInterval ?? 2000 ;
192+
193+ info ( `Watching ${ rootDir } for changes${ usePoll ? ' (polling mode)' : '' } ...` ) ;
169194 info ( 'Press Ctrl+C to stop.' ) ;
170195
171- const watcher = fs . watch ( rootDir , { recursive : true } , ( _eventType , filename ) => {
172- if ( ! filename ) return ;
173- if ( shouldIgnore ( filename ) ) return ;
174- if ( ! isTrackedExt ( filename ) ) return ;
196+ let cleanup : ( ) => void ;
175197
176- const fullPath = path . join ( rootDir , filename ) ;
177- pending . add ( fullPath ) ;
198+ if ( usePoll ) {
199+ // Polling mode: avoids native OS file watchers (NtNotifyChangeDirectoryFileEx)
200+ // which can crash ReFS drivers on Windows Dev Drives.
201+ const mtimeMap = new Map < string , number > ( ) ;
178202
179- if ( timer ) clearTimeout ( timer ) ;
180- timer = setTimeout ( async ( ) => {
181- const files = [ ...pending ] ;
182- pending . clear ( ) ;
183- await processPendingFiles ( files , db , rootDir , stmts , engineOpts , cache ) ;
184- } , DEBOUNCE_MS ) ;
185- } ) ;
203+ // Seed initial mtimes
204+ const initial : string [ ] = [ ] ;
205+ collectTrackedFiles ( rootDir , initial ) ;
206+ for ( const f of initial ) {
207+ try {
208+ mtimeMap . set ( f , fs . statSync ( f ) . mtimeMs ) ;
209+ } catch {
210+ /* deleted between collect and stat */
211+ }
212+ }
213+ info ( `Polling ${ initial . length } tracked files every ${ POLL_INTERVAL_MS } ms` ) ;
214+
215+ const pollTimer = setInterval ( ( ) => {
216+ const current : string [ ] = [ ] ;
217+ collectTrackedFiles ( rootDir , current ) ;
218+ const currentSet = new Set ( current ) ;
219+
220+ // Detect modified or new files
221+ for ( const f of current ) {
222+ try {
223+ const mtime = fs . statSync ( f ) . mtimeMs ;
224+ const prev = mtimeMap . get ( f ) ;
225+ if ( prev === undefined || mtime !== prev ) {
226+ mtimeMap . set ( f , mtime ) ;
227+ pending . add ( f ) ;
228+ }
229+ } catch {
230+ /* deleted between collect and stat */
231+ }
232+ }
233+
234+ // Detect deleted files
235+ for ( const f of mtimeMap . keys ( ) ) {
236+ if ( ! currentSet . has ( f ) ) {
237+ mtimeMap . delete ( f ) ;
238+ pending . add ( f ) ;
239+ }
240+ }
241+
242+ if ( pending . size > 0 ) {
243+ if ( timer ) clearTimeout ( timer ) ;
244+ timer = setTimeout ( async ( ) => {
245+ const files = [ ...pending ] ;
246+ pending . clear ( ) ;
247+ await processPendingFiles ( files , db , rootDir , stmts , engineOpts , cache ) ;
248+ } , DEBOUNCE_MS ) ;
249+ }
250+ } , POLL_INTERVAL_MS ) ;
251+
252+ cleanup = ( ) => clearInterval ( pollTimer ) ;
253+ } else {
254+ // Native OS watcher — efficient but can trigger ReFS crashes on Windows Dev Drives.
255+ // Use --poll if you experience BSOD/HYPERVISOR_ERROR on ReFS volumes.
256+ const watcher = fs . watch ( rootDir , { recursive : true } , ( _eventType , filename ) => {
257+ if ( ! filename ) return ;
258+ if ( shouldIgnore ( filename ) ) return ;
259+ if ( ! isTrackedExt ( filename ) ) return ;
260+
261+ const fullPath = path . join ( rootDir , filename ) ;
262+ pending . add ( fullPath ) ;
263+
264+ if ( timer ) clearTimeout ( timer ) ;
265+ timer = setTimeout ( async ( ) => {
266+ const files = [ ...pending ] ;
267+ pending . clear ( ) ;
268+ await processPendingFiles ( files , db , rootDir , stmts , engineOpts , cache ) ;
269+ } , DEBOUNCE_MS ) ;
270+ } ) ;
271+
272+ cleanup = ( ) => watcher . close ( ) ;
273+ }
186274
187275 process . on ( 'SIGINT' , ( ) => {
188276 info ( 'Stopping watcher...' ) ;
189- watcher . close ( ) ;
277+ cleanup ( ) ;
190278 // Flush any pending file paths to journal before exit
191279 if ( pending . size > 0 ) {
192280 const entries = [ ...pending ] . map ( ( filePath ) => ( {
0 commit comments