use wsb::prelude::*;
use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode, DebouncedEvent};
use std::collections::HashSet;
use std::sync::mpsc;
use std::sync::Mutex;
use std::time::Duration;
pub struct WatchScope {
pub directories: Vec<PathBuf>,
pub walk_errors: usize,
}
pub fn watchable_directories(root: &Path, cross_repo_boundaries: bool) -> WatchScope {
let mut builder = ignore::WalkBuilder::new(root);
builder
.hidden(false) .follow_links(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true);
builder.filter_entry(move |entry| {
let name = entry.file_name();
if name == ".git" || name == ".wsb" {
return false;
}
if !cross_repo_boundaries
&& entry.depth() > 0
&& entry.file_type().is_some_and(|t| t.is_dir())
&& entry.path().join(".git").exists()
{
return false;
}
true
});
let mut directories = Vec::new();
let mut walk_errors = 0usize;
for entry in builder.build() {
match entry {
Ok(entry) => {
if entry.file_type().is_some_and(|t| t.is_dir()) {
directories.push(entry.into_path());
}
}
Err(_) => walk_errors += 1,
}
}
WatchScope {
directories,
walk_errors,
}
}
fn describe_roots(roots: &[PathBuf]) -> String {
match roots {
[] => "no roots".to_string(),
[one] => one.display().to_string(),
[first, rest @ ..] => format!("{} and {} more", first.display(), rest.len()),
}
}
pub fn watchable_directories_of(roots: &[PathBuf], cross_repo_boundaries: bool) -> WatchScope {
let mut seen = HashSet::new();
let mut directories = Vec::new();
let mut walk_errors = 0usize;
for root in roots {
let scope = watchable_directories(root, cross_repo_boundaries);
walk_errors += scope.walk_errors;
for dir in scope.directories {
if seen.insert(dir.clone()) {
directories.push(dir);
}
}
}
WatchScope {
directories,
walk_errors,
}
}
pub fn spawn_scoped_watcher<F>(
label: String,
roots: Vec<PathBuf>,
cross_repo_boundaries: bool,
debounce: Duration,
on_events: F,
) -> Result<(), String>
where
F: Fn(&[DebouncedEvent]) + Send + 'static,
{
let scope = watchable_directories_of(&roots, cross_repo_boundaries);
if scope.directories.is_empty() {
return Err(format!(
"{label}: no watchable directories under {}",
describe_roots(&roots)
));
}
let watched: Arc<Mutex<HashSet<PathBuf>>> = Arc::new(Mutex::new(HashSet::new()));
let (rescan_tx, rescan_rx) = mpsc::channel::<()>();
let callback_watched = Arc::clone(&watched);
let callback_label = label.clone();
let mut debouncer = new_debouncer(
debounce,
move |result: Result<Vec<DebouncedEvent>, notify_debouncer_mini::notify::Error>| {
match result {
Ok(events) => {
let needs_rescan = {
let watched = callback_watched
.lock()
.expect("fs-scope watch set lock poisoned");
events.iter().any(|event| {
(event.path.is_dir() && !watched.contains(&event.path))
|| event
.path
.file_name()
.is_some_and(|name| name == ".gitignore" || name == ".ignore")
})
};
if needs_rescan {
let _ = rescan_tx.send(());
}
on_events(&events);
}
Err(error) => {
tracing::warn!(watcher = %callback_label, error = %error, "notify error");
}
}
},
)
.map_err(|e| format!("{label}: cannot create watcher: {e}"))?;
let mut initial_failures = 0usize;
let mut first_error: Option<String> = None;
{
let mut watched = watched.lock().expect("fs-scope watch set lock poisoned");
let mut batch = debouncer.watcher().paths_mut();
for dir in &scope.directories {
match batch.add(dir, RecursiveMode::NonRecursive) {
Ok(()) => {
watched.insert(dir.clone());
}
Err(error) => {
initial_failures += 1;
if first_error.is_none() {
first_error = Some(format!("{error}"));
}
}
}
}
batch
.commit()
.map_err(|e| format!("{label}: cannot start watching {}: {e}", describe_roots(&roots)))?;
if watched.is_empty() {
return Err(format!(
"{label}: could not watch any of {} directories under {} — first error: {}",
scope.directories.len(),
describe_roots(&roots),
first_error.unwrap_or_else(|| "none recorded".to_string()),
));
}
tracing::info!(
watcher = %label,
roots = roots.len(),
watched = watched.len(),
failed = initial_failures,
walk_errors = scope.walk_errors,
"fs scope watching"
);
if initial_failures > 0 {
tracing::warn!(
watcher = %label,
failed = initial_failures,
error = first_error.as_deref().unwrap_or("none recorded"),
"fs scope: some directories could not be watched; the periodic \
scan still covers them, at its own cadence"
);
}
}
let thread_label = label.clone();
std::thread::Builder::new()
.name(format!("fs-scope:{label}"))
.spawn(move || {
while rescan_rx.recv().is_ok() {
while rescan_rx.try_recv().is_ok() {}
let scope = watchable_directories_of(&roots, cross_repo_boundaries);
let mut added = 0usize;
let mut failures = 0usize;
{
let mut watched =
watched.lock().expect("fs-scope watch set lock poisoned");
let new: Vec<PathBuf> = scope
.directories
.into_iter()
.filter(|dir| !watched.contains(dir))
.collect();
if !new.is_empty() {
let mut batch = debouncer.watcher().paths_mut();
let mut accepted = Vec::new();
for dir in new {
match batch.add(&dir, RecursiveMode::NonRecursive) {
Ok(()) => accepted.push(dir),
Err(_) => failures += 1,
}
}
match batch.commit() {
Ok(()) => {
added = accepted.len();
watched.extend(accepted);
}
Err(error) => {
failures += accepted.len();
tracing::warn!(
watcher = %thread_label,
%error,
"fs scope: the new directories could not be watched"
);
}
}
}
}
if added > 0 || failures > 0 {
tracing::info!(
watcher = %thread_label,
added,
failures,
"fs scope grew"
);
}
}
})
.map_err(|e| format!("{label}: cannot spawn rescan thread: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn scaffold() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
std::fs::create_dir(root.join(".git")).unwrap();
std::fs::write(root.join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
std::fs::create_dir_all(root.join("src/deep")).unwrap();
std::fs::create_dir_all(root.join("node_modules/left-pad")).unwrap();
std::fs::create_dir_all(root.join("target/release")).unwrap();
std::fs::create_dir_all(root.join(".wsb/state")).unwrap();
std::fs::create_dir_all(root.join("sub/inner")).unwrap();
std::fs::create_dir(root.join("sub/.git")).unwrap();
dir
}
fn names(root: &Path, scope: &WatchScope) -> Vec<String> {
let mut out: Vec<String> = scope
.directories
.iter()
.map(|d| wsb::platform::slash_path(d.strip_prefix(root).expect("scope stays under the root")))
.collect();
out.sort();
out
}
#[test]
fn test8968_ignored_directories_are_not_watchable() {
let dir = scaffold();
let scope = watchable_directories(dir.path(), false);
let listed = names(dir.path(), &scope);
assert!(
!listed.iter().any(|n| n.starts_with("node_modules")),
"node_modules must be outside the scope: {listed:?}"
);
assert!(
!listed.iter().any(|n| n.starts_with("target")),
"target must be outside the scope: {listed:?}"
);
assert!(
listed.contains(&"src".to_string()) && listed.contains(&"src/deep".to_string()),
"the tracked tree must be inside the scope: {listed:?}"
);
assert!(
listed.contains(&String::new()),
"the root itself must be watched: {listed:?}"
);
}
#[test]
fn test8969_git_and_wsb_are_never_watchable() {
let dir = scaffold();
for cross in [false, true] {
let scope = watchable_directories(dir.path(), cross);
let listed = names(dir.path(), &scope);
assert!(
!listed.iter().any(|n| n.starts_with(".git") || n.starts_with(".wsb")),
"cross={cross}: .git/.wsb must be outside the scope: {listed:?}"
);
}
}
#[test]
fn test8970_nested_repositories_are_a_boundary_unless_crossed() {
let dir = scaffold();
let bounded = names(dir.path(), &watchable_directories(dir.path(), false));
assert!(
!bounded.iter().any(|n| n.starts_with("sub")),
"a nested repo must be outside a bounded scope: {bounded:?}"
);
let crossed = names(dir.path(), &watchable_directories(dir.path(), true));
assert!(
crossed.contains(&"sub".to_string()) && crossed.contains(&"sub/inner".to_string()),
"a crossing scope must include the nested repo: {crossed:?}"
);
}
#[test]
fn test8971_watching_nothing_is_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("does-not-exist");
let result = spawn_scoped_watcher(
"test".to_string(),
vec![missing],
false,
Duration::from_millis(10),
|_| {},
);
assert!(
result.is_err(),
"a root with nothing watchable must be refused"
);
}
#[test]
fn test9710_many_roots_are_one_watcher() {
let first = scaffold();
let second = scaffold();
let roots = vec![first.path().to_path_buf(), second.path().to_path_buf()];
let scope = watchable_directories_of(&roots, false);
assert!(
scope.directories.contains(&first.path().join("src")),
"the first root's tree is missing: {:?}",
scope.directories
);
assert!(
scope.directories.contains(&second.path().join("src/deep")),
"the second root's tree is missing: {:?}",
scope.directories
);
let unique: HashSet<&PathBuf> = scope.directories.iter().collect();
assert_eq!(
unique.len(),
scope.directories.len(),
"a directory was listed twice"
);
let seen = std::sync::Arc::new(Mutex::new(Vec::<PathBuf>::new()));
let recorder = std::sync::Arc::clone(&seen);
spawn_scoped_watcher(
"test-multi".to_string(),
roots,
false,
Duration::from_millis(10),
move |events| {
let mut seen = recorder.lock().expect("recorder");
seen.extend(events.iter().map(|e| e.path.clone()));
},
)
.expect("one watcher must cover every root");
std::fs::write(first.path().join("src/one.txt"), "a").unwrap();
std::fs::write(second.path().join("src/two.txt"), "b").unwrap();
let first_root = wsb::platform::canonicalize(&first.path()).expect("the first root resolves");
let second_root = wsb::platform::canonicalize(&second.path()).expect("the second root resolves");
for _ in 0..50 {
std::thread::sleep(Duration::from_millis(40));
let seen = seen.lock().expect("recorder");
let from_first = seen.iter().any(|p| p.starts_with(&first_root));
let from_second = seen.iter().any(|p| p.starts_with(&second_root));
if from_first && from_second {
return;
}
}
let seen = seen.lock().expect("recorder");
panic!("one watcher did not report both roots: {seen:?}");
}
#[test]
fn test12915_a_large_scope_is_watched_in_one_batch() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
std::fs::create_dir(root.join(".git")).unwrap();
const DIRECTORIES: usize = 2000;
for i in 0..DIRECTORIES {
std::fs::create_dir(root.join(format!("d{i:04}"))).unwrap();
}
let seen = std::sync::Arc::new(Mutex::new(Vec::<PathBuf>::new()));
let recorder = std::sync::Arc::clone(&seen);
let started = std::time::Instant::now();
spawn_scoped_watcher(
"test-large".to_string(),
vec![root.to_path_buf()],
false,
Duration::from_millis(10),
move |events| {
let mut seen = recorder.lock().expect("recorder");
seen.extend(events.iter().map(|e| e.path.clone()));
},
)
.expect("a large scope must be watched");
let took = started.elapsed();
assert!(
took < Duration::from_secs(10),
"watching {DIRECTORIES} directories took {took:?}"
);
let last = wsb::platform::canonicalize(root.join(format!("d{:04}", DIRECTORIES - 1)))
.expect("the last directory resolves");
std::fs::write(last.join("touched.txt"), "x").unwrap();
for _ in 0..50 {
std::thread::sleep(Duration::from_millis(40));
if seen.lock().expect("recorder").iter().any(|p| p.starts_with(&last)) {
return;
}
}
let seen = seen.lock().expect("recorder");
panic!("a change in the last directory added was not reported: {seen:?}");
}
}