From c7501c956d806cddb1122d9f571c6d7c409c6387 Mon Sep 17 00:00:00 2001 From: Jordi Kroon Date: Thu, 1 Jan 2026 12:19:18 +0100 Subject: [PATCH 1/2] feat: add support for exclusion patterns with '!' prefix Add exclusion pattern support to allow users to exclude specific paths from triggering reloads. Patterns prefixed with '!' are now parsed as exclusions and stored separately. The globalWatcher checks if events match any exclusion patterns before processing them, preventing excluded paths from triggering reloads while still allowing them to be matched for filtering purposes. - Add isExclude field to track exclusion patterns - Parse '!' prefix during pattern initialization - Rename allowReload to matchesEvent for clarity on matching logic - Extract exclusion check into separate allowReload wrapper - Add isExcludedEvent method to globalWatcher for event filtering - Filter excluded events before debouncing in event listener --- internal/watcher/pattern.go | 25 ++++++++++++++++++--- internal/watcher/pattern_test.go | 38 ++++++++++++++++++++++++++++++++ internal/watcher/watcher.go | 24 +++++++++++++++++++- 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/internal/watcher/pattern.go b/internal/watcher/pattern.go index be045f747d..a7536be4dd 100644 --- a/internal/watcher/pattern.go +++ b/internal/watcher/pattern.go @@ -19,6 +19,7 @@ type pattern struct { parsedValues []string events chan eventHolder failureCount int + isExclude bool watcher *watcher.Watcher } @@ -33,8 +34,17 @@ func (p *pattern) startSession() { // this method prepares the pattern struct (aka /path/*pattern) func (p *pattern) parse() (err error) { - // first we clean the value - absPattern, err := fastabs.FastAbs(p.value) + // detect exclusion before resolving to absolute + raw := strings.TrimSpace(p.value) + if strings.HasPrefix(raw, "!") { + p.isExclude = true + raw = strings.TrimSpace(strings.TrimPrefix(raw, "!")) + } else { + p.isExclude = false + } + + // then we clean the value + absPattern, err := fastabs.FastAbs(raw) if err != nil { return err } @@ -81,7 +91,7 @@ func (p *pattern) parse() (err error) { return nil } -func (p *pattern) allowReload(event *watcher.Event) bool { +func (p *pattern) matchesEvent(event *watcher.Event) bool { if !isValidEventType(event.EffectType) || !isValidPathType(event) { return false } @@ -92,6 +102,15 @@ func (p *pattern) allowReload(event *watcher.Event) bool { return p.isValidPattern(event.PathName) || p.isValidPattern(event.AssociatedPathName) } +func (p *pattern) allowReload(event *watcher.Event) bool { + // Excludes never trigger reload by themselves, but they still match events for filtering. + if p.isExclude { + return false + } + + return p.matchesEvent(event) +} + func (p *pattern) handle(event *watcher.Event) { // If the watcher prematurely sends the die@ event, retry watching if event.PathType == watcher.PathTypeWatcher && strings.HasPrefix(event.PathName, "e/self/die@") && watcherIsActive.Load() { diff --git a/internal/watcher/pattern_test.go b/internal/watcher/pattern_test.go index 8ee3907c7e..dc8f15180e 100644 --- a/internal/watcher/pattern_test.go +++ b/internal/watcher/pattern_test.go @@ -355,6 +355,44 @@ func TestAnAssociatedEventTriggersTheWatcher(t *testing.T) { assert.Equal(t, e, (<-w.events).event) } +func TestGlobalWatcherIsExcludedEvent(t *testing.T) { + pg := &PatternGroup{Patterns: []string{"/app/**/*.php", "!/app/vendor/**"}} + + include := &pattern{patternGroup: pg, value: "/app/**/*.php"} + exclude := &pattern{patternGroup: pg, value: "!/app/vendor/**"} + + require.NoError(t, include.parse()) + require.NoError(t, exclude.parse()) + + gw := &globalWatcher{ + groups: []*PatternGroup{pg}, + watchers: []*pattern{include, exclude}, + excludes: map[*PatternGroup][]*pattern{pg: {exclude}}, + } + + inVendor := &watcher.Event{PathName: "/app/vendor/pkg/file.php"} + assert.True(t, include.matchesEvent(inVendor)) + assert.True(t, gw.isExcludedEvent(pg, inVendor)) + + notInVendor := &watcher.Event{PathName: "/app/src/file.php"} + assert.True(t, include.matchesEvent(notInVendor)) + assert.False(t, gw.isExcludedEvent(pg, notInVendor)) +} + +func TestExcludeMatchesAssociatedPath(t *testing.T) { + pg := &PatternGroup{Patterns: []string{"/app/**/*.php", "!/app/vendor/**"}} + + exclude := &pattern{patternGroup: pg, value: "!/app/vendor/**"} + require.NoError(t, exclude.parse()) + + gw := &globalWatcher{ + excludes: map[*PatternGroup][]*pattern{pg: {exclude}}, + } + + e := &watcher.Event{PathName: "/tmp/temporary", AssociatedPathName: "/app/vendor/x/file.php"} + assert.True(t, gw.isExcludedEvent(pg, e)) +} + func relativeDir(t *testing.T, relativePath string) string { t.Helper() diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 61b5fb81e3..3721acdaeb 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -50,6 +50,7 @@ type eventHolder struct { type globalWatcher struct { groups []*PatternGroup watchers []*pattern + excludes map[*PatternGroup][]*pattern events chan eventHolder stop chan struct{} } @@ -138,11 +139,18 @@ func (p *pattern) retryWatching() { func (g *globalWatcher) startWatching() error { g.events = make(chan eventHolder) g.stop = make(chan struct{}) + g.excludes = make(map[*PatternGroup][]*pattern) if err := g.parseFilePatterns(); err != nil { return err } + for _, w := range g.watchers { + if w.isExclude { + g.excludes[w.patternGroup] = append(g.excludes[w.patternGroup], w) + } + } + for _, w := range g.watchers { w.events = g.events w.startSession() @@ -170,6 +178,17 @@ func (g *globalWatcher) stopWatching() { } } +func (g *globalWatcher) isExcludedEvent(pg *PatternGroup, e *watcher.Event) bool { + excludes := g.excludes[pg] + for _, ex := range excludes { + if ex.matchesEvent(e) { + return true + } + } + + return false +} + func (g *globalWatcher) listenForFileEvents() { timer := time.NewTimer(debounceDuration) timer.Stop() @@ -182,8 +201,11 @@ func (g *globalWatcher) listenForFileEvents() { case <-g.stop: return case eh := <-g.events: - timer.Reset(debounceDuration) + if g.isExcludedEvent(eh.patternGroup, eh.event) { + continue + } + timer.Reset(debounceDuration) eventsPerGroup[eh.patternGroup] = append(eventsPerGroup[eh.patternGroup], eh.event) case <-timer.C: timer.Stop() From 670b32aab9563a22a5e1413306bfa599c1670fbc Mon Sep 17 00:00:00 2001 From: Jordi Kroon Date: Thu, 1 Jan 2026 12:33:37 +0100 Subject: [PATCH 2/2] test: add watch pattern exclusion test for Caddy configuration Add test case for worker watch configuration with exclude patterns. Verifies that exclude patterns (prefixed with !) are correctly parsed and stored alongside include patterns in the watch configuration. --- caddy/config_test.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/caddy/config_test.go b/caddy/config_test.go index a26d065a80..dfc682c1c8 100644 --- a/caddy/config_test.go +++ b/caddy/config_test.go @@ -220,3 +220,39 @@ func TestModuleWorkerWithCustomName(t *testing.T) { require.Equal(t, "m#custom-worker-name", module.Workers[0].Name, "Worker should have the custom name, prefixed with m#") require.Equal(t, "m#custom-worker-name", app.Workers[0].Name, "Worker should have the custom name, prefixed with m#") } + +func TestWorkerWatchAllowsExcludePatterns(t *testing.T) { + // Create a test configuration with an exclude watch pattern + configWithExcludeWatch := ` + { + php { + worker { + file ../testdata/worker-with-env.php + num 1 + watch + watch !./vendor/** + watch ./src/**/*.php + } + } + }` + + // Parse the configuration + d := caddyfile.NewTestDispenser(configWithExcludeWatch) + module := &FrankenPHPModule{} + + // Unmarshal the configuration + err := module.UnmarshalCaddyfile(d) + + // Verify that no error was returned + require.NoError(t, err, "Expected no error when configuring a worker with an exclude watch pattern") + + // Verify that the worker was added to the module + require.Len(t, module.Workers, 1, "Expected one worker to be added to the module") + require.Equal(t, "../testdata/worker-with-env.php", module.Workers[0].FileName, "Worker should have the correct filename") + + // Verify that the watch patterns were set correctly + require.Len(t, module.Workers[0].Watch, 3, "Expected three watch patterns") + require.Equal(t, defaultWatchPattern, module.Workers[0].Watch[0], "First watch pattern should be the default") + require.Equal(t, "!./vendor/**", module.Workers[0].Watch[1], "Second watch pattern should be the exclude pattern") + require.Equal(t, "./src/**/*.php", module.Workers[0].Watch[2], "Third watch pattern should match the configuration") +}