From 6a2b77819da705334d40d1e7e931e18c3ff6b4ab 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 5e6fda282..5446ed348 100644 --- a/internal/watcher/pattern.go +++ b/internal/watcher/pattern.go @@ -17,6 +17,7 @@ type pattern struct { parsedValues []string events chan eventHolder failureCount int + isExclude bool watcher *watcher.Watcher } @@ -31,8 +32,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 } @@ -72,7 +82,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 } @@ -83,6 +93,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 25b4dd58d..bda01f030 100644 --- a/internal/watcher/pattern_test.go +++ b/internal/watcher/pattern_test.go @@ -323,6 +323,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 { dir, err := filepath.Abs("./" + relativePath) assert.NoError(t, err) diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 61b5fb81e..3721acdae 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 9a875fad96bb3c8eacc57f30f9d141b069094414 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 a26d065a8..dfc682c1c 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") +}