From 5b66f050b83ed232d81e1b754e34cd499c5f9243 Mon Sep 17 00:00:00 2001 From: Srinivas Chippagiri Date: Wed, 21 Oct 2026 16:43:21 -0700 Subject: [PATCH] fix: match run_watch patterns only against events from their own watched directory Patterns are made relative to the directory they are watched in (for example /var/tmp/chart/** becomes **), but events from every watched directory were matched against every pattern. A pattern such as ** from one path therefore matched all events from the other paths and restarted the command on unrelated changes. Track the base directory per pattern and per event and only match patterns with the same base. Resolves #3222 Signed-off-by: Srinivas Chippagiri --- .../engine/basichandler/commands/run_watch.go | 51 +++++++++++++------ .../basichandler/commands/run_watch_test.go | 30 +++++++++++ 2 files changed, 66 insertions(+), 15 deletions(-) create mode 100644 pkg/devspace/pipeline/engine/basichandler/commands/run_watch_test.go diff --git a/pkg/devspace/pipeline/engine/basichandler/commands/run_watch.go b/pkg/devspace/pipeline/engine/basichandler/commands/run_watch.go index d816cc9780..da30177cde 100644 --- a/pkg/devspace/pipeline/engine/basichandler/commands/run_watch.go +++ b/pkg/devspace/pipeline/engine/basichandler/commands/run_watch.go @@ -88,6 +88,7 @@ func (w *watcher) Watch(ctx context.Context, action func(ctx context.Context) er // get folders from patterns pathsToWatch := map[string]bool{} + watchPatterns := make([]watchPattern, 0, len(patterns)) for i, p := range patterns { patternsSplitted := strings.Split(filepath.ToSlash(p), "/") lastIndex := len(patternsSplitted) - 1 @@ -116,12 +117,13 @@ func (w *watcher) Watch(ctx context.Context, action func(ctx context.Context) er } pathsToWatch[absolutePath] = true + watchPatterns = append(watchPatterns, watchPattern{base: absolutePath, pattern: patterns[i]}) } watchTree := notify.NewTree() defer watchTree.Close() - globalChannel := make(chan string, 100) + globalChannel := make(chan watchEvent, 100) for targetPath := range pathsToWatch { stat, err := os.Stat(targetPath) if err != nil { @@ -160,7 +162,7 @@ func (w *watcher) Watch(ctx context.Context, action func(ctx context.Context) er if err != nil { log.Debugf("error converting path %s: %v", e.Path(), err) } else { - globalChannel <- filepath.ToSlash(relPath) + globalChannel <- watchEvent{base: base, path: filepath.ToSlash(relPath)} } } } @@ -168,10 +170,37 @@ func (w *watcher) Watch(ctx context.Context, action func(ctx context.Context) er } // start command - return w.handleCommand(ctx, patterns, excludes, action, globalChannel) + return w.handleCommand(ctx, watchPatterns, excludes, action, globalChannel) } -func (w *watcher) handleCommand(ctx context.Context, patterns []string, excludes []string, action func(ctx context.Context) error, events chan string) error { +// watchPattern is a glob pattern that is relative to the directory (base) it is watched in. +type watchPattern struct { + base string + pattern string +} + +// watchEvent is a file system event with a path relative to the watched base directory. +type watchEvent struct { + base string + path string +} + +// matchesPatterns reports whether the event matches a pattern that belongs to the +// same base directory as the event. Patterns are relative to their own base, so they +// must not be matched against events coming from another watched directory. +func matchesPatterns(patterns []watchPattern, e watchEvent) bool { + for _, p := range patterns { + if p.base != e.base { + continue + } + if matched, _ := doublestar.Match(p.pattern, e.path); matched { + return true + } + } + return false +} + +func (w *watcher) handleCommand(ctx context.Context, patterns []watchPattern, excludes []string, action func(ctx context.Context) error, events chan watchEvent) error { hc := interp.HandlerCtx(ctx) var t *tomb.Tomb if !w.options.SkipInitial { @@ -190,18 +219,10 @@ func (w *watcher) handleCommand(ctx context.Context, patterns []string, excludes return nil case e := <-events: // check if match - hasMatched := false - for _, p := range patterns { - hasMatched, _ = doublestar.Match(p, e) - if hasMatched { - break - } - } - - if hasMatched { + if matchesPatterns(patterns, e) { excluded := false for _, excludePath := range excludes { - excluded, _ = doublestar.Match(excludePath, e) + excluded, _ = doublestar.Match(excludePath, e.path) if excluded { break } @@ -209,7 +230,7 @@ func (w *watcher) handleCommand(ctx context.Context, patterns []string, excludes if !excluded { numEvents++ - lastChange = e + lastChange = e.path } } case <-time.After(time.Millisecond * 1200): diff --git a/pkg/devspace/pipeline/engine/basichandler/commands/run_watch_test.go b/pkg/devspace/pipeline/engine/basichandler/commands/run_watch_test.go new file mode 100644 index 0000000000..10c9c72af3 --- /dev/null +++ b/pkg/devspace/pipeline/engine/basichandler/commands/run_watch_test.go @@ -0,0 +1,30 @@ +package commands + +import "testing" + +func TestMatchesPatterns(t *testing.T) { + patterns := []watchPattern{ + {base: "/project", pattern: "devspace.yaml"}, + {base: "/var/tmp/chart", pattern: "**"}, + } + + testCases := []struct { + name string + event watchEvent + want bool + }{ + {"exact file in its own base", watchEvent{base: "/project", path: "devspace.yaml"}, true}, + {"unrelated file in project base", watchEvent{base: "/project", path: "main.go"}, false}, + {"nested unrelated file in project base", watchEvent{base: "/project", path: "src/main.go"}, false}, + {"any file in chart base", watchEvent{base: "/var/tmp/chart", path: "templates/deploy.yaml"}, true}, + {"unknown base", watchEvent{base: "/other", path: "devspace.yaml"}, false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if got := matchesPatterns(patterns, tc.event); got != tc.want { + t.Fatalf("matchesPatterns(%v) = %v, want %v", tc.event, got, tc.want) + } + }) + } +}