source dump of claude code
at main 191 lines 5.3 kB view raw
1import chokidar, { type FSWatcher } from 'chokidar' 2import { isAbsolute, join } from 'path' 3import { registerCleanup } from '../cleanupRegistry.js' 4import { logForDebugging } from '../debug.js' 5import { errorMessage } from '../errors.js' 6import { 7 executeCwdChangedHooks, 8 executeFileChangedHooks, 9 type HookOutsideReplResult, 10} from '../hooks.js' 11import { clearCwdEnvFiles } from '../sessionEnvironment.js' 12import { getHooksConfigFromSnapshot } from './hooksConfigSnapshot.js' 13 14let watcher: FSWatcher | null = null 15let currentCwd: string 16let dynamicWatchPaths: string[] = [] 17let dynamicWatchPathsSorted: string[] = [] 18let initialized = false 19let hasEnvHooks = false 20let notifyCallback: ((text: string, isError: boolean) => void) | null = null 21 22export function setEnvHookNotifier( 23 cb: ((text: string, isError: boolean) => void) | null, 24): void { 25 notifyCallback = cb 26} 27 28export 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 48function 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 67function 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 80function 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 108export 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 122function 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 133export 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 177function 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 189export function resetFileChangedWatcherForTesting(): void { 190 dispose() 191}