From f3cd3b8ccf5ad78965e8a9ef307ba8dd8318cbbb Mon Sep 17 00:00:00 2001 From: ManManavadaria Date: Thu, 11 Jun 2026 07:10:47 +0000 Subject: [PATCH] fix(watch): eliminate cross-trigger ignore contamination in file watcher Remove normalizeWatchRoots, which merged ignore rules across all related roots, causing one service's ignores to suppress ignore for other services Separate OS-level watch paths (pathsToWatch) from trigger roots (notifyList) and keep the original trigger roots in notifyList with each root carries its own isolated PathMatcher Update shouldNotify to notify if any root allow Apply the same fixes to the Darwin watcher Fix a duplicate-path registration bug, remove path matcher intersection and trigger root updates in greatestExistingAncestors Signed-off-by: ManManavadaria --- pkg/compose/watch.go | 9 ++- pkg/watch/paths.go | 39 +-------- pkg/watch/paths_test.go | 134 +------------------------------ pkg/watch/watcher_darwin.go | 64 +++++++-------- pkg/watch/watcher_darwin_test.go | 45 ++++++++++- pkg/watch/watcher_naive.go | 47 +++++------ pkg/watch/watcher_naive_test.go | 89 +++++++++++++++++++- 7 files changed, 189 insertions(+), 238 deletions(-) diff --git a/pkg/compose/watch.go b/pkg/compose/watch.go index 2296a6ebe..b6d21f1d9 100644 --- a/pkg/compose/watch.go +++ b/pkg/compose/watch.go @@ -265,9 +265,10 @@ func (s *composeService) watch(ctx context.Context, project *types.Project, opti if existingMatcher, exists := ignoresByWatchPath[trigger.Path]; exists { ignore = watch.NewIntersectMatcher(existingMatcher, ignore) + } else { + paths = append(paths, trigger.Path) } ignoresByWatchPath[trigger.Path] = ignore - paths = append(paths, trigger.Path) } serviceWatchRules, err := getWatchRules(config, service) if err != nil { @@ -603,7 +604,8 @@ func (s *composeService) handleWatchBatch(ctx context.Context, project *types.Pr } options.LogTo.Log( api.WatchLogger, - fmt.Sprintf("service(s) %q restarted", services)) + fmt.Sprintf("service(s) %q restarted", services), + ) } eg, ctx := errgroup.WithContext(ctx) @@ -763,7 +765,8 @@ func (s *composeService) initialSync(ctx context.Context, project *types.Project dockerIgnores, watch.EphemeralPathMatcher(), dotGitIgnore, - triggerIgnore) + triggerIgnore, + ) pathsToCopy, err := s.initialSyncFiles(ctx, project, service, trigger, ignoreInitialSync) if err != nil { diff --git a/pkg/watch/paths.go b/pkg/watch/paths.go index c4213a24b..54cada574 100644 --- a/pkg/watch/paths.go +++ b/pkg/watch/paths.go @@ -20,8 +20,6 @@ import ( "fmt" "os" "path/filepath" - - pathutil "github.com/docker/compose/v5/internal/paths" ) func greatestExistingAncestor(path string) (string, error) { @@ -42,47 +40,14 @@ func greatestExistingAncestor(path string) (string, error) { return path, nil } -func greatestExistingAncestors(paths []string, ignoreList map[string]PathMatcher) ([]string, error) { - result := []string{} +func greatestExistingAncestors(paths []string) ([]string, error) { + result := make([]string, 0, len(paths)) for _, path := range paths { newP, err := greatestExistingAncestor(path) if err != nil { return nil, fmt.Errorf("finding ancestor of %s: %w", path, err) } result = append(result, newP) - if path != newP { - ignore := ignoreList[path] - if oldMatcher, exists := ignoreList[newP]; exists { - ignore = NewIntersectMatcher(oldMatcher, ignore) - } - ignoreList[newP] = ignore - delete(ignoreList, path) - } } return result, nil } - -func normalizeWatchRoots(paths []string, ignore map[string]PathMatcher) (map[string]bool, map[string]PathMatcher, error) { - notifyList := make(map[string]bool, len(paths)) - normalizedIgnores := make(map[string]PathMatcher, len(paths)) - - for _, root := range paths { - root, err := filepath.Abs(root) - if err != nil { - return nil, nil, err - } - notifyList[root] = true - - matchers := make([]PathMatcher, 0, len(ignore)) - for triggerPath, matcher := range ignore { - if matcher == nil { - continue - } - if root == triggerPath || pathutil.IsChild(root, triggerPath) || pathutil.IsChild(triggerPath, root) { - matchers = append(matchers, matcher) - } - } - normalizedIgnores[root] = NewIntersectMatcher(matchers...) - } - return notifyList, normalizedIgnores, nil -} diff --git a/pkg/watch/paths_test.go b/pkg/watch/paths_test.go index 9bc35dd6e..f6d5b9ef3 100644 --- a/pkg/watch/paths_test.go +++ b/pkg/watch/paths_test.go @@ -17,7 +17,6 @@ package watch import ( - "path/filepath" "runtime" "testing" @@ -47,149 +46,20 @@ func TestGreatestExistingAncestorsMovesIgnoreToAncestor(t *testing.T) { f := NewTempDirFixture(t) missing := f.JoinPath("missing", "child", "file.txt") - ignore, err := DockerIgnoreTesterFromContents(f.Path(), "vendor/\n") - assert.NilError(t, err) - ignoreList := map[string]PathMatcher{missing: ignore} - paths, err := greatestExistingAncestors([]string{missing}, ignoreList) + paths, err := greatestExistingAncestors([]string{missing}) assert.NilError(t, err) assert.Equal(t, 1, len(paths)) assert.Equal(t, f.Path(), paths[0]) - assert.Assert(t, ignoreList[f.Path()] != nil) - _, exists := ignoreList[missing] - assert.Assert(t, !exists) } func TestGreatestExistingAncestorsIntersectsIgnoreOnAncestor(t *testing.T) { f := NewTempDirFixture(t) missing := f.JoinPath("missing", "child", "file.txt") - vendorIgnore, err := DockerIgnoreTesterFromContents(f.Path(), "vendor/\n") - assert.NilError(t, err) - tmpIgnore, err := DockerIgnoreTesterFromContents(f.Path(), "tmp/\n") - assert.NilError(t, err) - ignoreList := map[string]PathMatcher{ - f.Path(): vendorIgnore, - missing: tmpIgnore, - } - paths, err := greatestExistingAncestors([]string{missing}, ignoreList) + paths, err := greatestExistingAncestors([]string{missing}) assert.NilError(t, err) assert.Equal(t, 1, len(paths)) assert.Equal(t, f.Path(), paths[0]) - - inter, ok := ignoreList[f.Path()].(intersectPathMatcher) - assert.Assert(t, ok) - assert.Equal(t, 2, len(inter.Matchers)) -} - -func TestNormalizeWatchRootsAbsolutizesPaths(t *testing.T) { - rel := "." - abs, err := filepath.Abs(rel) - assert.NilError(t, err) - - notifyList, _, err := normalizeWatchRoots([]string{rel}, nil) - assert.NilError(t, err) - assert.Assert(t, notifyList[abs]) -} - -func TestNormalizeWatchRootsAssignsRelatedIgnores(t *testing.T) { - f := NewTempDirFixture(t) - - root := f.Path() - child := f.JoinPath("child") - vendorIgnore, err := DockerIgnoreTesterFromContents(root, "vendor/\n") - assert.NilError(t, err) - unrelatedIgnore, err := DockerIgnoreTesterFromContents(root, "build/\n") - assert.NilError(t, err) - - ignores := map[string]PathMatcher{ - root: vendorIgnore, - child: vendorIgnore, - "/other": unrelatedIgnore, - } - notifyList, normalizedIgnores, err := normalizeWatchRoots([]string{root, child}, ignores) - assert.NilError(t, err) - assert.Assert(t, notifyList[root]) - assert.Assert(t, notifyList[child]) - - vendorFile := filepath.Join(root, "vendor", "mod.go") - matches, err := normalizedIgnores[root].Matches(vendorFile) - assert.NilError(t, err) - assert.Assert(t, matches) - - matches, err = normalizedIgnores[child].Matches(vendorFile) - assert.NilError(t, err) - assert.Assert(t, matches) - - buildFile := filepath.Join(root, "build", "out") - matches, err = normalizedIgnores[root].Matches(buildFile) - assert.NilError(t, err) - assert.Assert(t, !matches) -} - -func TestNormalizeWatchRootsSkipsNilMatchers(t *testing.T) { - f := NewTempDirFixture(t) - - root := f.Path() - notifyList, normalizedIgnores, err := normalizeWatchRoots([]string{root}, map[string]PathMatcher{root: nil}) - assert.NilError(t, err) - assert.Assert(t, notifyList[root]) - _, ok := normalizedIgnores[root].(EmptyMatcher) - assert.Assert(t, ok) -} - -func TestNormalizeWatchRootsUsesEmptyMatcherWithoutIgnores(t *testing.T) { - f := NewTempDirFixture(t) - - root := f.Path() - _, normalizedIgnores, err := normalizeWatchRoots([]string{root}, nil) - assert.NilError(t, err) - _, ok := normalizedIgnores[root].(EmptyMatcher) - assert.Assert(t, ok) -} - -func TestNormalizeWatchRootsInheritsParentIgnoreForChild(t *testing.T) { - f := NewTempDirFixture(t) - - root := f.Path() - child := f.JoinPath("pkg") - vendorIgnore, err := DockerIgnoreTesterFromContents(root, "pkg/vendor/\n") - assert.NilError(t, err) - - _, normalizedIgnores, err := normalizeWatchRoots([]string{child}, map[string]PathMatcher{root: vendorIgnore}) - assert.NilError(t, err) - - vendorFile := filepath.Join(child, "vendor", "x.go") - matches, err := normalizedIgnores[child].Matches(vendorFile) - assert.NilError(t, err) - assert.Assert(t, matches) -} - -func TestNormalizeWatchRootsIntersectsNestedIgnores(t *testing.T) { - f := NewTempDirFixture(t) - - root := f.Path() - child := f.JoinPath("pkg") - vendorIgnore, err := DockerIgnoreTesterFromContents(root, "vendor/\n") - assert.NilError(t, err) - tmpIgnore, err := DockerIgnoreTesterFromContents(root, "pkg/tmp/\n") - assert.NilError(t, err) - - ignores := map[string]PathMatcher{ - root: vendorIgnore, - child: tmpIgnore, - } - _, normalizedIgnores, err := normalizeWatchRoots([]string{root, child}, ignores) - assert.NilError(t, err) - - vendorFile := filepath.Join(root, "vendor", "x.go") - matches, err := normalizedIgnores[root].Matches(vendorFile) - assert.NilError(t, err) - assert.Assert(t, !matches, "nested ignores must all match for parent root") - - tmpUnderChild := filepath.Join(child, "tmp", "a") - matches, err = normalizedIgnores[child].Matches(tmpUnderChild) - assert.NilError(t, err) - assert.Assert(t, !matches, "nested ignores must all match for child root") } diff --git a/pkg/watch/watcher_darwin.go b/pkg/watch/watcher_darwin.go index d7fd71ac3..19e27951d 100644 --- a/pkg/watch/watcher_darwin.go +++ b/pkg/watch/watcher_darwin.go @@ -39,10 +39,13 @@ type fseventNotify struct { errors chan error stop chan struct{} - pathsWereWatching map[string]any - // ignore maps each pathsWereWatching root to the merged PathMatcher for paths under it. - ignore map[string]PathMatcher - closeOnce sync.Once + // watchPaths are the paths registered with the FSEvents stream. + watchPaths []string + // notifyList holds the original trigger paths. + notifyList map[string]bool + // ignore maps each trigger path (from notifyList) to its own isolated PathMatcher + ignore map[string]PathMatcher + closeOnce sync.Once } func (d *fseventNotify) loop() { @@ -58,8 +61,8 @@ func (d *fseventNotify) loop() { for _, e := range events { e.Path = filepath.Join(string(os.PathSeparator), e.Path) - _, isPathWereWatching := d.pathsWereWatching[e.Path] - if e.Flags&fsevents.ItemIsDir == fsevents.ItemIsDir && e.Flags&fsevents.ItemCreated == fsevents.ItemCreated && isPathWereWatching { + isTriggerRoot := d.notifyList[e.Path] + if e.Flags&fsevents.ItemIsDir == fsevents.ItemIsDir && e.Flags&fsevents.ItemCreated == fsevents.ItemCreated && isTriggerRoot { // This is the first create for the path that we're watching. We always get exactly one of these // even after we get the HistoryDone event. Skip it. continue @@ -81,27 +84,18 @@ func (d *fseventNotify) addStreamPath(name string) { } func (d *fseventNotify) Start() error { - notifyRoots := make([]string, 0, len(d.pathsWereWatching)) - for path := range d.pathsWereWatching { - notifyRoots = append(notifyRoots, path) - } - if len(notifyRoots) == 0 { + if len(d.watchPaths) == 0 { return nil } - pathsToWatch, err := greatestExistingAncestors(notifyRoots, d.ignore) + watchPaths, err := greatestExistingAncestors(d.watchPaths) if err != nil { return err } - pathsToWatch = pathutil.EncompassingPaths(pathsToWatch) + watchPaths = pathutil.EncompassingPaths(watchPaths) - _, normalizedIgnores, err := normalizeWatchRoots(notifyRoots, d.ignore) - if err != nil { - return err - } - d.ignore = normalizedIgnores d.stream.Paths = nil - for _, path := range pathsToWatch { + for _, path := range watchPaths { d.addStreamPath(path) } @@ -138,19 +132,17 @@ func (d *fseventNotify) Errors() chan error { } func (d *fseventNotify) shouldNotify(path string) bool { - - if _, ok := d.pathsWereWatching[path]; ok { + if d.notifyList[path] { stat, err := os.Lstat(path) isDir := err == nil && stat.IsDir() return !isDir } - for root := range d.pathsWereWatching { + for root := range d.notifyList { if pathutil.IsChild(root, path) { - if d.shouldIgnore(root, path) { - return false + if !d.shouldIgnore(root, path) { + return true } - return true } } return false @@ -186,17 +178,21 @@ func newWatcher(paths []string, ignore map[string]PathMatcher) (Notify, error) { stop: make(chan struct{}), } - watchRoots := pathutil.EncompassingPaths(paths) - notifyList, normalizedIgnores, err := normalizeWatchRoots(watchRoots, ignore) - if err != nil { - return nil, fmt.Errorf("newWatcher: %w", err) - } - dw.ignore = normalizedIgnores - dw.pathsWereWatching = make(map[string]any, len(notifyList)) - for path := range notifyList { - dw.pathsWereWatching[path] = struct{}{} + watchPaths := pathutil.EncompassingPaths(paths) + + notifyList := make(map[string]bool, len(paths)) + for _, path := range paths { + abs, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("newWatcher: %w", err) + } + notifyList[abs] = true } + dw.ignore = ignore + dw.notifyList = notifyList + dw.watchPaths = watchPaths + return dw, nil } diff --git a/pkg/watch/watcher_darwin_test.go b/pkg/watch/watcher_darwin_test.go index 281e2b3c1..25ecd938a 100644 --- a/pkg/watch/watcher_darwin_test.go +++ b/pkg/watch/watcher_darwin_test.go @@ -28,8 +28,8 @@ import ( func newFseventNotifyFixture(repo string, ignore map[string]PathMatcher) *fseventNotify { return &fseventNotify{ - pathsWereWatching: map[string]any{repo: struct{}{}}, - ignore: ignore, + notifyList: map[string]bool{repo: true}, + ignore: ignore, } } @@ -144,6 +144,47 @@ func TestFseventNotifyShouldNotifyIntersectMatcher(t *testing.T) { assert.Assert(t, !d2.shouldNotify(buildFile), "expected path ignored by every intersect matcher not to notify") } +func TestFseventNotifyShouldNotifyAnyRootSaysOK(t *testing.T) { + repoRoot := t.TempDir() + srcRoot := filepath.Join(repoRoot, "src") + assert.NilError(t, os.MkdirAll(srcRoot, 0o755)) + + // Service A watches repoRoot and ignores the entire src/ subtree. + // Service B watches repoRoot/src and ignores only node_modules/. + // A path is notified if ANY containing root's matcher does not suppress it. + parentIgnore, err := DockerIgnoreTesterFromContents(repoRoot, "src/\n") + assert.NilError(t, err) + childIgnore, err := DockerIgnoreTesterFromContents(srcRoot, "node_modules/\n") + assert.NilError(t, err) + + d := &fseventNotify{ + notifyList: map[string]bool{repoRoot: true, srcRoot: true}, + ignore: map[string]PathMatcher{ + repoRoot: parentIgnore, + srcRoot: childIgnore, + }, + } + + // srcRoot does not ignore foo.ts, so it is notified even though repoRoot ignores src/. + fooFile := filepath.Join(srcRoot, "foo.ts") + assert.NilError(t, os.WriteFile(fooFile, []byte("x"), 0o644)) + assert.Assert(t, d.shouldNotify(fooFile), + "file under child root must be notified; srcRoot does not ignore it even though repoRoot ignores src/") + + // Every containing root ignores this path (repoRoot via src/, srcRoot via node_modules/). + nodeModulesFile := filepath.Join(srcRoot, "node_modules", "dep.js") + assert.NilError(t, os.MkdirAll(filepath.Dir(nodeModulesFile), 0o755)) + assert.NilError(t, os.WriteFile(nodeModulesFile, []byte("x"), 0o644)) + assert.Assert(t, !d.shouldNotify(nodeModulesFile), + "node_modules file must not be notified; all containing roots ignore it") + + // repoRoot does not ignore main.go (outside src/), so it is notified. + otherFile := filepath.Join(repoRoot, "main.go") + assert.NilError(t, os.WriteFile(otherFile, []byte("x"), 0o644)) + assert.Assert(t, d.shouldNotify(otherFile), + "file outside src/ must be notified; repoRoot does not ignore it") +} + func TestFseventNotifyShouldIgnoreDockerignoreDirectory(t *testing.T) { repo := t.TempDir() ignore, err := DockerIgnoreTesterFromContents(repo, "bazel-bin/\n!bazel-bin/app-binary\n") diff --git a/pkg/watch/watcher_naive.go b/pkg/watch/watcher_naive.go index 5b3994f05..900f3282d 100644 --- a/pkg/watch/watcher_naive.go +++ b/pkg/watch/watcher_naive.go @@ -37,15 +37,15 @@ import ( // // All OS-specific codepaths are handled by fsnotify. type naiveNotify struct { - // Paths that we're watching that should be passed up to the caller. - // Note that we may have to watch ancestors of these paths - // in order to fulfill the API promise. + // pathsToWatch are the actual paths registered with the OS watcher. + pathsToWatch []string + // notifyList holds the original trigger paths. // // We often need to check if paths are a child of a path in // the notify list. It might be better to store this in a tree // structure, so we can filter the list quickly. notifyList map[string]bool - // ignore maps each notifyList root to the merged PathMatcher for paths under it. + // ignore maps each trigger path (from notifyList) to its isolated PathMatcher. ignore map[string]PathMatcher isWatcherRecursive bool @@ -61,13 +61,7 @@ func (d *naiveNotify) Start() error { return nil } - notifyRoots := make([]string, 0, len(d.notifyList)) - - for path := range d.notifyList { - notifyRoots = append(notifyRoots, path) - } - - pathsToWatch, err := greatestExistingAncestors(notifyRoots, d.ignore) + pathsToWatch, err := greatestExistingAncestors(d.pathsToWatch) if err != nil { return err } @@ -75,11 +69,6 @@ func (d *naiveNotify) Start() error { pathsToWatch = pathutil.EncompassingPaths(pathsToWatch) } - _, d.ignore, err = normalizeWatchRoots(notifyRoots, d.ignore) - if err != nil { - return err - } - for _, name := range pathsToWatch { fi, err := os.Stat(name) if err != nil && !os.IsNotExist(err) { @@ -236,7 +225,9 @@ func (d *naiveNotify) shouldNotify(path string) bool { for root := range d.notifyList { if pathutil.IsChild(root, path) { - return !d.shouldIgnore(root, path) + if !d.shouldIgnore(root, path) { + return true + } } } return false @@ -249,11 +240,7 @@ func (d *naiveNotify) shouldSkipDir(path string) bool { } // Only walk directories under a notifyList path or under an ancestor of one - // (Start() may watch a parent when the target is missing or is a file). - // Decide ancestor/descendant versus notifyList before applying ignores so one - // root's patterns cannot block reaching another root. - // When walking beneath a watched ancestor, prune subtrees only with that root's - // matcher from d.ignore. + // Ignore a path only if every parent root's ignore matcher agrees for root := range d.notifyList { if pathutil.IsChild(path, root) { return false @@ -278,9 +265,6 @@ func (d *naiveNotify) shouldIgnoreEntireDir(dir, path string) bool { logrus.Debugf("error checking ignored directory %q: %v", path, err) return false } - if matches { - return true - } return matches } return false @@ -334,14 +318,19 @@ func newWatcher(paths []string, ignore map[string]PathMatcher) (Notify, error) { watchRoots = pathutil.EncompassingPaths(paths) } - notifyList, normalizedIgnores, err := normalizeWatchRoots(watchRoots, ignore) - if err != nil { - return nil, fmt.Errorf("newWatcher: %w", err) + notifyList := make(map[string]bool, len(paths)) + for _, path := range paths { + abs, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("newWatcher: %w", err) + } + notifyList[abs] = true } wmw := &naiveNotify{ + pathsToWatch: watchRoots, notifyList: notifyList, - ignore: normalizedIgnores, + ignore: ignore, watcher: fsw, events: fsw.Events, wrappedEvents: wrappedEvents, diff --git a/pkg/watch/watcher_naive_test.go b/pkg/watch/watcher_naive_test.go index ee73ea303..8771c9080 100644 --- a/pkg/watch/watcher_naive_test.go +++ b/pkg/watch/watcher_naive_test.go @@ -123,7 +123,8 @@ func inotifyNodes() (int, error) { pid := os.Getpid() output, err := exec.Command("/bin/sh", "-c", fmt.Sprintf( - "find /proc/%d/fd -lname anon_inode:inotify -printf '%%hinfo/%%f\n' | xargs cat | grep -c '^inotify'", pid)).Output() + "find /proc/%d/fd -lname anon_inode:inotify -printf '%%hinfo/%%f\n' | xargs cat | grep -c '^inotify'", pid, + )).Output() if err != nil { return 0, fmt.Errorf("error running command to determine number of watched files: %w\n %s", err, output) } @@ -239,6 +240,92 @@ func TestShouldSkipDirDoesNotSkipAncestorOfWatchedPath(t *testing.T) { assert.Assert(t, !d.shouldSkipDir(parent), "expected parent directory to remain traversable when it contains a watched path") } +func TestShouldSkipDirRequiresAllContainingRootsToAgree(t *testing.T) { + repoRoot := t.TempDir() + srcRoot := filepath.Join(repoRoot, "src") + + // Service A watches repoRoot but does NOT list node_modules in its ignores. + // Service B watches src and ignores node_modules. + // node_modules under src must NOT be skipped — because service A (which also + // covers the path) has no rule for it. All containing roots must agree to skip. + rootIgnore, err := DockerIgnoreTesterFromContents(repoRoot, "need_perm_dir/\n") + assert.NilError(t, err) + childIgnore, err := DockerIgnoreTesterFromContents(srcRoot, "node_modules/\n") + assert.NilError(t, err) + + d := &naiveNotify{ + ignore: map[string]PathMatcher{repoRoot: rootIgnore, srcRoot: childIgnore}, + notifyList: map[string]bool{repoRoot: true, srcRoot: true}, + } + + nodeModulesDir := filepath.Join(srcRoot, "node_modules") + assert.Assert(t, !d.shouldSkipDir(nodeModulesDir), + "node_modules under child root must not be skipped when a containing root (repoRoot) has no matching ignore rule") + + // A legitimate subdir under src is also not skipped. + componentsDir := filepath.Join(srcRoot, "components") + assert.Assert(t, !d.shouldSkipDir(componentsDir), + "non-ignored directory under child root must remain watched") +} + +func TestShouldSkipDirNotVetoedByUnrelatedChildTrigger(t *testing.T) { + repoRoot := t.TempDir() + srcRoot := filepath.Join(repoRoot, "src") + + // Service A watches repoRoot and ignores root-owned-dir/. + // Service B watches repoRoot/src with an unrelated ignore. + // root-owned-dir is outside src, so service B has no opinion about it. + // The directory must still be skipped so the walker never enters it. + rootIgnore, err := DockerIgnoreTesterFromContents(repoRoot, "root-owned-dir/\n") + assert.NilError(t, err) + childIgnore, err := DockerIgnoreTesterFromContents(srcRoot, "node_modules/\n") + assert.NilError(t, err) + + d := &naiveNotify{ + ignore: map[string]PathMatcher{repoRoot: rootIgnore, srcRoot: childIgnore}, + notifyList: map[string]bool{repoRoot: true, srcRoot: true}, + } + + rootOwnedDir := filepath.Join(repoRoot, "root-owned-dir") + assert.Assert(t, d.shouldSkipDir(rootOwnedDir), + "root-owned-dir must be skipped; child trigger must not veto parent's ignore") +} + +func TestShouldNotifyAnyRootSaysOK(t *testing.T) { + repoRoot := t.TempDir() + srcRoot := filepath.Join(repoRoot, "src") + + // Service A watches repoRoot and ignores the entire src/ subtree. + // Service B watches repoRoot/src and ignores only node_modules/. + // A path is notified if ANY containing root's matcher does not suppress it. + parentIgnore, err := DockerIgnoreTesterFromContents(repoRoot, "src/\n") + assert.NilError(t, err) + childIgnore, err := DockerIgnoreTesterFromContents(srcRoot, "node_modules/\n") + assert.NilError(t, err) + + d := &naiveNotify{ + ignore: map[string]PathMatcher{repoRoot: parentIgnore, srcRoot: childIgnore}, + notifyList: map[string]bool{repoRoot: true, srcRoot: true}, + } + + // A regular source file under src/ is notified because srcRoot's matcher does + // not ignore it, even though repoRoot ignores all of src/. + fooFile := filepath.Join(srcRoot, "foo.ts") + assert.Assert(t, d.shouldNotify(fooFile), + "file under child root must be notified; srcRoot does not ignore it even though repoRoot ignores src/") + + // A file inside node_modules is not notified: every containing root ignores it + // (repoRoot ignores src/, srcRoot ignores node_modules/). + nodeModulesFile := filepath.Join(srcRoot, "node_modules", "dep.js") + assert.Assert(t, !d.shouldNotify(nodeModulesFile), + "node_modules file must not be notified; all containing roots ignore it") + + // A file outside src/ is notified because repoRoot does not ignore it. + otherFile := filepath.Join(repoRoot, "main.go") + assert.Assert(t, d.shouldNotify(otherFile), + "file outside src/ must be notified; repoRoot does not ignore it") +} + func TestShouldSkipDirIntersectRequiresAllTriggersToAgree(t *testing.T) { repoRoot := t.TempDir() ignoreVendor, err := DockerIgnoreTesterFromContents(repoRoot, "vendor/\n")