commit 07f5a2a9598a85405baf42053eb6b9a360d952a1
parent a7b4d498c9a35333022ff0565bf206530b76ffea
Author: Erik Loualiche <[email protected]>
Date: Sun, 8 Mar 2026 15:09:30 -0400
feat: add shouldInclude path-prefix filtering to watcher
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Diffstat:
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)
+ }
+ }
+}