esync

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

commit 07f5a2a9598a85405baf42053eb6b9a360d952a1
parent a7b4d498c9a35333022ff0565bf206530b76ffea
Author: Erik Loualiche <eloualic@umn.edu>
Date:   Sun,  8 Mar 2026 15:09:30 -0400

feat: add shouldInclude path-prefix filtering to watcher

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Minternal/watcher/watcher.go | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Minternal/watcher/watcher_test.go | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 114 insertions(+), 5 deletions(-)

diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go @@ -5,6 +5,7 @@ package watcher import ( "os" "path/filepath" + "strings" "sync" "time" @@ -89,7 +90,9 @@ type Watcher struct { fsw *fsnotify.Watcher debouncer *Debouncer path string + rootPath string ignores []string + includes []string done chan struct{} } @@ -97,21 +100,28 @@ type Watcher struct { // debounce interval in milliseconds (defaults to 500 if 0). ignores is a // list of filepath.Match patterns to skip. handler is called after each // debounced event batch. -func New(path string, debounceMs int, ignores []string, handler EventHandler) (*Watcher, error) { +func New(path string, debounceMs int, ignores []string, includes []string, handler EventHandler) (*Watcher, error) { if debounceMs <= 0 { debounceMs = 500 } + absPath, err := filepath.Abs(path) + if err != nil { + absPath = path + } + fsw, err := fsnotify.NewWatcher() if err != nil { return nil, err } w := &Watcher{ - fsw: fsw, - path: path, - ignores: ignores, - done: make(chan struct{}), + fsw: fsw, + path: path, + rootPath: absPath, + ignores: ignores, + includes: includes, + done: make(chan struct{}), } w.debouncer = NewDebouncer(time.Duration(debounceMs)*time.Millisecond, handler) @@ -162,6 +172,10 @@ func (w *Watcher) eventLoop() { continue } + if !w.shouldInclude(event.Name) { + continue + } + // If a new directory was created, watch it recursively if event.Op&fsnotify.Create != 0 { if info, err := os.Stat(event.Name); err == nil && info.IsDir() { @@ -199,6 +213,39 @@ func (w *Watcher) shouldIgnore(path string) bool { return false } +// shouldInclude checks whether path falls within one of the configured include +// prefixes. If no includes are configured, every path is included. The method +// also returns true for ancestor directories of an include prefix (needed for +// traversal) and for the root path itself. +func (w *Watcher) shouldInclude(path string) bool { + if len(w.includes) == 0 { + return true + } + + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + + rel, err := filepath.Rel(w.rootPath, abs) + if err != nil || rel == "." { + return true + } + + for _, inc := range w.includes { + incClean := filepath.Clean(inc) + // Path is the include prefix itself or is inside it + if rel == incClean || strings.HasPrefix(rel, incClean+string(filepath.Separator)) { + return true + } + // Path is an ancestor directory needed to reach the include prefix + if strings.HasPrefix(incClean, rel+string(filepath.Separator)) { + return true + } + } + return false +} + // addRecursive walks the directory tree rooted at path and adds every // directory to the fsnotify watcher. Individual files are not added // because fsnotify watches directories for events on their contents. @@ -215,6 +262,13 @@ func (w *Watcher) addRecursive(path string) error { return nil } + if !w.shouldInclude(p) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + if info.IsDir() { return w.fsw.Add(p) } diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go @@ -110,3 +110,58 @@ func TestShouldIgnore(t *testing.T) { } } } + +// --------------------------------------------------------------------------- +// 5. TestShouldInclude — verify include prefix matching +// --------------------------------------------------------------------------- +func TestShouldInclude(t *testing.T) { + w := &Watcher{ + rootPath: "/project", + includes: []string{"src", "docs/api"}, + } + + tests := []struct { + path string + expect bool + }{ + {"/project/src/main.go", true}, + {"/project/src/pkg/util.go", true}, + {"/project/docs/api/readme.md", true}, + {"/project/docs", true}, // ancestor of docs/api + {"/project/tmp/cache.bin", false}, + {"/project/build/out.o", false}, + {"/project", true}, // root always included + } + + for _, tt := range tests { + got := w.shouldInclude(tt.path) + if got != tt.expect { + t.Errorf("shouldInclude(%q) = %v, want %v", tt.path, got, tt.expect) + } + } +} + +// --------------------------------------------------------------------------- +// 6. TestShouldIncludeEmptyMeansAll — empty includes means include everything +// --------------------------------------------------------------------------- +func TestShouldIncludeEmptyMeansAll(t *testing.T) { + w := &Watcher{ + rootPath: "/project", + includes: nil, + } + + tests := []struct { + path string + expect bool + }{ + {"/project/anything/at/all.go", true}, + {"/project/tmp/cache.bin", true}, + } + + for _, tt := range tests { + got := w.shouldInclude(tt.path) + if got != tt.expect { + t.Errorf("shouldInclude(%q) = %v, want %v", tt.path, got, tt.expect) + } + } +}