From 4d62bd2317b3edbb48738e9ed8e7b5da002460ac Mon Sep 17 00:00:00 2001 From: Daan De Meyer Date: Fri, 3 Jul 2026 10:36:42 +0200 Subject: [PATCH 1/2] file_watcher: don't poison the notify watcher on unrepresentable paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notify watcher relativized every event path before doing anything else. Relativizing rejects path components buck's path types cannot represent — a literal backslash (which build outputs can transiently contain, systemd's escaped unit names being one real-world source) or non-UTF-8 bytes — and the resulting error poisons the watcher state: every subsequent command fails with "Error relativizing: ... is not relative to project root" until the daemon is killed. Events arriving while the watcher is poisoned are also dropped without setting missed_events, so builds after the failing one silently use stale state. Rework the watcher so that no file name, whatever bytes it contains, can break it: - Check the buck-out prefix on the raw path before any conversion, so the dominant event class is discarded cheaply and regardless of whether the name is representable. - Relativize leniently (new ProjectRoot::relativize_relaxed, built on a new AbsNormPath::strip_prefix_untyped and the unchecked half of ForwardRelativePathNormalizer::normalize_path) and match the configured ignores against the unvalidated string, so ignored directories can contain names buck's path types reject. The strict relativize is now the relaxed version plus validation. - For paths that survive the ignores but still cannot be represented, record a change of the nearest representable parent directory instead of erroring, mirroring what the watchman and edenfs watchers already do: buck cannot read such files anyway, so invalidating the parent's listing is enough. Signed-off-by: Daan De Meyer --- app/buck2_core/src/fs/project.rs | 59 ++++++- app/buck2_file_watcher/src/notify.rs | 182 +++++++++++++++++++-- app/buck2_fs/src/paths/abs_norm_path.rs | 28 +++- app/buck2_fs/src/paths/forward_rel_path.rs | 21 ++- 4 files changed, 263 insertions(+), 27 deletions(-) diff --git a/app/buck2_core/src/fs/project.rs b/app/buck2_core/src/fs/project.rs index 0baa00e05f37c..61d12e4062985 100644 --- a/app/buck2_core/src/fs/project.rs +++ b/app/buck2_core/src/fs/project.rs @@ -22,11 +22,11 @@ use buck2_fs::fs_util; use buck2_fs::paths::abs_norm_path::AbsNormPath; use buck2_fs::paths::abs_norm_path::AbsNormPathBuf; use buck2_fs::paths::forward_rel_path::ForwardRelativePath; +use buck2_fs::paths::forward_rel_path::ForwardRelativePathNormalizer; use buck2_fs::paths::relative_path::RelativePath; use buck2_fs::paths::relative_path::RelativePathBuf; use dupe::Dupe; use pagable::Pagable; -use ref_cast::RefCast; #[derive(Debug, buck2_error::Error)] #[buck2(input)] @@ -220,18 +220,35 @@ impl ProjectRoot { &self, p: &'a P, ) -> buck2_error::Result> { - let relative_path = p.as_ref().strip_prefix(self.root()).map_err(|_| { + // Enforce the invariants that the relaxed relativization skips. + match self.relativize_relaxed(p)? { + Cow::Borrowed(rel) => Ok(Cow::Borrowed(ProjectRelativePath::new(rel)?)), + Cow::Owned(rel) => Ok(Cow::Owned(ProjectRelativePathBuf::try_from(rel)?)), + } + } + + /// Like `relativize`, but returns the project-relative path as a plain + /// string, without requiring it to be representable as a + /// `ProjectRelativePath`: components the strict path types reject (most + /// notably an embedded backslash on Unix) are preserved as-is. This lets + /// callers check whether a path is even interesting (e.g. against ignores) + /// before failing on it. It does still fail on paths outside the project + /// root and on non-UTF-8 paths, which a string cannot represent. + pub fn relativize_relaxed<'a, P: ?Sized + AsRef>( + &self, + p: &'a P, + ) -> buck2_error::Result> { + let path = p.as_ref(); + let rel = path.strip_prefix_untyped(self.root()).map_err(|_| { buck2_error::buck2_error!( buck2_error::ErrorTag::Tier0, "Error relativizing: `{}` is not relative to project root `{}`", - p.as_ref(), + path, self.root() ) })?; - match relative_path { - Cow::Borrowed(p) => Ok(Cow::Borrowed(ProjectRelativePath::ref_cast(p))), - Cow::Owned(p) => Ok(Cow::Owned(ProjectRelativePathBuf::from(p))), - } + ForwardRelativePathNormalizer::normalize_path_unchecked(rel) + .with_buck_error_context(|| format!("Error relativizing `{path}`")) } /// Remove project root prefix from path (even if path is not canonical) @@ -806,6 +823,34 @@ mod tests { Ok(()) } + #[test] + fn test_relativize_relaxed() -> buck2_error::Result<()> { + let fs = ProjectRootTemp::new()?; + let root = fs.path(); + + let abs = root.root().join(ForwardRelativePath::new("foo/bar")?); + assert_eq!("foo/bar", &*root.relativize_relaxed(&abs)?); + + #[cfg(unix)] + { + use buck2_fs::paths::abs_norm_path::AbsNormPath; + + assert!( + root.relativize_relaxed(AbsNormPath::new("/dev/null")?) + .is_err_and(|e| e.to_string().contains("not relative to project root")) + ); + + // The strict relativization rejects embedded backslashes; the + // relaxed one preserves them for the caller to deal with. + let path = root.root().as_path().join(r"foo\bar/baz"); + let abs = AbsNormPath::new(&path)?; + assert!(root.relativize(abs).is_err()); + assert_eq!(r"foo\bar/baz", &*root.relativize_relaxed(abs)?); + } + + Ok(()) + } + #[test] fn test_relativizes_paths_correct() -> buck2_error::Result<()> { let fs = ProjectRootTemp::new()?; diff --git a/app/buck2_file_watcher/src/notify.rs b/app/buck2_file_watcher/src/notify.rs index 17932972bff1a..4c8fbe672bce1 100644 --- a/app/buck2_file_watcher/src/notify.rs +++ b/app/buck2_file_watcher/src/notify.rs @@ -9,6 +9,7 @@ */ use std::mem; +use std::path::Path; use std::sync::Arc; use std::sync::Mutex; @@ -21,6 +22,7 @@ use buck2_core::cells::CellResolver; use buck2_core::cells::cell_path::CellPath; use buck2_core::cells::name::CellName; use buck2_core::fs::project::ProjectRoot; +use buck2_core::fs::project_rel_path::ProjectRelativePath; use buck2_data::FileWatcherEventType; use buck2_data::FileWatcherKind; use buck2_error::conversion::from_any_with_tag; @@ -43,6 +45,7 @@ use tracing::info; use crate::file_watcher::FileWatcher; use crate::mergebase::Mergebase; use crate::stats::FileWatcherStats; +use crate::watchman::utils::find_first_valid_parent; fn ignore_event_kind(event_kind: EventKind) -> bool { match event_kind { @@ -84,22 +87,56 @@ impl NotifyFileData { let event = event.map_err(|e| from_any_with_tag(e, buck2_error::ErrorTag::NotifyWatcher))?; - for path in &event.paths { - // Testing shows that we get absolute paths back from the `notify` library. - // It's not documented though. - let path = root.relativize(AbsNormPath::new(&path)?)?; + if event.need_rescan() { + self.missed_events = true; + debug!("FileWatcher: File change events were missed"); + } + for path in &event.paths { // We ignore the buck-out prefix, as those are uninteresting events caused by us. // We also ignore other buck-out directories, as if you have two isolation dirs running at once, they are not interesting. // We do this in the notify-watcher, rather than a generic layer, as watchman users should configure // to ignore buck-out, to reduce the number of events, rather than hiding them later. - if path.starts_with(InvocationPaths::buck_out_dir_prefix()) { + // + // Checked on the raw path so this dominant event class is discarded + // cheaply, whatever bytes the path contains. + if let Ok(rel) = path.strip_prefix(root.root().as_path()) + && rel.starts_with(InvocationPaths::buck_out_dir_prefix().as_str()) + { // We don't want to event add them as ignored events, since they are super common // and very boring continue; } - let cell_path = cells.get_cell_path(&path); + // Uninteresting event kinds don't need the path at all. + if ignore_event_kind(event.kind) { + self.ignored += 1; + continue; + } + + // Testing shows that we get absolute paths back from the `notify` library. + // It's not documented though. + // + // Relativized leniently because ignored directories can transiently + // contain names `ProjectRelativePath` rejects (e.g. a literal backslash), + // and those must reach the ignore check below rather than fail: an error + // would poison the watcher permanently. + let rel = match AbsNormPath::new(&path).and_then(|path| root.relativize_relaxed(path)) { + Ok(rel) => rel, + Err(e) => match path.strip_prefix(root.root().as_path()) { + // Not relativizable at all (in practice: a non-UTF-8 name). + Ok(raw_rel) => { + self.degrade_to_parent(raw_rel, cells, ignore_specs); + continue; + } + // Outside the project root: a genuine error. + Err(_) => return Err(e), + }, + }; + + // The relaxed path may violate the `ProjectRelativePath` invariants; it + // is only used to match the ignores, never to identify a file. + let cell_path = cells.get_cell_path(ProjectRelativePath::unchecked_new(&rel)); let ignore = ignore_specs .get(&cell_path.cell()) // See the comment on the analogous code in `watchman/interface.rs` @@ -107,23 +144,52 @@ impl NotifyFileData { info!( "FileWatcher: {:?} {:?} (ignore = {})", - path, &event.kind, ignore + rel, &event.kind, ignore ); - if event.need_rescan() { - self.missed_events = true; - debug!("FileWatcher: File change events were missed"); - } - - if ignore || ignore_event_kind(event.kind) { + if ignore { self.ignored += 1; - } else { + } else if ProjectRelativePath::new(&*rel).is_ok() { self.events.insert((cell_path, event.kind)); + } else { + // Interesting, but not representable (e.g. an embedded backslash). + self.degrade_to_parent(Path::new(&*rel), cells, ignore_specs); } } Ok(()) } + /// The event path cannot be represented as a `ProjectRelativePath`. Buck + /// cannot read such paths anyway, so record a change of the nearest + /// representable parent directory instead — mirroring the watchman and + /// edenfs watchers — rather than erroring, which would poison the watcher. + fn degrade_to_parent( + &mut self, + rel: &Path, + cells: &CellResolver, + ignore_specs: &StdBuckHashMap, + ) { + let parent = find_first_valid_parent(rel).unwrap_or(ProjectRelativePath::empty()); + let cell_path = cells.get_cell_path(parent); + let ignore = ignore_specs + .get(&cell_path.cell()) + .is_some_and(|ignore| ignore.is_match(cell_path.path())); + + info!( + "FileWatcher: {:?} -> {:?} (unrepresentable path, ignore = {})", + rel, parent, ignore + ); + + if ignore { + self.ignored += 1; + } else { + // Maps to `dir_added_or_removed` in `sync`, invalidating the + // parent's directory listing. + self.events + .insert((cell_path, EventKind::Create(CreateKind::Folder))); + } + } + fn sync(self) -> (buck2_data::FileWatcherStats, Option) { // The changes that go into the DICE transaction let mut changed = FileChangeTracker::new(); @@ -341,3 +407,91 @@ impl FileWatcher for NotifyFileWatcher { .await } } + +#[cfg(all(test, unix))] +mod tests { + use buck2_core::cells::cell_root_path::CellRootPathBuf; + use buck2_core::fs::project::ProjectRootTemp; + + use super::*; + + fn process_path( + fs: &ProjectRootTemp, + rel: impl AsRef, + ) -> buck2_error::Result { + let cells = CellResolver::testing_with_name_and_path( + CellName::testing_new("root"), + CellRootPathBuf::testing_new(""), + ); + let mut ignore_specs = StdBuckHashMap::default(); + ignore_specs.insert( + CellName::testing_new("root"), + IgnoreSet::from_ignore_spec("ignored", true)?, + ); + + let event = notify::Event::new(EventKind::Create(CreateKind::File)) + .add_path(fs.path().root().as_path().join(rel.as_ref())); + let mut data = NotifyFileData::new(); + data.process(Ok(event), fs.path(), &cells, &ignore_specs)?; + Ok(data) + } + + #[test] + fn test_ignores_apply_before_path_validation() -> buck2_error::Result<()> { + let fs = ProjectRootTemp::new()?; + + // A regular event is recorded. + let data = process_path(&fs, "src/file")?; + assert_eq!(1, data.events.len()); + assert_eq!(0, data.ignored); + + // Ignored paths are discarded even when they contain components + // `ProjectRelativePath` rejects, such as a literal backslash. + let data = process_path(&fs, r"buck-out/foo\bar")?; + assert_eq!(0, data.events.len()); + assert_eq!(0, data.ignored); + + let data = process_path(&fs, r"ignored/foo\bar")?; + assert_eq!(0, data.events.len()); + assert_eq!(1, data.ignored); + + Ok(()) + } + + #[test] + fn test_unrepresentable_paths_never_error() -> buck2_error::Result<()> { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let fs = ProjectRootTemp::new()?; + + // An unrepresentable name outside any ignored directory degrades to a + // change of the nearest representable parent directory instead of + // erroring, like the watchman and edenfs watchers. + let data = process_path(&fs, r"src/foo\bar")?; + assert_eq!(0, data.ignored); + let (cell_path, kind) = data.events.iter().next().unwrap(); + assert_eq!("root//src", cell_path.to_string()); + assert_eq!(EventKind::Create(CreateKind::Folder), *kind); + + // Same for non-UTF-8 names, which cannot even be relativized. + let non_utf8 = OsStr::from_bytes(b"foo\xff"); + let data = process_path(&fs, Path::new("src").join(non_utf8))?; + assert_eq!(0, data.ignored); + let (cell_path, kind) = data.events.iter().next().unwrap(); + assert_eq!("root//src", cell_path.to_string()); + assert_eq!(EventKind::Create(CreateKind::Folder), *kind); + + // Under buck-out and ignored directories they are discarded like any + // other path there. + let data = process_path(&fs, Path::new("buck-out").join(non_utf8))?; + assert_eq!(0, data.events.len()); + assert_eq!(0, data.ignored); + + let data = process_path(&fs, Path::new("ignored").join(non_utf8))?; + assert_eq!(0, data.events.len()); + assert_eq!(1, data.ignored); + + Ok(()) + } +} diff --git a/app/buck2_fs/src/paths/abs_norm_path.rs b/app/buck2_fs/src/paths/abs_norm_path.rs index 087c11eb4b211..2d37476e08697 100644 --- a/app/buck2_fs/src/paths/abs_norm_path.rs +++ b/app/buck2_fs/src/paths/abs_norm_path.rs @@ -267,10 +267,36 @@ impl AbsNormPath { &self, base: P, ) -> buck2_error::Result> { - let stripped_path = self.strip_prefix_impl(base.as_ref())?; + let stripped_path = self.strip_prefix_untyped(base)?; ForwardRelativePathNormalizer::normalize_path(stripped_path) } + /// Like `strip_prefix`, but returns the raw remainder without requiring it + /// to be representable as a `ForwardRelativePath`. + /// + /// ``` + /// use std::path::Path; + /// + /// use buck2_fs::paths::abs_norm_path::AbsNormPath; + /// + /// if cfg!(not(windows)) { + /// let path = AbsNormPath::new(r"/test/foo\bar")?; + /// assert!(path.strip_prefix(AbsNormPath::new("/test")?).is_err()); + /// assert_eq!( + /// Path::new(r"foo\bar"), + /// path.strip_prefix_untyped(AbsNormPath::new("/test")?)? + /// ); + /// } + /// + /// # buck2_error::Ok(()) + /// ``` + pub fn strip_prefix_untyped>( + &self, + base: P, + ) -> buck2_error::Result<&Path> { + self.strip_prefix_impl(base.as_ref()) + } + #[cfg(not(windows))] fn strip_prefix_impl(&self, base: &AbsNormPath) -> buck2_error::Result<&Path> { self.0.strip_prefix(&base.0) diff --git a/app/buck2_fs/src/paths/forward_rel_path.rs b/app/buck2_fs/src/paths/forward_rel_path.rs index 9e29fe5ea5b60..2e80ed77ff00b 100644 --- a/app/buck2_fs/src/paths/forward_rel_path.rs +++ b/app/buck2_fs/src/paths/forward_rel_path.rs @@ -1292,6 +1292,18 @@ impl ForwardRelativePathNormalizer { pub fn normalize_path + ?Sized>( rel_path: &P, ) -> buck2_error::Result> { + match Self::normalize_path_unchecked(rel_path)? { + Cow::Borrowed(path) => Ok(Cow::Borrowed(ForwardRelativePath::new(path)?)), + Cow::Owned(path) => Ok(Cow::Owned(ForwardRelativePathBuf::try_from(path)?)), + } + } + + /// Like `normalize_path`, but returns the normalized string without + /// requiring it to be representable as a `ForwardRelativePath` (e.g. it + /// may contain an embedded backslash on Unix). + pub fn normalize_path_unchecked + ?Sized>( + rel_path: &P, + ) -> buck2_error::Result> { let rel_path = rel_path.as_ref(); if !rel_path.is_relative() { return Err( @@ -1303,12 +1315,11 @@ impl ForwardRelativePathNormalizer { .ok_or_else(|| ForwardRelativePathError::PathNotUtf8(rel_path.display().to_string()))?; let bytes = path_str.as_bytes(); if cfg!(windows) && memchr::memchr(b'\\', bytes).is_some() { - let normalized_path = path_str.replace('\\', "/"); - Ok(Cow::Owned(ForwardRelativePathBuf::try_from( - normalized_path, - )?)) + // `\` is the path separator on Windows, so it cannot appear inside + // a component there; normalize it to the `/` buck's paths use. + Ok(Cow::Owned(path_str.replace('\\', "/"))) } else { - Ok(Cow::Borrowed(ForwardRelativePath::new(path_str)?)) + Ok(Cow::Borrowed(path_str)) } } } From ca7220499102945d30779024a56df1aedc8118c3 Mon Sep 17 00:00:00 2001 From: Daan De Meyer Date: Thu, 28 May 2026 11:10:28 -0700 Subject: [PATCH 2/2] file_watcher: don't watch buck-out or ignored directories Switch the notify watcher to a notify fork (patched in via the daandemeyer/notify GitHub fork) that adds a watch_filtered() API taking a filter which decides what gets watched: recursive scans do not descend into rejected directories, directories created later are checked against the filter before being auto-watched, and events beneath rejected directories are suppressed. The notify watcher passes a filter rejecting buck-out, ignored directories, and paths buck cannot represent. On Linux (inotify) these never get watch descriptors, so they generate no events at all: this eliminates the dominant source of useless wakeups during builds (buck-out writes), stops large buck-out trees from exhausting fs.inotify.max_user_watches, and speeds up daemon startup. On macOS (FSEvents) and Windows, which cannot watch directories selectively, the events are suppressed inside the notify backend before reaching our callback. The filter prunes any directory whose own path matches an ignore pattern, so a file-shaped glob (e.g. *.tmp) matching a directory name prunes that whole subtree. --- Cargo.toml | 3 +- app/buck2_file_watcher/src/notify.rs | 80 ++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 820076ad9666b..e3490497be44c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ # @generated by autocargo [patch.crates-io] +notify = { git = "https://github.com/daandemeyer/notify.git", rev = "32d20b277cecce61d11257f216e68f28e5af632e" } perf-event = { version = "0.4", git = "https://github.com/Nero5023/perf-event.git", rev = "6dae86b6d4807acec081e6dc0a53167f57f8c0f4" } perf-event-open-sys = { version = "5.0", git = "https://github.com/Nero5023/perf-event.git", rev = "6dae86b6d4807acec081e6dc0a53167f57f8c0f4" } @@ -369,7 +370,7 @@ multimap = "0.8.3" nix = { version = "0.30.1", features = ["dir", "event", "hostname", "inotify", "ioctl", "mman", "mount", "net", "poll", "ptrace", "reboot", "resource", "sched", "signal", "term", "time", "user", "zerocopy"] } nom = "8" nom-language = "0.1" -notify = "8.2.0" +notify = "9.0.0-rc.4" num-bigint = { version = "0.4.7", features = ["rand", "serde"] } num_enum = "0.7.6" object = { version = "0.36.7", features = ["read", "write"] } diff --git a/app/buck2_file_watcher/src/notify.rs b/app/buck2_file_watcher/src/notify.rs index 4c8fbe672bce1..d670b0f1d8f61 100644 --- a/app/buck2_file_watcher/src/notify.rs +++ b/app/buck2_file_watcher/src/notify.rs @@ -33,6 +33,7 @@ use dice::DiceTransactionUpdater; use dupe::Dupe; use notify::EventKind; use notify::RecommendedWatcher; +use notify::WatchFilter; use notify::Watcher; use notify::event::CreateKind; use notify::event::MetadataKind; @@ -95,8 +96,8 @@ impl NotifyFileData { for path in &event.paths { // We ignore the buck-out prefix, as those are uninteresting events caused by us. // We also ignore other buck-out directories, as if you have two isolation dirs running at once, they are not interesting. - // We do this in the notify-watcher, rather than a generic layer, as watchman users should configure - // to ignore buck-out, to reduce the number of events, rather than hiding them later. + // The watch filter already prunes buck-out at watch registration time, but backends + // that cannot watch selectively may still let events slip through in rare cases. // // Checked on the raw path so this dominant event class is discarded // cheaply, whatever bytes the path contains. @@ -331,11 +332,44 @@ impl NotifyFileData { } } +/// A filter that prunes buck-out and ignored directories at watch-registration +/// time: inotify never installs watches beneath them, so they generate no +/// events at all. Backends that cannot watch selectively (FSEvents, Windows) +/// suppress the events on delivery instead, before they reach our callback. +/// +/// This prunes any directory whose own path matches an ignore pattern, so a +/// file-shaped glob (e.g. `*.tmp`) matching a directory name prunes that +/// whole subtree. +fn ignore_watch_filter( + root: &ProjectRoot, + cells: &CellResolver, + ignore_specs: &StdBuckHashMap, +) -> WatchFilter { + let root = root.dupe(); + let cells = cells.dupe(); + let ignore_specs = ignore_specs.clone(); + WatchFilter::with_filter(move |path| { + // Prune paths we cannot represent (e.g. non-UTF-8): buck cannot read + // them anyway, so their events would be unusable. + let Ok(rel) = AbsNormPath::new(path).and_then(|abs| root.relativize(abs)) else { + return false; + }; + if rel.starts_with(InvocationPaths::buck_out_dir_prefix()) { + return false; + } + let cell_path = cells.get_cell_path(&rel); + !ignore_specs + .get(&cell_path.cell()) + .is_some_and(|i| i.is_match(cell_path.path())) + }) +} + #[derive(Allocative)] pub struct NotifyFileWatcher { - #[allocative(skip)] + /// Never used directly, but must be kept alive: dropping the watcher + /// removes all its watches. #[expect(unused)] - // FIXME(JakobDegen): Clarify if this just needs to be kept alive or can be removed? + #[allocative(skip)] watcher: RecommendedWatcher, data: Arc>>, } @@ -349,6 +383,7 @@ impl NotifyFileWatcher { let data = Arc::new(Mutex::new(Ok(NotifyFileData::new()))); let data2 = data.dupe(); let root2 = root.dupe(); + let watch_filter = ignore_watch_filter(root, &cells, &ignore_specs); let mut watcher = notify::recommended_watcher(move |event| { let mut guard = data2.lock().unwrap(); if let Ok(state) = &mut *guard { @@ -359,7 +394,11 @@ impl NotifyFileWatcher { }) .map_err(|e| from_any_with_tag(e, buck2_error::ErrorTag::NotifyWatcher))?; watcher - .watch(root.root().as_path(), notify::RecursiveMode::Recursive) + .watch_filtered( + root.root().as_path(), + notify::RecursiveMode::Recursive, + watch_filter, + ) .map_err(|e| from_any_with_tag(e, buck2_error::ErrorTag::NotifyWatcher))?; Ok(Self { watcher, data }) } @@ -494,4 +533,35 @@ mod tests { Ok(()) } + + #[test] + fn test_watch_filter_prunes_buck_out_and_ignored() -> buck2_error::Result<()> { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let fs = ProjectRootTemp::new()?; + let root_path = fs.path().root().as_path(); + let cells = CellResolver::testing_with_name_and_path( + CellName::testing_new("root"), + CellRootPathBuf::testing_new(""), + ); + let mut specs = StdBuckHashMap::default(); + specs.insert( + CellName::testing_new("root"), + IgnoreSet::from_ignore_spec("**/node_modules", true)?, + ); + let filter = ignore_watch_filter(fs.path(), &cells, &specs); + + assert!(filter.should_watch(root_path)); + assert!(filter.should_watch(&root_path.join("src"))); + assert!(!filter.should_watch(&root_path.join("buck-out"))); + assert!(!filter.should_watch(&root_path.join("buck-out/v2/gen"))); + assert!(!filter.should_watch(&root_path.join("src/node_modules"))); + assert!(filter.should_watch(&root_path.join("src/node_modules_not"))); + + // Unrepresentable names are pruned too: buck cannot read them. + assert!(!filter.should_watch(&root_path.join(r"back\slash"))); + assert!(!filter.should_watch(&root_path.join(OsStr::from_bytes(b"foo\xff")))); + Ok(()) + } }