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") +} 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()