esync

Directory watching and remote syncing
Log | Files | Refs | README | LICENSE

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 }