watcher.go (8620B)
1 // Package watcher monitors a directory tree for file-system changes using 2 // fsnotify and debounces rapid events into a single callback. 3 package watcher 4 5 import ( 6 "os" 7 "path/filepath" 8 "strings" 9 "sync" 10 "time" 11 12 "github.com/fsnotify/fsnotify" 13 ) 14 15 // --------------------------------------------------------------------------- 16 // BrokenSymlink 17 // --------------------------------------------------------------------------- 18 19 // BrokenSymlink records a symlink whose target does not exist. 20 type BrokenSymlink struct { 21 Path string // absolute path to the symlink 22 Target string // what the symlink points to 23 } 24 25 // findBrokenSymlinks scans a directory for symlinks whose targets do not exist. 26 func findBrokenSymlinks(dir string) []BrokenSymlink { 27 entries, err := os.ReadDir(dir) 28 if err != nil { 29 return nil 30 } 31 var broken []BrokenSymlink 32 for _, e := range entries { 33 if e.Type()&os.ModeSymlink == 0 { 34 continue 35 } 36 full := filepath.Join(dir, e.Name()) 37 target, err := os.Readlink(full) 38 if err != nil { 39 continue 40 } 41 // Resolve relative targets against the directory 42 if !filepath.IsAbs(target) { 43 target = filepath.Join(dir, target) 44 } 45 if _, err := os.Stat(target); err != nil { 46 broken = append(broken, BrokenSymlink{Path: full, Target: target}) 47 } 48 } 49 return broken 50 } 51 52 // --------------------------------------------------------------------------- 53 // EventHandler 54 // --------------------------------------------------------------------------- 55 56 // EventHandler is called after the debounce window closes. 57 type EventHandler func() 58 59 // --------------------------------------------------------------------------- 60 // Debouncer 61 // --------------------------------------------------------------------------- 62 63 // Debouncer batches rapid events into a single callback invocation. 64 // Each call to Trigger resets the timer; the callback fires only after 65 // the debounce interval elapses with no new triggers. 66 type Debouncer struct { 67 interval time.Duration 68 callback func() 69 timer *time.Timer 70 mu sync.Mutex 71 stopped bool 72 } 73 74 // NewDebouncer creates a Debouncer that will invoke callback after interval 75 // of inactivity following the most recent Trigger call. 76 func NewDebouncer(interval time.Duration, callback func()) *Debouncer { 77 return &Debouncer{ 78 interval: interval, 79 callback: callback, 80 } 81 } 82 83 // Trigger resets the debounce timer. When the timer fires (after interval of 84 // inactivity), the callback is invoked. 85 func (d *Debouncer) Trigger() { 86 d.mu.Lock() 87 defer d.mu.Unlock() 88 89 if d.stopped { 90 return 91 } 92 93 if d.timer != nil { 94 d.timer.Stop() 95 } 96 97 d.timer = time.AfterFunc(d.interval, func() { 98 d.mu.Lock() 99 stopped := d.stopped 100 d.mu.Unlock() 101 if !stopped { 102 d.callback() 103 } 104 }) 105 } 106 107 // Stop cancels any pending callback. After Stop returns, no further callbacks 108 // will be invoked even if Trigger is called again. 109 func (d *Debouncer) Stop() { 110 d.mu.Lock() 111 defer d.mu.Unlock() 112 113 d.stopped = true 114 if d.timer != nil { 115 d.timer.Stop() 116 } 117 } 118 119 // --------------------------------------------------------------------------- 120 // Watcher 121 // --------------------------------------------------------------------------- 122 123 // Watcher monitors a directory tree for file-system changes using fsnotify. 124 // Events are debounced so that a burst of rapid changes results in a single 125 // call to the configured handler. 126 type Watcher struct { 127 fsw *fsnotify.Watcher 128 debouncer *Debouncer 129 path string 130 rootPath string 131 ignores []string 132 includes []string 133 done chan struct{} 134 BrokenSymlinks []BrokenSymlink 135 } 136 137 // New creates a Watcher for the given directory path. debounceMs sets the 138 // debounce interval in milliseconds (defaults to 500 if 0). ignores is a 139 // list of filepath.Match patterns to skip. handler is called after each 140 // debounced event batch. 141 func New(path string, debounceMs int, ignores []string, includes []string, handler EventHandler) (*Watcher, error) { 142 if debounceMs <= 0 { 143 debounceMs = 500 144 } 145 146 absPath, err := filepath.Abs(path) 147 if err != nil { 148 absPath = path 149 } 150 151 fsw, err := fsnotify.NewWatcher() 152 if err != nil { 153 return nil, err 154 } 155 156 w := &Watcher{ 157 fsw: fsw, 158 path: path, 159 rootPath: absPath, 160 ignores: ignores, 161 includes: includes, 162 done: make(chan struct{}), 163 } 164 165 w.debouncer = NewDebouncer(time.Duration(debounceMs)*time.Millisecond, handler) 166 167 return w, nil 168 } 169 170 // Start adds the watched path recursively and launches the event loop in a 171 // background goroutine. 172 func (w *Watcher) Start() error { 173 if err := w.addRecursive(w.path); err != nil { 174 return err 175 } 176 177 go w.eventLoop() 178 return nil 179 } 180 181 // Stop shuts down the watcher: cancels the debouncer, closes fsnotify, and 182 // waits for the event loop goroutine to exit. 183 func (w *Watcher) Stop() { 184 w.debouncer.Stop() 185 w.fsw.Close() 186 <-w.done 187 } 188 189 // TriggerSync immediately invokes the sync handler (bypasses debounce). 190 // Safe to call after Stop — checks the stopped flag before invoking. 191 func (w *Watcher) TriggerSync() { 192 w.debouncer.mu.Lock() 193 stopped := w.debouncer.stopped 194 w.debouncer.mu.Unlock() 195 if !stopped { 196 w.debouncer.callback() 197 } 198 } 199 200 // --------------------------------------------------------------------------- 201 // Private methods 202 // --------------------------------------------------------------------------- 203 204 // eventLoop reads fsnotify events and errors until the watcher is closed. 205 func (w *Watcher) eventLoop() { 206 defer close(w.done) 207 208 for { 209 select { 210 case event, ok := <-w.fsw.Events: 211 if !ok { 212 return 213 } 214 215 // Only react to meaningful operations 216 if !isRelevantOp(event.Op) { 217 continue 218 } 219 220 if w.shouldIgnore(event.Name) { 221 continue 222 } 223 224 if !w.shouldInclude(event.Name) { 225 continue 226 } 227 228 // If a new directory was created, watch it recursively 229 if event.Op&fsnotify.Create != 0 { 230 if info, err := os.Stat(event.Name); err == nil && info.IsDir() { 231 _ = w.addRecursive(event.Name) 232 } 233 } 234 235 w.debouncer.Trigger() 236 237 case _, ok := <-w.fsw.Errors: 238 if !ok { 239 return 240 } 241 // Errors are logged but do not stop the loop. 242 } 243 } 244 } 245 246 // isRelevantOp returns true for file-system operations we care about. 247 // Chmod is included because touch(1) and some editors only update metadata, 248 // which fsnotify surfaces as Chmod on macOS (kqueue) and Linux (inotify). 249 func isRelevantOp(op fsnotify.Op) bool { 250 return op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename|fsnotify.Chmod) != 0 251 } 252 253 // shouldIgnore checks the base name of path against all ignore patterns 254 // using filepath.Match. 255 func (w *Watcher) shouldIgnore(path string) bool { 256 base := filepath.Base(path) 257 for _, pattern := range w.ignores { 258 if matched, _ := filepath.Match(pattern, base); matched { 259 return true 260 } 261 } 262 return false 263 } 264 265 // shouldInclude checks whether path falls within one of the configured include 266 // prefixes. If no includes are configured, every path is included. The method 267 // also returns true for ancestor directories of an include prefix (needed for 268 // traversal) and for the root path itself. 269 func (w *Watcher) shouldInclude(path string) bool { 270 if len(w.includes) == 0 { 271 return true 272 } 273 274 abs, err := filepath.Abs(path) 275 if err != nil { 276 abs = path 277 } 278 279 rel, err := filepath.Rel(w.rootPath, abs) 280 if err != nil || rel == "." { 281 return true 282 } 283 284 for _, inc := range w.includes { 285 incClean := filepath.Clean(inc) 286 // Path is the include prefix itself or is inside it 287 if rel == incClean || strings.HasPrefix(rel, incClean+string(filepath.Separator)) { 288 return true 289 } 290 // Path is an ancestor directory needed to reach the include prefix 291 if strings.HasPrefix(incClean, rel+string(filepath.Separator)) { 292 return true 293 } 294 } 295 return false 296 } 297 298 // addRecursive walks the directory tree rooted at path and adds every 299 // directory to the fsnotify watcher. Individual files are not added 300 // because fsnotify watches directories for events on their contents. 301 func (w *Watcher) addRecursive(path string) error { 302 return filepath.Walk(path, func(p string, info os.FileInfo, err error) error { 303 if err != nil { 304 return nil // skip entries we cannot stat 305 } 306 307 if w.shouldIgnore(p) { 308 if info.IsDir() { 309 return filepath.SkipDir 310 } 311 return nil 312 } 313 314 if !w.shouldInclude(p) { 315 if info.IsDir() { 316 return filepath.SkipDir 317 } 318 return nil 319 } 320 321 if info.IsDir() { 322 if err := w.fsw.Add(p); err != nil { 323 if broken := findBrokenSymlinks(p); len(broken) > 0 { 324 w.BrokenSymlinks = append(w.BrokenSymlinks, broken...) 325 return nil // skip dir, continue walking 326 } 327 return err // non-symlink error, propagate 328 } 329 } 330 331 return nil 332 }) 333 }