From 8c0e4ab554fdb262af71c33aa8c81450e6f6fdfd Mon Sep 17 00:00:00 2001 From: Andy Carlson <2yinyang2@gmail.com> Date: Fri, 26 Jun 2026 19:12:01 -0700 Subject: [PATCH 1/8] fix(repo_metadata): skip rebuilding gitignored subtrees on watcher events --- crates/repo_metadata/src/entry.rs | 2 +- crates/repo_metadata/src/local_model.rs | 57 ++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/crates/repo_metadata/src/entry.rs b/crates/repo_metadata/src/entry.rs index c5fedbbaa8..e6aa89072e 100644 --- a/crates/repo_metadata/src/entry.rs +++ b/crates/repo_metadata/src/entry.rs @@ -691,7 +691,7 @@ pub fn is_git_internal_path(path: &Path) -> bool { /// `force_included_paths`. Each force-included path is a relative component /// sequence (e.g. `.agents/skills`) matched against the tail of `path`, so a /// match also holds for the ancestor prefixes leading to it. -fn matches_force_included_path(path: &Path, force_included_paths: &[PathBuf]) -> bool { +pub(crate) fn matches_force_included_path(path: &Path, force_included_paths: &[PathBuf]) -> bool { let path_components: Vec<_> = path .components() .filter_map(|component| match component { diff --git a/crates/repo_metadata/src/local_model.rs b/crates/repo_metadata/src/local_model.rs index b91686267e..43fdc9a34c 100644 --- a/crates/repo_metadata/src/local_model.rs +++ b/crates/repo_metadata/src/local_model.rs @@ -40,6 +40,7 @@ use warp_util::standardized_path::StandardizedPath; use crate::entry::{ BudgetExceededBehavior, BuildTreeError, BuildTreeOptions, Entry, FileId, IgnoredPathStrategy, + matches_force_included_path, }; use crate::repository::Repository; use crate::standing_queries::{ @@ -542,6 +543,24 @@ impl LocalRepoMetadataModel { event: &BulkFilesystemWatcherEvent, ctx: &mut ModelContext, ) { + // Diagnostics: surface the volume and a sample of the changed paths that + // triggered this rebuild, so the source of watcher churn can be traced. + let added_count = event.added.len(); + let modified_count = event.modified.len(); + let deleted_count = event.deleted.len(); + let moved_count = event.moved.len(); + let total_count = added_count + modified_count + deleted_count + moved_count; + let sample = event + .added_or_updated_iter() + .chain(event.deleted.iter()) + .take(20) + .map(|path| path.display().to_string()) + .collect::>() + .join(", "); + log::error!( + "[watcher-diag] fs event: added={added_count} modified={modified_count} deleted={deleted_count} moved={moved_count} total={total_count} sample=[{sample}]" + ); + // Create a map to collect changes per repository let mut repo_updates: HashMap = HashMap::new(); let symlink_target_paths = self.add_symlink_target_updates(event, &mut repo_updates); @@ -584,6 +603,12 @@ impl LocalRepoMetadataModel { // Phase 1 (background thread): compute lightweight mutations via filesystem I/O. // Phase 2 (main thread callback): apply mutations directly to the tree — no clone needed. for (repo_path, repo_scoped_update) in repo_updates { + let n_added = repo_scoped_update.added.len(); + let n_deleted = repo_scoped_update.deleted.len(); + let n_moved = repo_scoped_update.moved.len(); + log::error!( + "[watcher-diag] repo={repo_path} input added={n_added} deleted={n_deleted} moved={n_moved}" + ); if let Some(IndexedRepoState::Indexed(state)) = self.repositories.get_mut(&repo_path) { let repo_path_clone = repo_path.clone(); let gitignores_clone = state.gitignores.clone(); @@ -1187,10 +1212,31 @@ impl LocalRepoMetadataModel { continue; } + // Gitignored directories (e.g. build output such as `target/`) + // are represented in the canonical tree as unloaded + // placeholders, exactly as the initial index does via + // `IgnoredPathStrategy::IncludeLazy`. Recursively walking them + // here would re-index enormous ignored subtrees (tens of + // thousands of files) on every filesystem event, which is the + // root cause of the watcher rebuild storm. Force-included paths + // (e.g. skill provider directories) must still be materialized + // even when they live under an ignored directory. + if is_ignored && !matches_force_included_path(path_to_add, force_included_paths) { + mutations.push(FileTreeMutation::AddUnloadedDirectory { + path: path_to_add.clone(), + is_ignored, + }); + continue; + } + let mut files = Vec::new(); let mut gitignores = gitignores.to_owned(); let mut file_limit = MAX_FILES_PER_REPO; - match Entry::build_tree_with_standing_queries( + // Diagnostics: time and size each full subtree rebuild so the + // expensive directories (the watcher-storm culprits) are + // identifiable. + let rebuild_start = std::time::Instant::now(); + let build_result = Entry::build_tree_with_standing_queries( path_to_add, &mut files, &mut gitignores, @@ -1204,7 +1250,14 @@ impl LocalRepoMetadataModel { }, &mut standing_results, standing_query_definitions, - ) { + ); + log::error!( + "[watcher-diag] rebuilt subtree dir={} files={} elapsed_ms={}", + path_to_add.display(), + files.len(), + rebuild_start.elapsed().as_millis() + ); + match build_result { Ok(subtree) => { mutations.push(FileTreeMutation::AddDirectorySubtree { dir_path: path_to_add.clone(), From a5dec120c8b8c9251b8d796e23245449157c3ca3 Mon Sep 17 00:00:00 2001 From: Andy Carlson <2yinyang2@gmail.com> Date: Fri, 26 Jun 2026 21:27:14 -0700 Subject: [PATCH 2/8] chore(repo_metadata): remove temporary watcher-diag logging Removes the [watcher-diag] error-level instrumentation added to diagnose the rebuild storm; the fix in the previous commit stands on its own. Co-Authored-By: Oz --- crates/repo_metadata/src/local_model.rs | 39 ++----------------------- 1 file changed, 2 insertions(+), 37 deletions(-) diff --git a/crates/repo_metadata/src/local_model.rs b/crates/repo_metadata/src/local_model.rs index 43fdc9a34c..e78f184f41 100644 --- a/crates/repo_metadata/src/local_model.rs +++ b/crates/repo_metadata/src/local_model.rs @@ -543,24 +543,6 @@ impl LocalRepoMetadataModel { event: &BulkFilesystemWatcherEvent, ctx: &mut ModelContext, ) { - // Diagnostics: surface the volume and a sample of the changed paths that - // triggered this rebuild, so the source of watcher churn can be traced. - let added_count = event.added.len(); - let modified_count = event.modified.len(); - let deleted_count = event.deleted.len(); - let moved_count = event.moved.len(); - let total_count = added_count + modified_count + deleted_count + moved_count; - let sample = event - .added_or_updated_iter() - .chain(event.deleted.iter()) - .take(20) - .map(|path| path.display().to_string()) - .collect::>() - .join(", "); - log::error!( - "[watcher-diag] fs event: added={added_count} modified={modified_count} deleted={deleted_count} moved={moved_count} total={total_count} sample=[{sample}]" - ); - // Create a map to collect changes per repository let mut repo_updates: HashMap = HashMap::new(); let symlink_target_paths = self.add_symlink_target_updates(event, &mut repo_updates); @@ -603,12 +585,6 @@ impl LocalRepoMetadataModel { // Phase 1 (background thread): compute lightweight mutations via filesystem I/O. // Phase 2 (main thread callback): apply mutations directly to the tree — no clone needed. for (repo_path, repo_scoped_update) in repo_updates { - let n_added = repo_scoped_update.added.len(); - let n_deleted = repo_scoped_update.deleted.len(); - let n_moved = repo_scoped_update.moved.len(); - log::error!( - "[watcher-diag] repo={repo_path} input added={n_added} deleted={n_deleted} moved={n_moved}" - ); if let Some(IndexedRepoState::Indexed(state)) = self.repositories.get_mut(&repo_path) { let repo_path_clone = repo_path.clone(); let gitignores_clone = state.gitignores.clone(); @@ -1232,11 +1208,7 @@ impl LocalRepoMetadataModel { let mut files = Vec::new(); let mut gitignores = gitignores.to_owned(); let mut file_limit = MAX_FILES_PER_REPO; - // Diagnostics: time and size each full subtree rebuild so the - // expensive directories (the watcher-storm culprits) are - // identifiable. - let rebuild_start = std::time::Instant::now(); - let build_result = Entry::build_tree_with_standing_queries( + match Entry::build_tree_with_standing_queries( path_to_add, &mut files, &mut gitignores, @@ -1250,14 +1222,7 @@ impl LocalRepoMetadataModel { }, &mut standing_results, standing_query_definitions, - ); - log::error!( - "[watcher-diag] rebuilt subtree dir={} files={} elapsed_ms={}", - path_to_add.display(), - files.len(), - rebuild_start.elapsed().as_millis() - ); - match build_result { + ) { Ok(subtree) => { mutations.push(FileTreeMutation::AddDirectorySubtree { dir_path: path_to_add.clone(), From 0d73f92cc9be99e393af346f6af2dac1ad8b0c0f Mon Sep 17 00:00:00 2001 From: Andy Carlson <2yinyang2@gmail.com> Date: Sun, 28 Jun 2026 11:19:38 -0700 Subject: [PATCH 3/8] style: rustfmt import ordering in repo_metadata Co-Authored-By: Oz --- crates/repo_metadata/src/local_model.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/repo_metadata/src/local_model.rs b/crates/repo_metadata/src/local_model.rs index e78f184f41..f3da78e170 100644 --- a/crates/repo_metadata/src/local_model.rs +++ b/crates/repo_metadata/src/local_model.rs @@ -39,8 +39,8 @@ pub struct RepoContents<'a> { use warp_util::standardized_path::StandardizedPath; use crate::entry::{ - BudgetExceededBehavior, BuildTreeError, BuildTreeOptions, Entry, FileId, IgnoredPathStrategy, - matches_force_included_path, + matches_force_included_path, BudgetExceededBehavior, BuildTreeError, BuildTreeOptions, Entry, + FileId, IgnoredPathStrategy, }; use crate::repository::Repository; use crate::standing_queries::{ From e6f3ff8b4bf4f95d4f826f53651a0080b981936e Mon Sep 17 00:00:00 2001 From: Andy Carlson <2yinyang2@gmail.com> Date: Sun, 28 Jun 2026 12:50:12 -0700 Subject: [PATCH 4/8] remove explanation comment --- crates/repo_metadata/src/local_model.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/crates/repo_metadata/src/local_model.rs b/crates/repo_metadata/src/local_model.rs index f3da78e170..f8c2bba47f 100644 --- a/crates/repo_metadata/src/local_model.rs +++ b/crates/repo_metadata/src/local_model.rs @@ -1188,15 +1188,6 @@ impl LocalRepoMetadataModel { continue; } - // Gitignored directories (e.g. build output such as `target/`) - // are represented in the canonical tree as unloaded - // placeholders, exactly as the initial index does via - // `IgnoredPathStrategy::IncludeLazy`. Recursively walking them - // here would re-index enormous ignored subtrees (tens of - // thousands of files) on every filesystem event, which is the - // root cause of the watcher rebuild storm. Force-included paths - // (e.g. skill provider directories) must still be materialized - // even when they live under an ignored directory. if is_ignored && !matches_force_included_path(path_to_add, force_included_paths) { mutations.push(FileTreeMutation::AddUnloadedDirectory { path: path_to_add.clone(), From 5f77e8774edac802d88fae43c2297d12bac957ce Mon Sep 17 00:00:00 2001 From: Andy Carlson <2yinyang2@gmail.com> Date: Mon, 29 Jun 2026 15:44:41 -0700 Subject: [PATCH 5/8] fix(repo_metadata): classify force-included directories under an ignored parent as ignored in incremental watcher updates, matching the initial index --- crates/repo_metadata/src/entry.rs | 4 +- crates/repo_metadata/src/entry_tests.rs | 4 + crates/repo_metadata/src/local_model.rs | 3 + crates/repo_metadata/src/local_model_tests.rs | 126 +++++++++++++++++- 4 files changed, 133 insertions(+), 4 deletions(-) diff --git a/crates/repo_metadata/src/entry.rs b/crates/repo_metadata/src/entry.rs index e6aa89072e..4973052f5f 100644 --- a/crates/repo_metadata/src/entry.rs +++ b/crates/repo_metadata/src/entry.rs @@ -152,12 +152,14 @@ impl Entry { ) } /// Builds the materialized tree and standing results during the same filesystem traversal. + #[allow(clippy::too_many_arguments)] pub(crate) fn build_tree_with_standing_queries( path: impl Into, files: &mut Vec, gitignores: &mut Vec, remaining_file_quota: Option<&mut usize>, options: BuildTreeOptions<'_>, + ancestor_is_ignored: bool, standing_results: &mut StandingQueryResults, definitions: &StandingQueryDefinitions, ) -> Result { @@ -171,7 +173,7 @@ impl Entry { gitignores, remaining_file_quota, options, - false, + ancestor_is_ignored, Some(&mut standing_queries), ) } diff --git a/crates/repo_metadata/src/entry_tests.rs b/crates/repo_metadata/src/entry_tests.rs index a9659470e0..3a22491f45 100644 --- a/crates/repo_metadata/src/entry_tests.rs +++ b/crates/repo_metadata/src/entry_tests.rs @@ -394,6 +394,7 @@ fn standing_queries_report_skills_below_an_ignored_directory() { force_included_paths: &[std::path::PathBuf::from(".agents/skills")], budget_exceeded_behavior: super::BudgetExceededBehavior::StopAndLazyLoad, }, + false, &mut results, &definitions, ) @@ -448,6 +449,7 @@ fn standing_queries_report_symlinked_skills_without_materializing_symlinked_dire force_included_paths: &[], budget_exceeded_behavior: super::BudgetExceededBehavior::StopAndLazyLoad, }, + false, &mut results, &definitions, ) @@ -491,6 +493,7 @@ fn standing_queries_do_not_report_rules_below_an_unloaded_shallow_directory() { force_included_paths: &[], budget_exceeded_behavior: super::BudgetExceededBehavior::StopAndLazyLoad, }, + false, &mut results, &StandingQueryDefinitions::default(), ) @@ -543,6 +546,7 @@ fn shallow_tree_expands_force_included_skill_branch_only() { force_included_paths: &[std::path::PathBuf::from(".agents/skills")], budget_exceeded_behavior: super::BudgetExceededBehavior::StopAndLazyLoad, }, + false, &mut results, &definitions, ) diff --git a/crates/repo_metadata/src/local_model.rs b/crates/repo_metadata/src/local_model.rs index f8c2bba47f..c67f33aea8 100644 --- a/crates/repo_metadata/src/local_model.rs +++ b/crates/repo_metadata/src/local_model.rs @@ -930,6 +930,7 @@ impl LocalRepoMetadataModel { force_included_paths: &self.force_included_paths, budget_exceeded_behavior: BudgetExceededBehavior::StopAndLazyLoad, }, + false, &mut standing_results, &self.standing_query_definitions, ) @@ -1211,6 +1212,7 @@ impl LocalRepoMetadataModel { force_included_paths, budget_exceeded_behavior: BudgetExceededBehavior::StopAndLazyLoad, }, + is_ignored, &mut standing_results, standing_query_definitions, ) { @@ -1554,6 +1556,7 @@ impl LocalRepoMetadataModel { force_included_paths: &force_included_paths, budget_exceeded_behavior: BudgetExceededBehavior::StopAndLazyLoad, }, + false, &mut standing_results, &standing_query_definitions, ); diff --git a/crates/repo_metadata/src/local_model_tests.rs b/crates/repo_metadata/src/local_model_tests.rs index 16e6149930..06603dd8c7 100644 --- a/crates/repo_metadata/src/local_model_tests.rs +++ b/crates/repo_metadata/src/local_model_tests.rs @@ -17,11 +17,14 @@ use warpui_core::App; #[cfg(feature = "local_fs")] use watcher::BulkFilesystemWatcherEvent; -use crate::entry::{DirectoryEntry, Entry, FileMetadata}; +use crate::entry::{ + BudgetExceededBehavior, BuildTreeOptions, DirectoryEntry, Entry, FileMetadata, + IgnoredPathStrategy, +}; use crate::file_tree_store::{FileTreeEntry, FileTreeEntryState, FileTreeState}; use crate::local_model::{ - GetContentsArgs, IndexedRepoState, LocalRepoMetadataModel, RepoUpdate, RepositoryMetadataEvent, - RootWatchMode, + FileTreeMutation, GetContentsArgs, IndexedRepoState, LocalRepoMetadataModel, RepoUpdate, + RepositoryMetadataEvent, RootWatchMode, }; use crate::repositories::DetectedRepositories; use crate::watcher::DirectoryWatcher; @@ -2276,6 +2279,123 @@ fn recursive_repo_uses_recursive_watch_mode() { }); } +#[test] +fn incremental_force_included_dir_under_ignored_parent_matches_initial_index() { + fn find_entry<'a>(entry: &'a Entry, target: &StandardizedPath) -> Option<&'a Entry> { + if entry.path() == target { + return Some(entry); + } + if let Entry::Directory(dir) = entry { + for child in &dir.children { + if let Some(found) = find_entry(child, target) { + return Some(found); + } + } + } + None + } + + VirtualFS::test( + "incremental_force_included_under_ignored_parent", + |dirs, mut vfs| { + // `.agents/` is ignored by the repo-root .gitignore; `.agents/skills` + // is force-included, so it is ignored only because of its ancestor. + vfs.mkdir("repo/.agents/skills").with_files(vec![ + Stub::FileWithContent("repo/.gitignore", ".agents/\n"), + Stub::FileWithContent("repo/.agents/skills/SKILL.md", "skill"), + ]); + + let repo_local = dirs.tests().join("repo"); + let skills_local = repo_local.join(".agents").join("skills"); + + let force_included = vec![PathBuf::from(".agents/skills")]; + let gitignores = crate::gitignores_for_directory(&repo_local); + let definitions = StandingQueryDefinitions::default(); + + // Ground truth: how the initial full index classifies `.agents/skills`. + // Mirrors `index_directory`, which builds from the repo root with + // `IncludeLazy` + force-included paths so the ignored `.agents` + // ancestor propagates down into `.agents/skills`. + let expected_ignored = { + let mut files = Vec::new(); + let mut gitignores = gitignores.clone(); + let mut budget = 100_000usize; + let mut standing_results = crate::StandingQueryResults::default(); + let root = Entry::build_tree_with_standing_queries( + &repo_local, + &mut files, + &mut gitignores, + Some(&mut budget), + BuildTreeOptions { + max_depth: 64, + current_depth: 0, + ignored_path_strategy: &IgnoredPathStrategy::IncludeLazy, + force_included_paths: &force_included, + budget_exceeded_behavior: BudgetExceededBehavior::StopAndLazyLoad, + }, + false, + &mut standing_results, + &definitions, + ) + .expect("initial index build should succeed"); + + let skills_canonical = + dunce::canonicalize(&skills_local).expect("skills dir should exist"); + let skills_node_path = + StandardizedPath::from_local_absolute_unchecked(&skills_canonical); + find_entry(&root, &skills_node_path) + .expect("`.agents/skills` should be materialized by the initial index") + .ignored() + }; + + assert!( + expected_ignored, + "fixture sanity: the initial index should mark `.agents/skills` ignored \ + via its `.agents` ancestor" + ); + + // Incremental watcher path: `.agents/skills` is reported as added. + let update = RepoUpdate { + added: vec![skills_local.clone()], + ..Default::default() + }; + let (mutations, _standing_results, _removed) = + block_on(LocalRepoMetadataModel::compute_file_tree_mutations( + &update, + &gitignores, + &force_included, + &definitions, + false, /* lazy_load */ + )); + + let incremental_ignored = mutations + .iter() + .find_map(|mutation| match mutation { + FileTreeMutation::AddDirectorySubtree { dir_path, subtree } + if dir_path == &skills_local => + { + Some(subtree.ignored()) + } + FileTreeMutation::AddDirectorySubtree { .. } + | FileTreeMutation::Remove(_) + | FileTreeMutation::AddFile { .. } + | FileTreeMutation::AddUnloadedDirectory { .. } => None, + }) + .expect( + "incremental update should materialize the force-included subtree \ + as an AddDirectorySubtree mutation", + ); + + assert_eq!( + incremental_ignored, expected_ignored, + "force-included `.agents/skills` under ignored `.agents`: incremental watcher \ + update recorded ignored={incremental_ignored}, but the initial index records \ + ignored={expected_ignored}" + ); + }, + ); +} + /// Expanding a gitignored directory inside a git repo registers an on-demand /// non-recursive watch for it on Linux (where the recursive root watch prunes /// gitignored dirs), while other platforms rely on the recursive root watch. From 2f9ae09e6b1d50792609f5eda127d90ad3c44081 Mon Sep 17 00:00:00 2001 From: Andy Carlson <2yinyang2@gmail.com> Date: Mon, 29 Jun 2026 19:25:26 -0700 Subject: [PATCH 6/8] switch to storing the main ignored ancestor --- crates/repo_metadata/src/local_model.rs | 50 +++++++++++++++++-- crates/repo_metadata/src/local_model_tests.rs | 49 ++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/crates/repo_metadata/src/local_model.rs b/crates/repo_metadata/src/local_model.rs index c67f33aea8..eb588e61e5 100644 --- a/crates/repo_metadata/src/local_model.rs +++ b/crates/repo_metadata/src/local_model.rs @@ -4,9 +4,7 @@ //! This module provides a singleton model that manages repository metadata across //! all repositories tracked by Warp. -use std::collections::HashMap; -#[cfg(feature = "local_fs")] -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -1153,6 +1151,9 @@ impl LocalRepoMetadataModel { let mut mutations = Vec::new(); let mut standing_results = StandingQueryResults::default(); let mut removed_roots = Vec::new(); + // Tracks ignored-root placeholders already emitted in this batch so a burst of events under + // the same ignored directory collapses to one entry. + let mut emitted_ignored_roots: HashSet = HashSet::new(); // Removals for deleted and moved-from paths for path_to_remove in update.deleted.iter().chain(update.moved.values()) { @@ -1176,6 +1177,22 @@ impl LocalRepoMetadataModel { let is_ignored = Self::path_is_ignored(path_to_add, gitignores); + // A path that is ignored because of an ancestor directory is represented as single + // unloaded placeholder at the topmost ignored ancestor, with nothing below it materialized. + if !lazy_load && is_ignored { + if let Some(ignored_root) = + Self::topmost_ignored_ancestor(path_to_add, gitignores, force_included_paths) + { + if emitted_ignored_roots.insert(ignored_root.clone()) { + mutations.push(FileTreeMutation::AddUnloadedDirectory { + path: ignored_root, + is_ignored: true, + }); + } + continue; + } + } + if path_to_add.is_dir() { if lazy_load { // Lazy (non-git) roots are not materialized when a directory @@ -1443,6 +1460,33 @@ impl LocalRepoMetadataModel { matches_gitignores(path, is_dir, gitignores, true) } + /// Returns the shallowest ancestor directory of `path` that is gitignored (and not + /// force-included), or `None` when `path`'s parent is not an ignored, non-force-included + /// directory. + fn topmost_ignored_ancestor( + path: &Path, + gitignores: &[Gitignore], + force_included_paths: &[PathBuf], + ) -> Option { + let parent = path.parent()?; + if !Self::path_is_ignored(parent, gitignores) + || matches_force_included_path(parent, force_included_paths) + { + return None; + } + let mut ancestor = parent; + while let Some(next) = ancestor.parent() { + if Self::path_is_ignored(next, gitignores) + && !matches_force_included_path(next, force_included_paths) + { + ancestor = next; + } else { + break; + } + } + Some(ancestor.to_path_buf()) + } + /// Fully indexes a local directory after registering it with the directory watcher. #[cfg(feature = "local_fs")] pub fn index_directory_path( diff --git a/crates/repo_metadata/src/local_model_tests.rs b/crates/repo_metadata/src/local_model_tests.rs index 06603dd8c7..ba07350e26 100644 --- a/crates/repo_metadata/src/local_model_tests.rs +++ b/crates/repo_metadata/src/local_model_tests.rs @@ -2396,6 +2396,55 @@ fn incremental_force_included_dir_under_ignored_parent_matches_initial_index() { ); } +/// A deep event under a gitignored directory (e.g. `target/debug/.fingerprint`) collapses to a +/// single unloaded placeholder at the topmost ignored ancestor (`target`). +#[test] +fn incremental_deep_ignored_path_collapses_to_topmost_ignored_ancestor() { + VirtualFS::test( + "incremental_deep_ignored_collapses_to_ignored_root", + |dirs, mut vfs| { + vfs.mkdir("repo/target/debug/.fingerprint") + .with_files(vec![Stub::FileWithContent("repo/.gitignore", "target/\n")]); + + let repo_local = dirs.tests().join("repo"); + let deep_local = repo_local.join("target").join("debug").join(".fingerprint"); + let target_local = repo_local.join("target"); + + let gitignores = crate::gitignores_for_directory(&repo_local); + let definitions = StandingQueryDefinitions::default(); + + let update = RepoUpdate { + added: vec![deep_local], + ..Default::default() + }; + let (mutations, _standing_results, _removed) = + block_on(LocalRepoMetadataModel::compute_file_tree_mutations( + &update, + &gitignores, + &[], /* force_included_paths */ + &definitions, + false, /* lazy_load */ + )); + + assert_eq!( + mutations.len(), + 1, + "deep ignored event should collapse to one placeholder, got {mutations:?}" + ); + match &mutations[0] { + FileTreeMutation::AddUnloadedDirectory { path, is_ignored } => { + assert_eq!( + path, &target_local, + "placeholder should sit at the topmost ignored ancestor `target`" + ); + assert!(*is_ignored, "the ignored root placeholder must be ignored"); + } + other => panic!("expected AddUnloadedDirectory at `target`, got {other:?}"), + } + }, + ); +} + /// Expanding a gitignored directory inside a git repo registers an on-demand /// non-recursive watch for it on Linux (where the recursive root watch prunes /// gitignored dirs), while other platforms rely on the recursive root watch. From d0260af03931e4781e72b9b46f973c5e95c194d1 Mon Sep 17 00:00:00 2001 From: Andy Carlson <2yinyang2@gmail.com> Date: Mon, 29 Jun 2026 20:50:09 -0700 Subject: [PATCH 7/8] make sure expanded folders stay expanded --- crates/repo_metadata/src/local_model.rs | 71 +++------ crates/repo_metadata/src/local_model_tests.rs | 145 +++++++++++++++--- 2 files changed, 147 insertions(+), 69 deletions(-) diff --git a/crates/repo_metadata/src/local_model.rs b/crates/repo_metadata/src/local_model.rs index eb588e61e5..a97780c585 100644 --- a/crates/repo_metadata/src/local_model.rs +++ b/crates/repo_metadata/src/local_model.rs @@ -4,7 +4,9 @@ //! This module provides a singleton model that manages repository metadata across //! all repositories tracked by Warp. -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; +#[cfg(feature = "local_fs")] +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -1151,9 +1153,6 @@ impl LocalRepoMetadataModel { let mut mutations = Vec::new(); let mut standing_results = StandingQueryResults::default(); let mut removed_roots = Vec::new(); - // Tracks ignored-root placeholders already emitted in this batch so a burst of events under - // the same ignored directory collapses to one entry. - let mut emitted_ignored_roots: HashSet = HashSet::new(); // Removals for deleted and moved-from paths for path_to_remove in update.deleted.iter().chain(update.moved.values()) { @@ -1177,22 +1176,6 @@ impl LocalRepoMetadataModel { let is_ignored = Self::path_is_ignored(path_to_add, gitignores); - // A path that is ignored because of an ancestor directory is represented as single - // unloaded placeholder at the topmost ignored ancestor, with nothing below it materialized. - if !lazy_load && is_ignored { - if let Some(ignored_root) = - Self::topmost_ignored_ancestor(path_to_add, gitignores, force_included_paths) - { - if emitted_ignored_roots.insert(ignored_root.clone()) { - mutations.push(FileTreeMutation::AddUnloadedDirectory { - path: ignored_root, - is_ignored: true, - }); - } - continue; - } - } - if path_to_add.is_dir() { if lazy_load { // Lazy (non-git) roots are not materialized when a directory @@ -1314,7 +1297,12 @@ impl LocalRepoMetadataModel { let Some(std_path) = StandardizedPath::try_from_local(path).ok() else { continue; }; - if lazy_load && !Self::is_parent_loaded_in_entry(root_entry, &std_path) { + // Gitignored entries are lazy: like `lazy_load`, don't materialize one + // beneath an unloaded (collapsed) ignored ancestor. The ignored root keeps a + // loaded parent, so it is still created. + if (lazy_load || is_ignored) + && !Self::is_parent_loaded_in_entry(root_entry, &std_path) + { continue; } let Some(parent) = std_path.parent() else { @@ -1391,7 +1379,19 @@ impl LocalRepoMetadataModel { let Some(std_path) = StandardizedPath::try_from_local(path).ok() else { continue; }; - if lazy_load && !Self::is_parent_loaded_in_entry(root_entry, &std_path) { + // Never downgrade a directory the user has already expanded back to an + // unloaded placeholder (e.g. a self-event on a loaded gitignored dir). + if matches!( + root_entry.get(&std_path), + Some(FileTreeEntryState::Directory(dir)) if dir.loaded + ) { + continue; + } + // Gitignored placeholders are lazy: like `lazy_load`, don't materialize one + // beneath an unloaded (collapsed) ignored ancestor. + if (lazy_load || is_ignored) + && !Self::is_parent_loaded_in_entry(root_entry, &std_path) + { continue; } let Some(parent) = std_path.parent() else { @@ -1460,33 +1460,6 @@ impl LocalRepoMetadataModel { matches_gitignores(path, is_dir, gitignores, true) } - /// Returns the shallowest ancestor directory of `path` that is gitignored (and not - /// force-included), or `None` when `path`'s parent is not an ignored, non-force-included - /// directory. - fn topmost_ignored_ancestor( - path: &Path, - gitignores: &[Gitignore], - force_included_paths: &[PathBuf], - ) -> Option { - let parent = path.parent()?; - if !Self::path_is_ignored(parent, gitignores) - || matches_force_included_path(parent, force_included_paths) - { - return None; - } - let mut ancestor = parent; - while let Some(next) = ancestor.parent() { - if Self::path_is_ignored(next, gitignores) - && !matches_force_included_path(next, force_included_paths) - { - ancestor = next; - } else { - break; - } - } - Some(ancestor.to_path_buf()) - } - /// Fully indexes a local directory after registering it with the directory watcher. #[cfg(feature = "local_fs")] pub fn index_directory_path( diff --git a/crates/repo_metadata/src/local_model_tests.rs b/crates/repo_metadata/src/local_model_tests.rs index ba07350e26..d80fe8342e 100644 --- a/crates/repo_metadata/src/local_model_tests.rs +++ b/crates/repo_metadata/src/local_model_tests.rs @@ -2396,25 +2396,47 @@ fn incremental_force_included_dir_under_ignored_parent_matches_initial_index() { ); } -/// A deep event under a gitignored directory (e.g. `target/debug/.fingerprint`) collapses to a -/// single unloaded placeholder at the topmost ignored ancestor (`target`). +/// A filesystem event deep under an UNLOADED (collapsed) gitignored directory is +/// dropped at apply time, so nothing below the unloaded placeholder is +/// materialized — matching the initial index's single-placeholder representation. #[test] -fn incremental_deep_ignored_path_collapses_to_topmost_ignored_ancestor() { +fn incremental_deep_event_under_unloaded_ignored_dir_is_collapsed() { VirtualFS::test( - "incremental_deep_ignored_collapses_to_ignored_root", + "incremental_deep_event_under_unloaded_ignored_dir", |dirs, mut vfs| { - vfs.mkdir("repo/target/debug/.fingerprint") - .with_files(vec![Stub::FileWithContent("repo/.gitignore", "target/\n")]); + vfs.mkdir("repo/target/debug/.fingerprint").with_files(vec![ + Stub::FileWithContent("repo/.gitignore", "target/\n"), + Stub::FileWithContent("repo/target/debug/.fingerprint/x.json", "{}"), + ]); let repo_local = dirs.tests().join("repo"); - let deep_local = repo_local.join("target").join("debug").join(".fingerprint"); let target_local = repo_local.join("target"); + let deep_dir_local = target_local.join("debug").join(".fingerprint"); + let deep_file_local = deep_dir_local.join("x.json"); + + let repo_std = StandardizedPath::try_from_local(&repo_local).unwrap(); + let target_std = StandardizedPath::try_from_local(&target_local).unwrap(); + let debug_std = StandardizedPath::try_from_local(&target_local.join("debug")).unwrap(); + + // Post-initial-index state: `target` is a single UNLOADED ignored placeholder. + let root_entry = Entry::Directory(DirectoryEntry { + path: repo_std, + ignored: false, + loaded: true, + children: vec![Entry::Directory(DirectoryEntry { + path: target_std.clone(), + ignored: true, + loaded: false, + children: vec![], + })], + }); + let mut tree = FileTreeEntry::from(root_entry); let gitignores = crate::gitignores_for_directory(&repo_local); let definitions = StandingQueryDefinitions::default(); let update = RepoUpdate { - added: vec![deep_local], + added: vec![deep_dir_local, deep_file_local], ..Default::default() }; let (mutations, _standing_results, _removed) = @@ -2425,22 +2447,105 @@ fn incremental_deep_ignored_path_collapses_to_topmost_ignored_ancestor() { &definitions, false, /* lazy_load */ )); - - assert_eq!( - mutations.len(), - 1, - "deep ignored event should collapse to one placeholder, got {mutations:?}" + LocalRepoMetadataModel::apply_file_tree_mutations(&mut tree, mutations, false, false); + + // `target` stays a single unloaded placeholder; nothing below it is materialized. + match tree + .get(&target_std) + .expect("`target` should remain in the tree") + { + FileTreeEntryState::Directory(directory) => { + assert!( + !directory.loaded, + "`target` should remain an unloaded placeholder" + ); + assert!(directory.ignored, "`target` should stay ignored"); + } + FileTreeEntryState::File(_) => panic!("`target` should be a directory"), + } + assert!( + tree.get(&debug_std).is_none(), + "nothing below the unloaded `target` placeholder should be materialized" ); - match &mutations[0] { - FileTreeMutation::AddUnloadedDirectory { path, is_ignored } => { - assert_eq!( - path, &target_local, - "placeholder should sit at the topmost ignored ancestor `target`" + }, + ); +} + +/// A filesystem event under a gitignored directory the user has already +/// expanded (so it is `loaded`) must keep that directory loaded. +#[test] +fn incremental_event_under_expanded_ignored_dir_keeps_it_loaded() { + VirtualFS::test( + "incremental_event_under_expanded_ignored_dir", + |dirs, mut vfs| { + vfs.mkdir("repo/target/debug").with_files(vec![ + Stub::FileWithContent("repo/.gitignore", "target/\n"), + Stub::FileWithContent("repo/target/debug/new.rs", "x"), + ]); + + let repo_local = dirs.tests().join("repo"); + let target_local = repo_local.join("target"); + let new_file_local = target_local.join("debug").join("new.rs"); + + let repo_std = StandardizedPath::try_from_local(&repo_local).unwrap(); + let target_std = StandardizedPath::try_from_local(&target_local).unwrap(); + let debug_std = StandardizedPath::try_from_local(&target_local.join("debug")).unwrap(); + let new_file_std = StandardizedPath::try_from_local(&new_file_local).unwrap(); + + // The user has expanded the gitignored `target/`, so it is loaded. + let root_entry = Entry::Directory(DirectoryEntry { + path: repo_std, + ignored: false, + loaded: true, + children: vec![Entry::Directory(DirectoryEntry { + path: target_std.clone(), + ignored: true, + loaded: true, + children: vec![Entry::Directory(DirectoryEntry { + path: debug_std, + ignored: true, + loaded: true, + children: vec![], + })], + })], + }); + let mut tree = FileTreeEntry::from(root_entry); + + let gitignores = crate::gitignores_for_directory(&repo_local); + let definitions = StandingQueryDefinitions::default(); + + let update = RepoUpdate { + added: vec![new_file_local], + ..Default::default() + }; + let (mutations, _standing_results, _removed) = + block_on(LocalRepoMetadataModel::compute_file_tree_mutations( + &update, + &gitignores, + &[], /* force_included_paths */ + &definitions, + false, /* lazy_load */ + )); + LocalRepoMetadataModel::apply_file_tree_mutations(&mut tree, mutations, false, false); + + match tree + .get(&target_std) + .expect("expanded `target` should remain in the tree") + { + FileTreeEntryState::Directory(directory) => { + assert!( + directory.loaded, + "an event under an expanded ignored dir must not collapse it to an \ + unloaded placeholder" ); - assert!(*is_ignored, "the ignored root placeholder must be ignored"); + assert!(directory.ignored, "`target` should still be ignored"); } - other => panic!("expected AddUnloadedDirectory at `target`, got {other:?}"), + FileTreeEntryState::File(_) => panic!("`target` should be a directory"), } + assert!( + tree.get(&new_file_std).is_some(), + "the new file under the expanded ignored dir should be delivered" + ); }, ); } From 6a1550819ee19eb637bce43dbf36ee6ae86a19d8 Mon Sep 17 00:00:00 2001 From: Andy Carlson <2yinyang2@gmail.com> Date: Tue, 30 Jun 2026 12:12:09 -0700 Subject: [PATCH 8/8] clean up noise --- crates/repo_metadata/src/local_model.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/repo_metadata/src/local_model.rs b/crates/repo_metadata/src/local_model.rs index a97780c585..1888236d3d 100644 --- a/crates/repo_metadata/src/local_model.rs +++ b/crates/repo_metadata/src/local_model.rs @@ -1297,9 +1297,6 @@ impl LocalRepoMetadataModel { let Some(std_path) = StandardizedPath::try_from_local(path).ok() else { continue; }; - // Gitignored entries are lazy: like `lazy_load`, don't materialize one - // beneath an unloaded (collapsed) ignored ancestor. The ignored root keeps a - // loaded parent, so it is still created. if (lazy_load || is_ignored) && !Self::is_parent_loaded_in_entry(root_entry, &std_path) { @@ -1379,8 +1376,6 @@ impl LocalRepoMetadataModel { let Some(std_path) = StandardizedPath::try_from_local(path).ok() else { continue; }; - // Never downgrade a directory the user has already expanded back to an - // unloaded placeholder (e.g. a self-event on a loaded gitignored dir). if matches!( root_entry.get(&std_path), Some(FileTreeEntryState::Directory(dir)) if dir.loaded