Implement PathMatcher ignores in Darwin FSEvents watcher

- Store PathMatcher on fseventNotify and filter events with shouldIgnore and shouldNotify, matching the naive watcher

Signed-off-by: ManManavadaria <manmanavadaria@gmail.com>
This commit is contained in:
ManManavadaria 2026-05-11 13:17:57 +00:00
parent 62a24caecd
commit cf46c2c020
2 changed files with 148 additions and 2 deletions

View file

@ -26,6 +26,7 @@ import (
"time"
"github.com/fsnotify/fsevents"
"github.com/sirupsen/logrus"
pathutil "github.com/docker/compose/v5/internal/paths"
)
@ -39,6 +40,7 @@ type fseventNotify struct {
stop chan struct{}
pathsWereWatching map[string]any
ignore PathMatcher
closeOnce sync.Once
}
@ -62,6 +64,10 @@ func (d *fseventNotify) loop() {
continue
}
if !d.shouldNotify(e.Path) {
continue
}
d.events <- NewFileEvent(e.Path)
}
}
@ -115,7 +121,38 @@ func (d *fseventNotify) Errors() chan error {
return d.errors
}
func newWatcher(paths []string, _ ...PathMatcher) (Notify, error) {
func (d *fseventNotify) shouldNotify(path string) bool {
if d.shouldIgnore(path) {
return false
}
if _, ok := d.pathsWereWatching[path]; ok {
stat, err := os.Lstat(path)
isDir := err == nil && stat.IsDir()
return !isDir
}
for root := range d.pathsWereWatching {
if pathutil.IsChild(root, path) {
return true
}
}
return false
}
func (d *fseventNotify) shouldIgnore(path string) bool {
if d.ignore == nil {
return false
}
matches, err := d.ignore.Matches(path)
if err != nil {
logrus.Debugf("error checking ignored path %q: %v", path, err)
return false
}
return matches
}
func newWatcher(paths []string, ignore PathMatcher) (Notify, error) {
dw := &fseventNotify{
stream: &fsevents.EventStream{
Latency: 50 * time.Millisecond,
@ -127,6 +164,7 @@ func newWatcher(paths []string, _ ...PathMatcher) (Notify, error) {
events: make(chan FileEvent),
errors: make(chan error),
stop: make(chan struct{}),
ignore: ignore,
}
paths = pathutil.EncompassingPaths(paths)

View file

@ -19,15 +19,24 @@
package watch
import (
"os"
"path/filepath"
"testing"
"gotest.tools/v3/assert"
)
func newFseventNotifyFixture(repo string, ignore PathMatcher) *fseventNotify {
return &fseventNotify{
pathsWereWatching: map[string]any{repo: struct{}{}},
ignore: ignore,
}
}
func TestFseventNotifyCloseIdempotent(t *testing.T) {
// Create a watcher with a temporary directory
tmpDir := t.TempDir()
watcher, err := newWatcher([]string{tmpDir})
watcher, err := newWatcher([]string{tmpDir}, nil)
assert.NilError(t, err)
// Start the watcher
@ -46,3 +55,102 @@ func TestFseventNotifyCloseIdempotent(t *testing.T) {
err = watcher.Close()
assert.NilError(t, err)
}
func TestFseventNotifyShouldNotifyNilIgnore(t *testing.T) {
repo := t.TempDir()
child := filepath.Join(repo, "child.txt")
assert.NilError(t, os.WriteFile(child, []byte("x"), 0o644))
d := newFseventNotifyFixture(repo, nil)
assert.Assert(t, d.shouldNotify(child), "expected file under watched root to notify")
assert.Assert(t, !d.shouldNotify(repo), "expected directory event at watched root to be skipped")
}
func TestFseventNotifyShouldNotifyWatchedFileRoot(t *testing.T) {
repo := t.TempDir()
fileRoot := filepath.Join(repo, "watched.go")
assert.NilError(t, os.WriteFile(fileRoot, []byte("package main\n"), 0o644))
d := newFseventNotifyFixture(fileRoot, nil)
assert.Assert(t, d.shouldNotify(fileRoot), "expected file that is the watch root to notify")
}
func TestFseventNotifyShouldNotifyOutsideWatchedTree(t *testing.T) {
repo := t.TempDir()
other := t.TempDir()
d := newFseventNotifyFixture(repo, nil)
outPath := filepath.Join(other, "outside.txt")
assert.NilError(t, os.WriteFile(outPath, []byte("x"), 0o644))
assert.Assert(t, !d.shouldNotify(outPath), "expected path outside watch roots not to notify")
}
func TestFseventNotifyShouldNotifyRespectsDockerignore(t *testing.T) {
repo := t.TempDir()
ignore, err := DockerIgnoreTesterFromContents(repo, "vendor/\n")
assert.NilError(t, err)
d := newFseventNotifyFixture(repo, ignore)
kept := filepath.Join(repo, "src", "main.go")
assert.NilError(t, os.MkdirAll(filepath.Dir(kept), 0o755))
assert.NilError(t, os.WriteFile(kept, []byte("x"), 0o644))
assert.Assert(t, d.shouldNotify(kept), "expected non-ignored path to notify")
ignored := filepath.Join(repo, "vendor", "mod", "x.go")
assert.NilError(t, os.MkdirAll(filepath.Dir(ignored), 0o755))
assert.NilError(t, os.WriteFile(ignored, []byte("x"), 0o644))
assert.Assert(t, !d.shouldNotify(ignored), "expected dockerignored path not to notify")
}
func TestFseventNotifyShouldNotifyDockerignoreNegation(t *testing.T) {
repo := t.TempDir()
ignore, err := DockerIgnoreTesterFromContents(repo, "bazel-bin/\n!bazel-bin/app-binary\n")
assert.NilError(t, err)
d := newFseventNotifyFixture(repo, ignore)
ignoredChild := filepath.Join(repo, "bazel-bin", "cache", "out")
assert.NilError(t, os.MkdirAll(filepath.Dir(ignoredChild), 0o755))
assert.NilError(t, os.WriteFile(ignoredChild, []byte("x"), 0o644))
assert.Assert(t, !d.shouldNotify(ignoredChild), "expected ignored subtree under bazel-bin not to notify")
excepted := filepath.Join(repo, "bazel-bin", "app-binary", "binary")
assert.NilError(t, os.MkdirAll(filepath.Dir(excepted), 0o755))
assert.NilError(t, os.WriteFile(excepted, []byte("x"), 0o644))
assert.Assert(t, d.shouldNotify(excepted), "expected negated dockerignore path to notify")
}
func TestFseventNotifyShouldNotifyIntersectMatcher(t *testing.T) {
repo := t.TempDir()
ignoreVendor, err := DockerIgnoreTesterFromContents(repo, "vendor/\n")
assert.NilError(t, err)
ignoreTmp, err := DockerIgnoreTesterFromContents(repo, "tmp/\n")
assert.NilError(t, err)
d := newFseventNotifyFixture(repo, NewIntersectMatcher(ignoreVendor, ignoreTmp))
vendorFile := filepath.Join(repo, "vendor", "x", "go.mod")
assert.NilError(t, os.MkdirAll(filepath.Dir(vendorFile), 0o755))
assert.NilError(t, os.WriteFile(vendorFile, []byte("module x\n"), 0o644))
assert.Assert(t, d.shouldNotify(vendorFile), "vendor must notify when only one intersect matcher ignores it")
ignoreBuild1, err := DockerIgnoreTesterFromContents(repo, "build/\n")
assert.NilError(t, err)
ignoreBuild2, err := DockerIgnoreTesterFromContents(repo, "build/\n")
assert.NilError(t, err)
d2 := newFseventNotifyFixture(repo, NewIntersectMatcher(ignoreBuild1, ignoreBuild2))
buildFile := filepath.Join(repo, "build", "out", "a")
assert.NilError(t, os.MkdirAll(filepath.Dir(buildFile), 0o755))
assert.NilError(t, os.WriteFile(buildFile, []byte("x"), 0o644))
assert.Assert(t, !d2.shouldNotify(buildFile), "expected path ignored by every intersect matcher not to notify")
}
func TestFseventNotifyShouldIgnoreDockerignoreDirectory(t *testing.T) {
repo := t.TempDir()
ignore, err := DockerIgnoreTesterFromContents(repo, "bazel-bin/\n!bazel-bin/app-binary\n")
assert.NilError(t, err)
d := newFseventNotifyFixture(repo, ignore)
bazelBin := filepath.Join(repo, "bazel-bin")
assert.NilError(t, os.MkdirAll(bazelBin, 0o755))
assert.Assert(t, d.shouldIgnore(bazelBin), "expected directory path to match dockerignore")
}