From 139a75197e10d3413ff443ffe06012ab03558448 Mon Sep 17 00:00:00 2001 From: Shengyu Fu Date: Wed, 26 Aug 2026 20:50:43 -0700 Subject: [PATCH 1/3] Refresh the watcher's ignore rules when a `.ignore` file changes `is_ignore_rules_file` decides whether a filesystem event should rebuild and republish the matcher in `ServerState::gitignore`. It recognized `.gitignore` at any depth and root-level `p4ignore.ini`, but not `.ignore`. `.ignore` is a first-class ignore source everywhere else: the walk collects it separately, the matcher applies it, and it even outranks `.gitignore`. Unlike `.gitignore` it is not git-gated, so it is the one source that works outside a repository. Because the event never matched, a `.ignore` written while the server was live never scheduled the refresh, and the write then fell through to the reindex path where `should_skip_watcher_path` drops any dot-prefixed segment. Nothing happened at all: the startup matcher stayed published and files under the newly excluded directory kept being indexed until the hourly reconcile or a restart. Match `.ignore` by file name alongside `.gitignore`, using the existing `tgrep_core::gitignore` filename constants. `p4ignore.ini` stays root-scoped, mirroring the walker. Fixes #104 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tgrep-cli/src/serve.rs | 27 +++++- tgrep-cli/tests/watcher_dot_ignore.rs | 121 ++++++++++++++++++++++---- 2 files changed, 130 insertions(+), 18 deletions(-) diff --git a/tgrep-cli/src/serve.rs b/tgrep-cli/src/serve.rs index 9ab5549..47df660 100644 --- a/tgrep-cli/src/serve.rs +++ b/tgrep-cli/src/serve.rs @@ -1656,9 +1656,24 @@ fn should_skip_watcher_path( false } +/// Whether a changed path is an ignore-rules source, i.e. one whose contents +/// feed the matcher published in `ServerState::gitignore`. +/// +/// `.gitignore` and `.ignore` are matched by file name at any depth, because +/// [`tgrep_core::gitignore::matcher_from_ignore_paths`] anchors nested files of +/// both kinds. `.ignore` must be included even though it is git-agnostic — +/// leaving it out meant a `.ignore` written while the server was live never +/// refreshed the matcher, so the watcher kept indexing files the rule excluded. +/// +/// `p4ignore.ini` stays root-scoped, mirroring the walker, which only reads the +/// root-level file. fn is_ignore_rules_file(root: &Path, path: &Path) -> bool { - path.file_name().and_then(|name| name.to_str()) == Some(".gitignore") - || path == root.join(tgrep_core::gitignore::P4IGNORE_FILENAME) + let name = path.file_name().and_then(|name| name.to_str()); + matches!( + name, + Some(tgrep_core::gitignore::GITIGNORE_FILENAME) + | Some(tgrep_core::gitignore::DOT_IGNORE_FILENAME) + ) || path == root.join(tgrep_core::gitignore::P4IGNORE_FILENAME) } fn schedule_ignore_rules_refresh(state: Arc, root: PathBuf) { @@ -3672,6 +3687,14 @@ mod tests { root, Path::new("workspace/p4ignore.ini") )); + // `.ignore` is a first-class ignore source (and, unlike `.gitignore`, + // applies outside a git repo), so live edits to it must refresh the + // matcher too — at the root and nested. + assert!(is_ignore_rules_file(root, Path::new("workspace/.ignore"))); + assert!(is_ignore_rules_file( + root, + Path::new("workspace/nested/.ignore") + )); assert!(!is_ignore_rules_file( root, Path::new("workspace/nested/p4ignore.ini") diff --git a/tgrep-cli/tests/watcher_dot_ignore.rs b/tgrep-cli/tests/watcher_dot_ignore.rs index 652141c..914dc5d 100644 --- a/tgrep-cli/tests/watcher_dot_ignore.rs +++ b/tgrep-cli/tests/watcher_dot_ignore.rs @@ -101,22 +101,21 @@ fn wait_for_match(port: u16, pattern: &str, timeout: Duration) -> bool { } } -#[test] -fn watcher_honors_dot_ignore_and_still_indexes_new_files() { - let dir = TempDir::new().unwrap(); - let root = dir.path(); - let index_dir = root.join(".tgrep_test_index"); - - // No `.git` here: `.ignore` must apply on its own. - fs::write(root.join(".ignore"), "secret/\n").unwrap(); - fs::create_dir_all(root.join("secret")).unwrap(); - fs::create_dir_all(root.join("src")).unwrap(); - fs::write( - root.join("src").join("lib.rs"), - "fn seeded() { let normal_source_marker = 1; }\n", - ) - .unwrap(); +/// Poll until `pattern` stops being searchable, returning whether it went away. +fn wait_for_no_match(port: u16, pattern: &str, timeout: Duration) -> bool { + let start = Instant::now(); + loop { + if search_matches(port, pattern) == 0 { + return true; + } + if start.elapsed() > timeout { + return false; + } + thread::sleep(Duration::from_millis(100)); + } +} +fn build_index(root: &Path, index_dir: &Path) { let status = Command::new(tgrep_bin()) .args([ "index", @@ -127,7 +126,9 @@ fn watcher_honors_dot_ignore_and_still_indexes_new_files() { .status() .expect("failed to run tgrep index"); assert!(status.success(), "initial index build failed"); +} +fn spawn_server(root: &Path, index_dir: &Path) -> ServerGuard { let child = Command::new(tgrep_bin()) .args([ "serve", @@ -139,7 +140,27 @@ fn watcher_honors_dot_ignore_and_still_indexes_new_files() { .stdout(std::process::Stdio::null()) .spawn() .expect("failed to start tgrep serve"); - let _server = ServerGuard { child }; + ServerGuard { child } +} + +#[test] +fn watcher_honors_dot_ignore_and_still_indexes_new_files() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + let index_dir = root.join(".tgrep_test_index"); + + // No `.git` here: `.ignore` must apply on its own. + fs::write(root.join(".ignore"), "secret/\n").unwrap(); + fs::create_dir_all(root.join("secret")).unwrap(); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write( + root.join("src").join("lib.rs"), + "fn seeded() { let normal_source_marker = 1; }\n", + ) + .unwrap(); + + build_index(root, &index_dir); + let _server = spawn_server(root, &index_dir); let port = wait_for_port(&index_dir); @@ -176,3 +197,71 @@ fn watcher_honors_dot_ignore_and_still_indexes_new_files() { "watcher indexed a file under a directory excluded by .ignore" ); } + +/// A `.ignore` written *after* the server is live must refresh the ignore +/// rules, exactly as a `.gitignore` write does. +/// +/// The startup matcher is built from the walk that the initial index used, so +/// a `.ignore` that already exists is honored for free — which is what the +/// test above covers. Rules that appear later only take effect if the watcher +/// recognizes the `.ignore` write as an ignore-rules change and schedules the +/// refresh; when it does not, the stale matcher stays published and the +/// already-indexed content under the newly excluded directory stays +/// searchable indefinitely (the periodic reconcile is on an hourly timer). +/// +/// The fixture seeds the ignored file *before* indexing, so the assertion is a +/// transition — searchable, then not — rather than a fixed sleep racing the +/// refresh. +#[test] +fn late_dot_ignore_refreshes_the_watchers_ignore_rules() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + let index_dir = root.join(".tgrep_test_index"); + + // No `.git` here either: `.ignore` is not git-gated, so this pins the + // refresh path for the one ignore source that works outside a repo. + fs::create_dir_all(root.join("secret")).unwrap(); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write( + root.join("secret").join("creds.txt"), + "late_ignored_leak_marker\n", + ) + .unwrap(); + fs::write( + root.join("src").join("lib.rs"), + "fn seeded() { let normal_source_marker = 1; }\n", + ) + .unwrap(); + + build_index(root, &index_dir); + let _server = spawn_server(root, &index_dir); + + let port = wait_for_port(&index_dir); + + assert!( + wait_for_match(port, "normal_source_marker", Duration::from_secs(30)), + "expected the seeded source file to be searchable" + ); + // Positive control: with no `.ignore` yet, the seeded file under `secret/` + // is legitimately indexed. Without this the assertion below could pass + // simply because the file was never indexed in the first place. + assert!( + wait_for_match(port, "late_ignored_leak_marker", Duration::from_secs(30)), + "expected the file under secret/ to be indexed before any .ignore exists" + ); + thread::sleep(Duration::from_secs(2)); + + fs::write(root.join(".ignore"), "secret/\n").unwrap(); + + assert!( + wait_for_no_match(port, "late_ignored_leak_marker", Duration::from_secs(60)), + "a .ignore written while the server was live never refreshed the ignore \ + rules; content under the newly excluded directory is still searchable" + ); + + // The refresh must not take the rest of the index with it. + assert!( + search_matches(port, "normal_source_marker") > 0, + "the ignore-rules refresh dropped a file that is not ignored" + ); +} From 64ec4c325bf0cc859e31fe797c0ed39d9f97d215 Mon Sep 17 00:00:00 2001 From: Shengyu Fu Date: Wed, 26 Aug 2026 21:13:30 -0700 Subject: [PATCH 2/3] Stop the watcher from subscribing to ignored directories The watcher already discarded events for ignored paths, but only after the OS had delivered them. On Linux that is too late to matter: inotify has no recursive mode, so notify's `RecursiveMode::Recursive` walks the tree and spends one watch descriptor per directory. A repository whose `target/` or `node_modules/` holds most of its directories therefore burns most of the per-user `fs.inotify.max_user_watches` budget on events that are thrown away -- and because notify propagates the first registration failure, exhausting that budget makes `watch()` return an error and the server loses its watcher entirely. Subscribe per directory on inotify backends instead. `watchable_dirs` walks the tree once, pruning ignored, hidden and `--exclude`d subtrees before descending, and `WatchRegistry::sync` reconciles the live subscription set against it. The sync runs whenever the ignore matcher is published, so relaxing a rule subscribes to the tree it used to hide and tightening one drops it. A directory that cannot be subscribed is now reported and skipped rather than taking down the whole watcher. Non-recursive watches are not extended by notify, so a directory that appears at runtime is picked up in `watch_new_subtree`, which also indexes the files already inside it to close the create race. Windows (ReadDirectoryChangesW) and macOS (FSEvents) subscribe once for the whole subtree, so there is no per-directory registration to withhold; they keep the single recursive watch and delivery-time filtering. The behaviour the two paths must share -- new directories get indexed, new directories under an ignored path do not -- is tested everywhere. Along the way, `state.gitignore` had three publish sites and only one of them went through the helper. They are unified behind `publish_ignore_matcher` so the sync hook cannot be missed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4 --- tgrep-cli/src/serve.rs | 643 +++++++++++++++--- tgrep-cli/tests/watcher_watch_registration.rs | 308 +++++++++ 2 files changed, 862 insertions(+), 89 deletions(-) create mode 100644 tgrep-cli/tests/watcher_watch_registration.rs diff --git a/tgrep-cli/src/serve.rs b/tgrep-cli/src/serve.rs index 47df660..93fd52f 100644 --- a/tgrep-cli/src/serve.rs +++ b/tgrep-cli/src/serve.rs @@ -296,6 +296,12 @@ struct ServerState { index_total: std::sync::atomic::AtomicU64, /// True when file watching is enabled for this server. watch_enabled: bool, + /// The live watcher and the directories it is subscribed to. + /// + /// Held here rather than by `run` because the subscription set is not + /// fixed: publishing a new ignore matcher renarrows it, and a directory + /// created after startup has to be subscribed as it appears. + watch_registry: Mutex>, /// Directories to exclude from indexing. exclude_dirs: Vec, /// Disable all source-control ignore files for every server discovery path. @@ -570,6 +576,7 @@ pub fn run(root: &Path, index_path: Option<&Path>, options: ServeOptions<'_>) -> index_progress: std::sync::atomic::AtomicU64::new(0), index_total: std::sync::atomic::AtomicU64::new(0), watch_enabled: !no_watch, + watch_registry: Mutex::new(None), exclude_dirs: exclude_dirs.to_vec(), no_ignore, no_require_git, @@ -656,14 +663,13 @@ pub fn run(root: &Path, index_path: Option<&Path>, options: ServeOptions<'_>) -> } // Start file watcher (unless --no-watch) - let _watcher = if no_watch { + if no_watch { eprintln!("[trace] file watcher disabled (--no-watch)"); - None } else { let watcher_state = Arc::clone(&state); let watcher_root = root.clone(); - start_file_watcher(watcher_state, &watcher_root, watcher_queue_cap) - }; + start_file_watcher(watcher_state, &watcher_root, watcher_queue_cap); + } // Set up graceful shutdown let shutdown_index_dir = index_dir.clone(); @@ -744,14 +750,23 @@ fn build_stale_matcher( matcher } -/// Commit matcher and index semantics together while the stale refresh holds -/// `snapshot_gate`. `None` is a legitimate matcher when no rules exist. -fn commit_stale_matcher( +/// Publish a new ignore matcher and bring everything that depends on it up to +/// date. `None` is a legitimate matcher when no rules exist. +/// +/// Callers on the stale path hold `snapshot_gate` for write, which is what +/// makes the matcher swap and the index decisions around it atomic from the +/// watcher's point of view. +fn publish_ignore_matcher( state: &ServerState, + root: &Path, matcher: Option, ) { *state.gitignore.write().unwrap() = matcher; state.gitignore_pending.store(false, Ordering::SeqCst); + // New rules mean a different set of directories worth hearing about: + // a tightened rule releases the subscriptions under it, and a relaxed + // one takes subscriptions for the tree it used to hide. + sync_watch_registrations(state, root); } fn handle_connection(stream: TcpStream, state: &ServerState) -> Result<()> { @@ -1482,11 +1497,7 @@ fn handle_reload(id: Option, state: &ServerState) -> String { } } -fn start_file_watcher( - state: Arc, - root: &Path, - queue_cap: usize, -) -> Option { +fn start_file_watcher(state: Arc, root: &Path, queue_cap: usize) -> bool { use std::sync::mpsc::{RecvTimeoutError, TrySendError}; let root_path = root.to_path_buf(); @@ -1525,19 +1536,46 @@ fn start_file_watcher( Ok(w) => w, Err(e) => { eprintln!("[trace] warning: failed to start file watcher: {e}"); - return None; + return false; } }; - if let Err(e) = watcher.watch(root, RecursiveMode::Recursive) { + // The root is always subscribed. On a whole-subtree backend that single + // recursive subscription is the entire watch set; on a per-directory + // backend it is the anchor, and `sync_watch_registrations` below adds the + // descendants the ignore rules allow. + let root_mode = if PER_DIRECTORY_WATCHES { + RecursiveMode::NonRecursive + } else { + RecursiveMode::Recursive + }; + if let Err(e) = watcher.watch(root, root_mode) { eprintln!("[trace] warning: failed to watch directory: {e}"); - return None; + return false; + } + + *state.watch_registry.lock().unwrap() = Some(WatchRegistry { + watcher, + watched: std::iter::once(root.to_path_buf()).collect(), + }); + + // Subscribing to the descendants needs the ignore matcher, and on a warm + // start it is still being built on another thread. Skipping the sync here + // costs nothing: events are dropped while `gitignore_pending` is set, and + // the publish that clears it runs this same sync. Subscribing first and + // narrowing afterwards would mean briefly holding exactly the watches this + // is meant to avoid — on a repo big enough to exhaust the inotify budget, + // long enough to fail. + if state.gitignore_pending.load(Ordering::SeqCst) { + eprintln!("[trace] watcher subscriptions deferred until the ignore matcher is ready"); + } else { + sync_watch_registrations(&state, root); } let worker_state = Arc::clone(&state); let worker_root = root_path; let worker_index_dir = state.index_dir.clone(); - std::thread::Builder::new() + if std::thread::Builder::new() .name("tgrep-watcher".into()) .spawn(move || { loop { @@ -1579,14 +1617,19 @@ fn start_file_watcher( } } }) - .ok()?; + .is_err() + { + eprintln!("[trace] warning: failed to start the watcher worker thread"); + *state.watch_registry.lock().unwrap() = None; + return false; + } state .watcher_active .store(true, std::sync::atomic::Ordering::Relaxed); eprintln!("[trace] file watcher started"); - Some(watcher) + true } /// Decide whether the file watcher should skip a path entirely. @@ -1617,6 +1660,30 @@ fn should_skip_watcher_path( rel_path: &str, exclude_dirs: &[String], gitignore: Option<&tgrep_core::gitignore::IgnoreMatcher>, +) -> bool { + should_skip_watcher_entry(rel_path, exclude_dirs, gitignore, false) +} + +/// [`should_skip_watcher_path`] for a path known to be a directory. +/// +/// Two rules read differently for a directory. `--exclude` names apply to the +/// final segment as well, because the walker drops the whole subtree when the +/// entry it is looking at *is* the excluded directory. And the gitignore +/// matcher is told it is matching a directory, so a directory-only rule like +/// `build/` matches — as a file path, `build` does not. +fn should_skip_watcher_dir( + rel_path: &str, + exclude_dirs: &[String], + gitignore: Option<&tgrep_core::gitignore::IgnoreMatcher>, +) -> bool { + should_skip_watcher_entry(rel_path, exclude_dirs, gitignore, true) +} + +fn should_skip_watcher_entry( + rel_path: &str, + exclude_dirs: &[String], + gitignore: Option<&tgrep_core::gitignore::IgnoreMatcher>, + is_dir: bool, ) -> bool { // Single streaming pass over path components — no Vec allocation // on the hot watcher path. The hidden-component check applies to @@ -1632,23 +1699,21 @@ fn should_skip_watcher_path( if seg.starts_with('.') { return true; } - // Ancestor (i.e. not the last segment) — apply exclude_dirs. - if segments.peek().is_some() - && !exclude_dirs.is_empty() - && exclude_dirs.iter().any(|d| d == seg) - { + // An ancestor is always a directory; the final segment is one only + // when the caller says so. + let segment_is_dir = segments.peek().is_some() || is_dir; + if segment_is_dir && !exclude_dirs.is_empty() && exclude_dirs.iter().any(|d| d == seg) { return true; } } // Gitignore check (if a matcher is available). if let Some(gi) = gitignore { - // We don't know whether the path is a dir or a file here — for - // the watcher's purposes we treat all events as "file" matches. - // Notify usually fires per-file events anyway, and gitignore - // rules that target dirs would have already skipped the dir's - // contents via `matched_path_or_any_parents`. - if gi.is_ignored(Path::new(rel_path), false) { + // For a file event we don't know whether the path is a dir, so we + // treat it as a file. Notify usually fires per-file events anyway, + // and gitignore rules that target dirs would have already skipped + // the dir's contents via `matched_path_or_any_parents`. + if gi.is_ignored(Path::new(rel_path), is_dir) { return true; } } @@ -1676,6 +1741,238 @@ fn is_ignore_rules_file(root: &Path, path: &Path) -> bool { ) || path == root.join(tgrep_core::gitignore::P4IGNORE_FILENAME) } +/// Whether this platform's `notify` backend takes one OS subscription per +/// directory rather than a single recursive one for the whole tree. +/// +/// inotify has no recursive mode. `RecursiveMode::Recursive` makes notify walk +/// the tree itself and spend one watch descriptor per directory, so every +/// ignored directory costs a descriptor from the per-user +/// `fs.inotify.max_user_watches` budget purely to deliver events we then throw +/// away. Worse, notify's registration loop propagates the first failure, so a +/// repo whose ignored build output exhausts the budget makes `watch()` return +/// an error and the server loses its watcher entirely. +/// +/// ReadDirectoryChangesW (Windows) and FSEvents (macOS) subscribe once for the +/// whole subtree, so there is no per-directory registration to withhold. On +/// those platforms filtering on delivery is the only lever available, and +/// [`should_skip_watcher_path`] remains it. +/// +/// Deliberately limited to the backends we can exercise in CI. kqueue and +/// `PollWatcher` are per-path too, but nothing here builds or tests them. +const PER_DIRECTORY_WATCHES: bool = cfg!(any(target_os = "linux", target_os = "android")); + +/// The watcher plus the set of directories it is currently subscribed to. +/// +/// Only meaningful when [`PER_DIRECTORY_WATCHES`] is true; elsewhere `watched` +/// holds just the root, which is subscribed recursively. +struct WatchRegistry { + watcher: RecommendedWatcher, + watched: std::collections::HashSet, +} + +impl WatchRegistry { + /// Bring the subscription set in line with `desired`, subscribing to + /// directories that are newly relevant and dropping ones that are not. + /// + /// Returns `(added, removed)`. A single directory that cannot be + /// subscribed is reported and skipped rather than failing the sync: the + /// watcher is still useful for everything else, and giving up on the whole + /// tree is exactly the failure mode this registration exists to avoid. + fn sync(&mut self, desired: &std::collections::HashSet) -> (usize, usize) { + let stale: Vec = self.watched.difference(desired).cloned().collect(); + let mut removed = 0; + for dir in stale { + // Best effort. inotify drops a descriptor by itself when the + // directory is deleted, so "not found" is an expected outcome + // here, not an error worth reporting. + let _ = self.watcher.unwatch(&dir); + self.watched.remove(&dir); + removed += 1; + } + + let missing: Vec = desired.difference(&self.watched).cloned().collect(); + let mut added = 0; + let mut failures = 0; + for dir in missing { + match self.watcher.watch(&dir, RecursiveMode::NonRecursive) { + Ok(()) => { + self.watched.insert(dir); + added += 1; + } + Err(e) => { + // One line per sync, not per directory: exhausting the + // inotify budget fails thousands of these at once. + if failures == 0 { + eprintln!( + "[trace] warning: could not watch {}: {e} \ + (continuing with the directories that succeeded)", + dir.display() + ); + } + failures += 1; + } + } + } + if failures > 1 { + eprintln!("[trace] warning: {failures} directories could not be watched"); + } + (added, removed) + } +} + +/// Every directory at or below `start` whose contents the watcher needs to +/// hear about. +/// +/// A per-directory subscription reports the files directly inside it, so the +/// set is "`start`, plus every descendant directory the indexer would walk +/// into". Ignored directories are pruned along with their subtrees, which is +/// the whole point: the tree under `target/` or `node_modules/` is usually +/// most of the directories in a repo. +/// +/// `root` is the repository root and is only used to build the relative paths +/// the ignore rules are written against; `start` is where the walk begins. +/// They differ when a subtree that appeared at runtime is being subscribed, +/// and conflating them would match every rule at the wrong anchor. +/// +/// `start` itself is always included — callers are responsible for not asking +/// about a directory that is ignored. +/// +/// Symlinked directories are not descended into, matching the walker (the +/// `ignore` crate does not follow links by default). That also keeps a +/// symlink cycle from turning this into an infinite walk. +fn watchable_dirs( + root: &Path, + start: &Path, + exclude_dirs: &[String], + gitignore: Option<&tgrep_core::gitignore::IgnoreMatcher>, +) -> std::collections::HashSet { + let mut found = std::collections::HashSet::new(); + found.insert(start.to_path_buf()); + + let mut stack = vec![start.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + // An unreadable directory is not a reason to abandon the rest of + // the tree; the periodic reconcile is what catches what we miss. + continue; + }; + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + let path = entry.path(); + let Ok(rel) = path.strip_prefix(root) else { + continue; + }; + let rel = rel.to_string_lossy().replace('\\', "/"); + if should_skip_watcher_dir(&rel, exclude_dirs, gitignore) { + continue; + } + stack.push(path.clone()); + found.insert(path); + } + } + found +} + +/// Recompute the watcher's subscriptions against the ignore rules in force. +/// +/// Called when the watcher starts and every time the ignore matcher is +/// published, so relaxing a rule subscribes to the tree it used to hide and +/// tightening one drops it. +fn sync_watch_registrations(state: &ServerState, root: &Path) { + if !PER_DIRECTORY_WATCHES { + return; + } + let mut registry = state.watch_registry.lock().unwrap(); + let Some(registry) = registry.as_mut() else { + // The watcher has not started yet. It syncs once as it comes up, so + // there is nothing to do and nothing to remember. + return; + }; + + let start = Instant::now(); + let desired = { + let gitignore = state.gitignore.read().unwrap(); + watchable_dirs(root, root, &state.exclude_dirs, gitignore.as_ref()) + }; + let total = desired.len(); + let (added, removed) = registry.sync(&desired); + if added > 0 || removed > 0 { + eprintln!( + "[trace] watcher subscriptions: {total} directories \ + (+{added}, -{removed}) in {:.1}ms", + start.elapsed().as_secs_f64() * 1000.0 + ); + } +} + +/// Subscribe to a directory that has just appeared, and to anything already +/// inside it. +/// +/// Non-recursive subscriptions are not extended by notify — it only auto-adds +/// watches beneath a watch that was registered as recursive — so a new +/// directory has to be picked up here or its contents are invisible. +/// +/// Files that landed between the directory's creation and its subscription +/// would be missed by definition, so the same pass indexes what it finds. +/// The caller must already hold `snapshot_gate`. +fn watch_new_subtree(state: &ServerState, root: &Path, dir: &Path) { + let Ok(rel_dir) = dir.strip_prefix(root) else { + return; + }; + let rel_dir = rel_dir.to_string_lossy().replace('\\', "/"); + + let desired = { + let gitignore = state.gitignore.read().unwrap(); + // The event that brought us here was filtered with file semantics, so + // a `build/`-style rule that only ever matches directories has not + // been applied to this path yet. Re-check before subscribing to it. + if !rel_dir.is_empty() + && should_skip_watcher_dir(&rel_dir, &state.exclude_dirs, gitignore.as_ref()) + { + return; + } + watchable_dirs(root, dir, &state.exclude_dirs, gitignore.as_ref()) + }; + + { + let mut registry = state.watch_registry.lock().unwrap(); + let Some(registry) = registry.as_mut() else { + return; + }; + // A union, not a replacement: `desired` covers only this subtree, and + // `sync` would read anything outside it as newly stale and unsubscribe + // from the entire rest of the repository. + let union: std::collections::HashSet = + registry.watched.union(&desired).cloned().collect(); + registry.sync(&union); + } + + for subdir in &desired { + let Ok(entries) = std::fs::read_dir(subdir) else { + continue; + }; + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|t| t.is_file()) { + continue; + } + let path = entry.path(); + let Ok(rel) = path.strip_prefix(root) else { + continue; + }; + let rel = rel.to_string_lossy().replace('\\', "/"); + let skip = { + let gitignore = state.gitignore.read().unwrap(); + should_skip_watcher_path(&rel, &state.exclude_dirs, gitignore.as_ref()) + }; + if !skip { + reindex_file(state, &path, &rel); + } + } + } +} + fn schedule_ignore_rules_refresh(state: Arc, root: PathBuf) { if state .ignore_refresh_scheduled @@ -1713,8 +2010,6 @@ fn schedule_ignore_rules_refresh(state: Arc, root: PathBuf) { } fn handle_fs_event(state: &Arc, root: &Path, event: &Event) { - use tgrep_core::meta::FileStamp; - let dominated_kinds = matches!( event.kind, EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) @@ -1810,66 +2105,83 @@ fn handle_fs_event(state: &Arc, root: &Path, event: &Event) { } if !path.is_file() { + if PER_DIRECTORY_WATCHES && path.is_dir() { + // With non-recursive subscriptions notify will not extend the + // watch set for us, so a directory that just appeared — and + // anything already inside it — has to be picked up here. + watch_new_subtree(state, root, path); + } continue; } - // Compute the file's current stamp and skip if it matches what we - // last indexed. notify on Windows in particular fires Modify events - // for atime/attribute updates, opens, etc. — re-indexing on those - // would re-read large files, churn the live overlay, and produce a - // misleading "modified" trace for files that didn't actually change. - let current = match std::fs::metadata(path) { - Ok(m) => FileStamp { - mtime: m - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()) - .unwrap_or(0), - size: m.len(), - }, - Err(_) => continue, - }; - if state.file_stamps.read().unwrap().get(&rel_path) == Some(¤t) { - continue; - } + reindex_file(state, path, &rel_path); + } +} - // Read contents and extract trigrams OUTSIDE the index write lock - // so a concurrent search (which needs a read lock) is not blocked - // on our file I/O and trigram parsing. Windows' SRWLock is - // writer-preferring: a single waiting writer here would otherwise - // stall every subsequent search request. - let data = match std::fs::read(path) { - Ok(d) => d, - Err(_) => continue, - }; - let text = tgrep_core::encoding::decode_for_index(&data); - let is_binary = tgrep_core::trigram::is_binary(&text); - let per_tri = if is_binary { - None - } else { - Some(tgrep_core::live::LiveIndex::compute_trigram_masks(&text)) - }; +/// Read a file and merge it into the live index, unless its stamp says the +/// content we already indexed is current. +/// +/// The caller must hold `snapshot_gate`: the read, the commit, and the stamp +/// update have to be atomic with respect to a flush or auto-save. +fn reindex_file(state: &ServerState, path: &Path, rel_path: &str) { + use tgrep_core::meta::FileStamp; - eprintln!("[trace] reindex: modified {rel_path}"); - // gate acquired at the function level — the commit + stamp - // update is processed atomically with respect to flush/auto-save. - { - let mut index = state.index.write().unwrap(); - match per_tri { - Some(per_tri) => index.live.commit_upsert(&rel_path, per_tri), - None => index.live.delete_file(&rel_path), - } - } - state - .file_stamps - .write() - .unwrap() - .insert(rel_path.clone(), current); - if let Ok(mut cache) = state.cache.write() { - cache.pop(&rel_path); + // Compute the file's current stamp and skip if it matches what we + // last indexed. notify on Windows in particular fires Modify events + // for atime/attribute updates, opens, etc. — re-indexing on those + // would re-read large files, churn the live overlay, and produce a + // misleading "modified" trace for files that didn't actually change. + let current = match std::fs::metadata(path) { + Ok(m) => FileStamp { + mtime: m + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0), + size: m.len(), + }, + Err(_) => return, + }; + if state.file_stamps.read().unwrap().get(rel_path) == Some(¤t) { + return; + } + + // Read contents and extract trigrams OUTSIDE the index write lock + // so a concurrent search (which needs a read lock) is not blocked + // on our file I/O and trigram parsing. Windows' SRWLock is + // writer-preferring: a single waiting writer here would otherwise + // stall every subsequent search request. + let data = match std::fs::read(path) { + Ok(d) => d, + Err(_) => return, + }; + let text = tgrep_core::encoding::decode_for_index(&data); + let is_binary = tgrep_core::trigram::is_binary(&text); + let per_tri = if is_binary { + None + } else { + Some(tgrep_core::live::LiveIndex::compute_trigram_masks(&text)) + }; + + eprintln!("[trace] reindex: modified {rel_path}"); + // Gate held by the caller — the commit + stamp update is processed + // atomically with respect to flush/auto-save. + { + let mut index = state.index.write().unwrap(); + match per_tri { + Some(per_tri) => index.live.commit_upsert(rel_path, per_tri), + None => index.live.delete_file(rel_path), } } + state + .file_stamps + .write() + .unwrap() + .insert(rel_path.to_string(), current); + if let Ok(mut cache) = state.cache.write() { + cache.pop(rel_path); + } } /// Whether a scheduled reconcile should run now. @@ -2369,7 +2681,7 @@ fn background_refresh_stale( // this function holds `snapshot_gate` for write across its whole body, and // the only reader of `state.gitignore` takes the read side first, so no // event can observe the matcher before this function returns either way. - commit_stale_matcher(state, build_stale_matcher(state, root, &walk)); + publish_ignore_matcher(state, root, build_stale_matcher(state, root, &walk)); if walk.skipped_error > 0 { eprintln!( @@ -2607,8 +2919,7 @@ fn bootstrap_index_build(state: &Arc, root: &Path, index_dir: &Path state.no_require_git, ); let found = matcher.is_some(); - *state.gitignore.write().unwrap() = matcher; - state.gitignore_pending.store(false, Ordering::SeqCst); + publish_ignore_matcher(state, root, matcher); eprintln!( "[trace] gitignore matcher built from {} file(s) in {:.1}ms{}", outcome.gitignore_files.len(), @@ -2701,8 +3012,7 @@ fn background_index_build(state: &Arc, root: &Path, index_dir: &Pat state.no_require_git, ); let has_matcher = matcher.is_some(); - *state.gitignore.write().unwrap() = matcher; - state.gitignore_pending.store(false, Ordering::SeqCst); + publish_ignore_matcher(state, root, matcher); eprintln!( "[trace] gitignore matcher built from index walk in {:.1}ms \ ({} .gitignore + {} .ignore files{})", @@ -3455,6 +3765,7 @@ mod tests { index_progress: std::sync::atomic::AtomicU64::new(0), index_total: std::sync::atomic::AtomicU64::new(0), watch_enabled: true, + watch_registry: Mutex::new(None), exclude_dirs: Vec::new(), no_ignore: false, no_require_git: false, @@ -3676,6 +3987,160 @@ mod tests { )); } + #[test] + fn watchable_dirs_prunes_ignored_and_hidden_subtrees() { + // The point of the subscription set: an ignored directory costs one + // inotify watch descriptor per directory inside it, so pruning has to + // happen before the subtree is walked, not after its events arrive. + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + std::fs::create_dir(root.join(".git")).unwrap(); + std::fs::write(root.join(".gitignore"), "build/\n").unwrap(); + + for dir in [ + "src", + "src/nested", + "build", + "build/a", + "build/a/deep", + ".git/objects", + "vendor", + "vendor/pkg", + ] { + std::fs::create_dir_all(root.join(dir)).unwrap(); + } + + let gi = tgrep_core::gitignore::build_matcher(root).expect("matcher should build"); + let exclude = vec!["vendor".to_string()]; + let dirs = watchable_dirs(root, root, &exclude, Some(&gi)); + + let rel: std::collections::HashSet = dirs + .iter() + .map(|p| { + p.strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/") + }) + .collect(); + + // The root itself is always watched, plus the directories the indexer + // would descend into. + assert!(rel.contains(""), "root must always be watched: {rel:?}"); + assert!(rel.contains("src")); + assert!(rel.contains("src/nested")); + + // A gitignored directory and everything beneath it. + assert!( + !rel.contains("build"), + "gitignored dir was watched: {rel:?}" + ); + assert!(!rel.contains("build/a")); + assert!(!rel.contains("build/a/deep")); + + // Hidden directories, which the walker skips too. + assert!(!rel.contains(".git")); + assert!(!rel.contains(".git/objects")); + + // `--exclude` names prune the directory itself, not just its children. + assert!(!rel.contains("vendor"), "excluded dir was watched: {rel:?}"); + assert!(!rel.contains("vendor/pkg")); + } + + #[test] + fn watchable_dirs_without_a_matcher_keeps_everything_visible() { + // `--no-ignore` publishes no matcher. The subscription set must then + // be the whole tree minus hidden paths, matching what the walk indexes; + // silently narrowing it would drop events for files that ARE indexed. + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("build/a")).unwrap(); + std::fs::create_dir_all(root.join(".hidden")).unwrap(); + + let dirs = watchable_dirs(root, root, &[], None); + let rel: std::collections::HashSet = dirs + .iter() + .map(|p| { + p.strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/") + }) + .collect(); + + assert!(rel.contains("build")); + assert!(rel.contains("build/a")); + assert!(!rel.contains(".hidden")); + } + + #[test] + fn watchable_dirs_anchors_rules_at_the_root_not_the_start_directory() { + // A subtree that appears at runtime is walked from itself, but the + // ignore rules are written against paths relative to the repository + // root. Walking with the subtree as the anchor would test "nested" + // against a rule meant for "src/nested" and prune the wrong things. + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + std::fs::create_dir(root.join(".git")).unwrap(); + std::fs::write(root.join(".gitignore"), "src/fresh/skipped/\n/keep/\n").unwrap(); + + for dir in ["src/fresh/skipped", "src/fresh/keep", "src/fresh/kept"] { + std::fs::create_dir_all(root.join(dir)).unwrap(); + } + + let gi = tgrep_core::gitignore::build_matcher(root).expect("matcher should build"); + let dirs = watchable_dirs(root, &root.join("src/fresh"), &[], Some(&gi)); + let rel: std::collections::HashSet = dirs + .iter() + .map(|p| { + p.strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/") + }) + .collect(); + + assert!( + rel.contains("src/fresh"), + "start dir must be watched: {rel:?}" + ); + assert!(rel.contains("src/fresh/kept")); + assert!( + !rel.contains("src/fresh/skipped"), + "a root-anchored rule was not applied: {rel:?}" + ); + // `keep/` is anchored at the root, so it must NOT prune + // `src/fresh/keep` just because the walk started at `src/fresh`. + assert!( + rel.contains("src/fresh/keep"), + "a root-anchored rule was applied at the wrong depth: {rel:?}" + ); + } + + #[test] + fn skip_watcher_dir_applies_directory_semantics() { + // A directory-only gitignore rule (`build/`) does not match the path + // `build` when it is tested as a file, which is why the subscription + // set needs its own dir-aware entry point. + let tmp = TempDir::new().unwrap(); + std::fs::create_dir(tmp.path().join(".git")).unwrap(); + std::fs::write(tmp.path().join(".gitignore"), "build/\n").unwrap(); + let gi = tgrep_core::gitignore::build_matcher(tmp.path()).expect("matcher should build"); + + assert!(should_skip_watcher_dir("build", &[], Some(&gi))); + assert!(!should_skip_watcher_path("build", &[], Some(&gi))); + + // `--exclude` prunes the named directory itself... + let exclude = vec!["target".to_string()]; + assert!(should_skip_watcher_dir("target", &exclude, None)); + assert!(should_skip_watcher_dir("src/target", &exclude, None)); + // ...but a *file* of that name is still indexed, so it must not be + // skipped. This is the invariant `should_skip_watcher_path` already + // held, and sharing an implementation must not have changed it. + assert!(!should_skip_watcher_path("target", &exclude, None)); + assert!(!should_skip_watcher_path("src/target", &exclude, None)); + } + #[test] fn identifies_live_ignore_rule_changes() { let root = Path::new("workspace"); diff --git a/tgrep-cli/tests/watcher_watch_registration.rs b/tgrep-cli/tests/watcher_watch_registration.rs new file mode 100644 index 0000000..47376da --- /dev/null +++ b/tgrep-cli/tests/watcher_watch_registration.rs @@ -0,0 +1,308 @@ +//! The watcher must not take OS subscriptions for trees it is going to ignore. +//! +//! On Linux `notify` has no recursive inotify mode: `RecursiveMode::Recursive` +//! walks the tree and spends one watch descriptor per directory. Ignored build +//! output is usually most of the directories in a repository, so subscribing to +//! it burns the per-user `fs.inotify.max_user_watches` budget on events that +//! are then discarded — and because notify propagates the first registration +//! failure, a repo large enough to exhaust that budget loses its watcher +//! entirely. +//! +//! This is observable: the kernel reports a process's inotify watches in +//! `/proc//fdinfo/`, one `inotify wd:` line per watch. The test below +//! counts them for a live server and pins that the ignored subtree is absent, +//! while asserting the watcher still works — a watcher that registered nothing +//! at all would trivially satisfy the count. +//! +//! Windows (ReadDirectoryChangesW) and macOS (FSEvents) subscribe once for the +//! whole subtree, so there is no per-directory registration to withhold and +//! nothing here applies; the count assertion is Linux-only for that reason. + +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpStream; +use std::path::Path; +use std::process::{Child, Command}; +use std::thread; +use std::time::{Duration, Instant}; + +use tempfile::TempDir; + +/// Directories inside the ignored tree. Large enough that a recursive +/// subscription is unmistakable in the watch count, small enough to create +/// quickly. +#[cfg(target_os = "linux")] +const IGNORED_DIRS: usize = 60; +/// Directories inside the indexed tree. +#[cfg(target_os = "linux")] +const SOURCE_DIRS: usize = 4; + +fn tgrep_bin() -> std::path::PathBuf { + assert_cmd::cargo::cargo_bin("tgrep") +} + +struct ServerGuard { + child: Child, +} + +impl Drop for ServerGuard { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn send_request(port: u16, request: &str) -> std::io::Result { + let mut stream = TcpStream::connect(format!("127.0.0.1:{port}"))?; + stream.set_read_timeout(Some(Duration::from_secs(30)))?; + writeln!(stream, "{request}")?; + stream.flush()?; + let mut reader = BufReader::new(stream); + let mut response = String::new(); + reader.read_line(&mut response)?; + Ok(response) +} + +fn search_matches(port: u16, pattern: &str) -> u64 { + let request = serde_json::json!({ + "jsonrpc": "2.0", + "method": "search", + "id": 1, + "params": { "pattern": pattern } + }) + .to_string(); + let response = send_request(port, &request).expect("search request failed"); + let value: serde_json::Value = serde_json::from_str(&response).expect("invalid JSON response"); + value + .pointer("/result/num_matches") + .and_then(|v| v.as_u64()) + .unwrap_or_else(|| panic!("missing num_matches in response: {response}")) +} + +fn wait_for_match(port: u16, pattern: &str, timeout: Duration) -> bool { + let start = Instant::now(); + loop { + if search_matches(port, pattern) > 0 { + return true; + } + if start.elapsed() > timeout { + return false; + } + thread::sleep(Duration::from_millis(100)); + } +} + +/// Read `(pid, port)` once the server is accepting connections. +fn wait_for_server(index_dir: &Path) -> (u32, u16) { + let serve_json = index_dir.join("serve.json"); + let start = Instant::now(); + loop { + assert!( + start.elapsed() <= Duration::from_secs(60), + "tgrep serve did not start within 60 seconds" + ); + if let Ok(data) = fs::read_to_string(&serve_json) + && let Ok(info) = serde_json::from_str::(&data) + && let Some(p) = info.get("port").and_then(|v| v.as_u64()) + && let Some(pid) = info.get("pid").and_then(|v| v.as_u64()) + && TcpStream::connect(format!("127.0.0.1:{p}")).is_ok() + { + return (pid as u32, p as u16); + } + thread::sleep(Duration::from_millis(20)); + } +} + +/// Total inotify watch descriptors held by `pid`. +/// +/// Each inotify file descriptor's `fdinfo` lists one `inotify wd:` line per +/// watch, so summing them across the process's descriptors gives the number of +/// directories it is subscribed to. +#[cfg(target_os = "linux")] +fn inotify_watch_count(pid: u32) -> usize { + let dir = format!("/proc/{pid}/fdinfo"); + let Ok(entries) = fs::read_dir(&dir) else { + panic!("could not read {dir}; /proc must be mounted for this test"); + }; + let mut total = 0; + for entry in entries.flatten() { + // Descriptors come and go while we read; a vanished one is not a + // failure, it simply holds no watches we can count. + if let Ok(contents) = fs::read_to_string(entry.path()) { + total += contents + .lines() + .filter(|line| line.starts_with("inotify wd:")) + .count(); + } + } + total +} + +#[cfg(target_os = "linux")] +#[test] +fn watcher_does_not_subscribe_to_gitignored_directories() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + let index_dir = root.join(".tgrep_test_index"); + + // A real (if empty) `.git` entry: `.gitignore` rules only apply inside a + // repository, so without it the fixture would measure the wrong thing. + fs::create_dir_all(root.join(".git")).unwrap(); + fs::write(root.join(".gitignore"), "build/\n").unwrap(); + + for i in 0..IGNORED_DIRS { + let sub = root.join("build").join(format!("out{i}")); + fs::create_dir_all(&sub).unwrap(); + fs::write(sub.join("artifact.txt"), "ignored_build_output\n").unwrap(); + } + for i in 0..SOURCE_DIRS { + let sub = root.join("src").join(format!("pkg{i}")); + fs::create_dir_all(&sub).unwrap(); + fs::write( + sub.join("lib.rs"), + format!("fn seeded{i}() {{ let normal_source_marker = {i}; }}\n"), + ) + .unwrap(); + } + + let status = Command::new(tgrep_bin()) + .args([ + "index", + root.to_str().unwrap(), + "--index-path", + index_dir.to_str().unwrap(), + ]) + .status() + .expect("failed to run tgrep index"); + assert!(status.success(), "initial index build failed"); + + let child = Command::new(tgrep_bin()) + .args([ + "serve", + "--index-path", + index_dir.to_str().unwrap(), + root.to_str().unwrap(), + ]) + .stderr(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .spawn() + .expect("failed to start tgrep serve"); + let _server = ServerGuard { child }; + + let (pid, port) = wait_for_server(&index_dir); + + // Positive control, and a wait for the watcher to be live: subscriptions + // are deferred until the ignore matcher is published, so counting before + // that would pass for the wrong reason. + assert!( + wait_for_match(port, "normal_source_marker", Duration::from_secs(30)), + "expected the seeded source files to be searchable" + ); + fs::write( + root.join("src").join("added.rs"), + "fn added() { let watcher_added_marker = 1; }\n", + ) + .unwrap(); + assert!( + wait_for_match(port, "watcher_added_marker", Duration::from_secs(30)), + "watcher never indexed a newly created ordinary file, so the watch \ + count below would be meaningless" + ); + + let watches = inotify_watch_count(pid); + + // The tree the watcher legitimately needs is the root, `src`, `src/pkg*`, + // and `.git` is hidden so it is not watched either. Allow generous slack + // for anything else in the process holding an inotify fd, but stay far + // below the ~66 a recursive subscription over this fixture would take. + let allowed = SOURCE_DIRS + 8; + assert!( + watches <= allowed, + "watcher holds {watches} inotify watches for a tree whose only \ + non-ignored directories are the root plus {SOURCE_DIRS} under src/; \ + it is subscribing to the {IGNORED_DIRS} gitignored directories under \ + build/ (expected at most {allowed})" + ); +} + +/// New directories still have to be picked up. +/// +/// Non-recursive subscriptions are not extended by notify, so a directory +/// created after startup is invisible unless the watcher subscribes to it as +/// it appears. This runs everywhere: on a whole-subtree backend it simply +/// re-confirms the existing behaviour, which is the point — the two paths must +/// agree. +#[test] +fn watcher_indexes_files_in_directories_created_after_startup() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + let index_dir = root.join(".tgrep_test_index"); + + fs::create_dir_all(root.join(".git")).unwrap(); + fs::write(root.join(".gitignore"), "build/\n").unwrap(); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write( + root.join("src").join("lib.rs"), + "fn seeded() { let normal_source_marker = 1; }\n", + ) + .unwrap(); + + let status = Command::new(tgrep_bin()) + .args([ + "index", + root.to_str().unwrap(), + "--index-path", + index_dir.to_str().unwrap(), + ]) + .status() + .expect("failed to run tgrep index"); + assert!(status.success(), "initial index build failed"); + + let child = Command::new(tgrep_bin()) + .args([ + "serve", + "--index-path", + index_dir.to_str().unwrap(), + root.to_str().unwrap(), + ]) + .stderr(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .spawn() + .expect("failed to start tgrep serve"); + let _server = ServerGuard { child }; + + let (_pid, port) = wait_for_server(&index_dir); + assert!( + wait_for_match(port, "normal_source_marker", Duration::from_secs(30)), + "expected the seeded source file to be searchable" + ); + thread::sleep(Duration::from_secs(2)); + + // A whole new subtree, several levels deep, written in one go. The files + // land immediately after their directories, which is exactly the race the + // subscription pass has to close. + let nested = root.join("src").join("fresh").join("deeper"); + fs::create_dir_all(&nested).unwrap(); + fs::write( + nested.join("new.rs"), + "fn fresh() { let nested_new_dir_marker = 2; }\n", + ) + .unwrap(); + + assert!( + wait_for_match(port, "nested_new_dir_marker", Duration::from_secs(30)), + "watcher never indexed a file created in a directory that did not \ + exist when the server started" + ); + + // ...and a directory created inside the ignored tree stays ignored. + let ignored = root.join("build").join("fresh"); + fs::create_dir_all(&ignored).unwrap(); + fs::write(ignored.join("out.txt"), "new_ignored_dir_marker\n").unwrap(); + thread::sleep(Duration::from_secs(3)); + assert_eq!( + search_matches(port, "new_ignored_dir_marker"), + 0, + "watcher indexed a file in a directory created under a gitignored path" + ); +} From ae31d72d2868c2477af19e4e68174d428e178dde Mon Sep 17 00:00:00 2001 From: Shengyu Fu Date: Wed, 26 Aug 2026 21:17:13 -0700 Subject: [PATCH 3/3] CONTROL EXPERIMENT - do not merge: disable per-directory watches --- tgrep-cli/src/serve.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tgrep-cli/src/serve.rs b/tgrep-cli/src/serve.rs index 93fd52f..eb56c22 100644 --- a/tgrep-cli/src/serve.rs +++ b/tgrep-cli/src/serve.rs @@ -1759,7 +1759,7 @@ fn is_ignore_rules_file(root: &Path, path: &Path) -> bool { /// /// Deliberately limited to the backends we can exercise in CI. kqueue and /// `PollWatcher` are per-path too, but nothing here builds or tests them. -const PER_DIRECTORY_WATCHES: bool = cfg!(any(target_os = "linux", target_os = "android")); +const PER_DIRECTORY_WATCHES: bool = false; /// The watcher plus the set of directories it is currently subscribed to. ///