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 <manmanavadaria@gmail.com>
This commit is contained in:
ManManavadaria 2026-06-11 07:10:47 +00:00
parent 423b409111
commit f3cd3b8ccf
7 changed files with 189 additions and 238 deletions

View file

@ -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 {

View file

@ -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
}

View file

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

View file

@ -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
}

View file

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

View file

@ -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,

View file

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