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")