/ utils / hooks / fileChangedWatcher.ts
fileChangedWatcher.ts
  1  import chokidar, { type FSWatcher } from 'chokidar'
  2  import { isAbsolute, join } from 'path'
  3  import { registerCleanup } from '../cleanupRegistry.js'
  4  import { logForDebugging } from '../debug.js'
  5  import { errorMessage } from '../errors.js'
  6  import {
  7    executeCwdChangedHooks,
  8    executeFileChangedHooks,
  9    type HookOutsideReplResult,
 10  } from '../hooks.js'
 11  import { clearCwdEnvFiles } from '../sessionEnvironment.js'
 12  import { getHooksConfigFromSnapshot } from './hooksConfigSnapshot.js'
 13  
 14  let watcher: FSWatcher | null = null
 15  let currentCwd: string
 16  let dynamicWatchPaths: string[] = []
 17  let dynamicWatchPathsSorted: string[] = []
 18  let initialized = false
 19  let hasEnvHooks = false
 20  let notifyCallback: ((text: string, isError: boolean) => void) | null = null
 21  
 22  export function setEnvHookNotifier(
 23    cb: ((text: string, isError: boolean) => void) | null,
 24  ): void {
 25    notifyCallback = cb
 26  }
 27  
 28  export function initializeFileChangedWatcher(cwd: string): void {
 29    if (initialized) return
 30    initialized = true
 31    currentCwd = cwd
 32  
 33    const config = getHooksConfigFromSnapshot()
 34    hasEnvHooks =
 35      (config?.CwdChanged?.length ?? 0) > 0 ||
 36      (config?.FileChanged?.length ?? 0) > 0
 37  
 38    if (hasEnvHooks) {
 39      registerCleanup(async () => dispose())
 40    }
 41  
 42    const paths = resolveWatchPaths(config)
 43    if (paths.length === 0) return
 44  
 45    startWatching(paths)
 46  }
 47  
 48  function resolveWatchPaths(
 49    config?: ReturnType<typeof getHooksConfigFromSnapshot>,
 50  ): string[] {
 51    const matchers = (config ?? getHooksConfigFromSnapshot())?.FileChanged ?? []
 52  
 53    // Matcher field: filenames to watch in cwd, pipe-separated (e.g. ".envrc|.env")
 54    const staticPaths: string[] = []
 55    for (const m of matchers) {
 56      if (!m.matcher) continue
 57      for (const name of m.matcher.split('|').map(s => s.trim())) {
 58        if (!name) continue
 59        staticPaths.push(isAbsolute(name) ? name : join(currentCwd, name))
 60      }
 61    }
 62  
 63    // Combine static matcher paths with dynamic paths from hook output
 64    return [...new Set([...staticPaths, ...dynamicWatchPaths])]
 65  }
 66  
 67  function startWatching(paths: string[]): void {
 68    logForDebugging(`FileChanged: watching ${paths.length} paths`)
 69    watcher = chokidar.watch(paths, {
 70      persistent: true,
 71      ignoreInitial: true,
 72      awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 200 },
 73      ignorePermissionErrors: true,
 74    })
 75    watcher.on('change', p => handleFileEvent(p, 'change'))
 76    watcher.on('add', p => handleFileEvent(p, 'add'))
 77    watcher.on('unlink', p => handleFileEvent(p, 'unlink'))
 78  }
 79  
 80  function handleFileEvent(
 81    path: string,
 82    event: 'change' | 'add' | 'unlink',
 83  ): void {
 84    logForDebugging(`FileChanged: ${event} ${path}`)
 85    void executeFileChangedHooks(path, event)
 86      .then(({ results, watchPaths, systemMessages }) => {
 87        if (watchPaths.length > 0) {
 88          updateWatchPaths(watchPaths)
 89        }
 90        for (const msg of systemMessages) {
 91          notifyCallback?.(msg, false)
 92        }
 93        for (const r of results) {
 94          if (!r.succeeded && r.output) {
 95            notifyCallback?.(r.output, true)
 96          }
 97        }
 98      })
 99      .catch(e => {
100        const msg = errorMessage(e)
101        logForDebugging(`FileChanged hook failed: ${msg}`, {
102          level: 'error',
103        })
104        notifyCallback?.(msg, true)
105      })
106  }
107  
108  export function updateWatchPaths(paths: string[]): void {
109    if (!initialized) return
110    const sorted = paths.slice().sort()
111    if (
112      sorted.length === dynamicWatchPathsSorted.length &&
113      sorted.every((p, i) => p === dynamicWatchPathsSorted[i])
114    ) {
115      return
116    }
117    dynamicWatchPaths = paths
118    dynamicWatchPathsSorted = sorted
119    restartWatching()
120  }
121  
122  function restartWatching(): void {
123    if (watcher) {
124      void watcher.close()
125      watcher = null
126    }
127    const paths = resolveWatchPaths()
128    if (paths.length > 0) {
129      startWatching(paths)
130    }
131  }
132  
133  export async function onCwdChangedForHooks(
134    oldCwd: string,
135    newCwd: string,
136  ): Promise<void> {
137    if (oldCwd === newCwd) return
138  
139    // Re-evaluate from the current snapshot so mid-session hook changes are picked up
140    const config = getHooksConfigFromSnapshot()
141    const currentHasEnvHooks =
142      (config?.CwdChanged?.length ?? 0) > 0 ||
143      (config?.FileChanged?.length ?? 0) > 0
144    if (!currentHasEnvHooks) return
145    currentCwd = newCwd
146  
147    await clearCwdEnvFiles()
148    const hookResult = await executeCwdChangedHooks(oldCwd, newCwd).catch(e => {
149      const msg = errorMessage(e)
150      logForDebugging(`CwdChanged hook failed: ${msg}`, {
151        level: 'error',
152      })
153      notifyCallback?.(msg, true)
154      return {
155        results: [] as HookOutsideReplResult[],
156        watchPaths: [] as string[],
157        systemMessages: [] as string[],
158      }
159    })
160    dynamicWatchPaths = hookResult.watchPaths
161    dynamicWatchPathsSorted = hookResult.watchPaths.slice().sort()
162    for (const msg of hookResult.systemMessages) {
163      notifyCallback?.(msg, false)
164    }
165    for (const r of hookResult.results) {
166      if (!r.succeeded && r.output) {
167        notifyCallback?.(r.output, true)
168      }
169    }
170  
171    // Re-resolve matcher paths against the new cwd
172    if (initialized) {
173      restartWatching()
174    }
175  }
176  
177  function dispose(): void {
178    if (watcher) {
179      void watcher.close()
180      watcher = null
181    }
182    dynamicWatchPaths = []
183    dynamicWatchPathsSorted = []
184    initialized = false
185    hasEnvHooks = false
186    notifyCallback = null
187  }
188  
189  export function resetFileChangedWatcherForTesting(): void {
190    dispose()
191  }