diff --git a/app/src/lib.rs b/app/src/lib.rs index eebccadb65..64729d3c9b 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -1508,6 +1508,18 @@ pub(crate) fn initialize_app( #[cfg(not(target_family = "wasm"))] { ctx.add_singleton_model(DirectoryWatcher::new); + // Register the skill provider directories as force-included paths so + // the gitignore-pruning watch descend filter still watches gitignored + // skill directories (e.g. `.agents/skills`) for `Repository` + // subscribers (LSP, MCP). Registered before any repository begins + // watching so it gates descent on the very first registration. + DirectoryWatcher::handle(ctx).update(ctx, |watcher, _| { + watcher.register_force_included_paths( + ::ai::skills::SKILL_PROVIDER_DEFINITIONS + .iter() + .map(|provider| provider.skills_path.clone()), + ); + }); ctx.add_singleton_model(|_| DetectedRepositories::default()); if let Some(home_dir) = dirs::home_dir() { ctx.add_singleton_model(|ctx| HomeDirectoryWatcher::new(home_dir, ctx)); @@ -1533,7 +1545,7 @@ pub(crate) fn initialize_app( } else { RepoMetadataModel::new(ctx) }; - model.register_ignored_path_interests( + model.register_force_included_paths( ::ai::skills::SKILL_PROVIDER_DEFINITIONS .iter() .map(|provider| provider.skills_path.clone()), diff --git a/crates/repo_metadata/src/entry.rs b/crates/repo_metadata/src/entry.rs index 0f130707f7..2767678db3 100644 --- a/crates/repo_metadata/src/entry.rs +++ b/crates/repo_metadata/src/entry.rs @@ -73,7 +73,7 @@ pub(crate) struct BuildTreeOptions<'a> { pub max_depth: usize, pub current_depth: usize, pub ignored_path_strategy: &'a IgnoredPathStrategy, - pub ignored_path_interests: &'a [PathBuf], + pub force_included_paths: &'a [PathBuf], pub budget_exceeded_behavior: BudgetExceededBehavior, } @@ -128,7 +128,7 @@ impl Entry { ignored_path_strategy: &IgnoredPathStrategy, budget_exceeded_behavior: BudgetExceededBehavior, ) -> Result { - Self::build_tree_with_ignored_path_interests_and_ancestor( + Self::build_tree_with_force_included_paths_and_ancestor( path, files, gitignores, @@ -137,23 +137,24 @@ impl Entry { max_depth, current_depth, ignored_path_strategy, - ignored_path_interests: &[], + force_included_paths: &[], budget_exceeded_behavior, }, false, ) } - /// Builds a tree of entries from a given path, loading ignored paths that match - /// one of the supplied component-sequence interests instead of leaving them lazy. - pub(crate) fn build_tree_with_ignored_path_interests( + /// Builds a tree of entries from a given path, eagerly loading any path that + /// matches one of the supplied force-included paths instead of leaving it + /// lazy (see [`BuildTreeOptions::force_included_paths`]). + pub(crate) fn build_tree_with_force_included_paths( path: impl Into, files: &mut Vec, gitignores: &mut Vec, remaining_file_quota: Option<&mut usize>, options: BuildTreeOptions<'_>, ) -> Result { - Self::build_tree_with_ignored_path_interests_and_ancestor( + Self::build_tree_with_force_included_paths_and_ancestor( path, files, gitignores, @@ -174,7 +175,7 @@ impl Entry { ignored_path_strategy: &IgnoredPathStrategy, ancestor_is_ignored: bool, ) -> Result { - Self::build_tree_with_ignored_path_interests_and_ancestor( + Self::build_tree_with_force_included_paths_and_ancestor( path, files, gitignores, @@ -183,7 +184,7 @@ impl Entry { max_depth, current_depth, ignored_path_strategy, - ignored_path_interests: &[], + force_included_paths: &[], budget_exceeded_behavior: BudgetExceededBehavior::StopAndLazyLoad, }, ancestor_is_ignored, @@ -191,7 +192,7 @@ impl Entry { } #[allow(clippy::too_many_arguments)] - pub(crate) fn build_tree_with_ignored_path_interests_and_ancestor( + pub(crate) fn build_tree_with_force_included_paths_and_ancestor( path: impl Into, files: &mut Vec, gitignores: &mut Vec, @@ -257,18 +258,17 @@ impl Entry { // Budget handling. With `StopAndLazyLoad` (the default), once // the file quota is exhausted we stop expanding directories // and leave them as unloaded placeholders; directories on the - // path to a registered ignored-path interest (e.g. skill - // provider directories) are always expanded so - // discovery-critical files stay reachable. With `FailFast` we - // keep descending and abort below as soon as a file would - // exceed the budget. + // path to a force-included path (e.g. skill provider + // directories) are always expanded so discovery-critical + // files stay reachable. With `FailFast` we keep descending + // and abort below as soon as a file would exceed the budget. let should_expand = match options.budget_exceeded_behavior { BudgetExceededBehavior::FailFast => true, BudgetExceededBehavior::StopAndLazyLoad => { quota.is_none_or(|remaining| remaining > 0) - || matches_ignored_path_interest( + || matches_force_included_path( &job.path, - options.ignored_path_interests, + options.force_included_paths, ) } }; @@ -346,9 +346,9 @@ impl Entry { })); push_child(&mut nodes, job.index, child_index); // Lazy directories (past max depth, or ignored - // without a matching interest) stay unloaded. - // Everything else is queued for expansion, - // subject to the budget gate above. + // without a matching force-included path) stay + // unloaded. Everything else is queued for + // expansion, subject to the budget gate above. if !lazy { queue.push_back(DirJob { index: child_index, @@ -541,7 +541,7 @@ fn evaluate_entry( } } IgnoredPathStrategy::IncludeLazy => { - lazy = !matches_ignored_path_interest(curr_path, options.ignored_path_interests); + lazy = !matches_force_included_path(curr_path, options.force_included_paths); } IgnoredPathStrategy::Include => {} } @@ -629,7 +629,11 @@ pub fn is_git_internal_path(path: &Path) -> bool { }) } -fn matches_ignored_path_interest(path: &Path, ignored_path_interests: &[PathBuf]) -> bool { +/// Returns `true` when `path` is, contains, or lies on the way to one of the +/// `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 { let path_components: Vec<_> = path .components() .filter_map(|component| match component { @@ -641,8 +645,8 @@ fn matches_ignored_path_interest(path: &Path, ignored_path_interests: &[PathBuf] }) .collect(); - ignored_path_interests.iter().any(|interest| { - let interest_components: Vec<_> = interest + force_included_paths.iter().any(|force_included| { + let force_included_components: Vec<_> = force_included .components() .filter_map(|component| match component { Component::Normal(name) => Some(name), @@ -653,21 +657,21 @@ fn matches_ignored_path_interest(path: &Path, ignored_path_interests: &[PathBuf] }) .collect(); - if interest_components.is_empty() { + if force_included_components.is_empty() { return false; } if path_components - .windows(interest_components.len()) - .any(|window| window == interest_components.as_slice()) + .windows(force_included_components.len()) + .any(|window| window == force_included_components.as_slice()) { return true; } - (1..interest_components.len()).any(|prefix_len| { + (1..force_included_components.len()).any(|prefix_len| { path_components.len() >= prefix_len && path_components[path_components.len() - prefix_len..] - == interest_components[..prefix_len] + == force_included_components[..prefix_len] }) }) } @@ -910,21 +914,63 @@ fn descend_allowlist_matches(suffix: &[Component<'_>]) -> bool { } } +/// Returns whether a repository file watcher should descend into (and register +/// a watch on) the directory at `path`. +/// +/// Directories inside `.git/` follow the watcher allowlist, force-included +/// paths are always watched even when gitignored, and any other gitignored +/// directory is pruned so we don't register watches on `node_modules`, build +/// output, vendored deps, etc. +pub fn should_watch_repo_directory( + path: &Path, + gitignores: &[Gitignore], + force_included_paths: &[PathBuf], +) -> bool { + if is_git_internal_path(path) { + return should_watch_directory_in_git_path(path); + } + + if matches_force_included_path(path, force_included_paths) { + return true; + } + + !matches_gitignores( + path, + path.is_dir(), + gitignores, + /* check_ancestors */ true, + ) +} + /// Returns the [`WatchFilter`] used by repository file watchers. /// /// Emit predicate: forwards events for everything outside `.git/` plus the /// allowlisted files inside `.git/` (HEAD, refs/heads/*, index.lock, /// config, config.worktree, refs/remotes//*, and worktree equivalents). +/// Gitignored files that live directly in a watched (non-ignored) directory +/// are still emitted here and tagged `is_ignored` downstream, preserving +/// existing behavior. +/// +/// Descend predicate: see [`should_watch_repo_directory`]. In addition to the +/// `.git/` allowlist, it prunes gitignored directories (honoring registered +/// force-included paths) so the recursive walk does not register watches on +/// gitignored subtrees. /// -/// Descend predicate: prunes `.git/objects/`, `.git/hooks/`, `.git/logs/`, -/// `.git/info/`, `.git/lfs/`, etc. so the recursive walk does not register -/// watches on those subtrees, but still descends into `.git/`, -/// `.git/refs/heads/`, `.git/refs/remotes//`, and `.git/worktrees//` -/// so the allowlisted children remain reachable on Linux. +/// `gitignores` should be the repo's root + global gitignores (as produced by +/// [`gitignores_for_directory`]), matching `Repository::check_gitignore_status` +/// so descend decisions and the downstream `is_ignored` tagging stay +/// consistent. Nested per-directory `.gitignore` files are not consulted here +/// (same limitation as the existing tagging), which can only cause us to +/// over-watch, never to miss events. #[cfg(feature = "local_fs")] -pub fn repo_watch_filter() -> WatchFilter { +pub fn repo_watch_filter( + gitignores: Vec, + force_included_paths: Vec, +) -> WatchFilter { + let should_watch = + move |path: &Path| should_watch_repo_directory(path, &gitignores, &force_included_paths); WatchFilter::with_filter( - Arc::new(should_watch_directory_in_git_path), + Arc::new(should_watch), Arc::new(|path: &Path| !should_ignore_git_path(path)), ) } diff --git a/crates/repo_metadata/src/entry_tests.rs b/crates/repo_metadata/src/entry_tests.rs index e104f4edd7..38d7757e09 100644 --- a/crates/repo_metadata/src/entry_tests.rs +++ b/crates/repo_metadata/src/entry_tests.rs @@ -2,7 +2,7 @@ use std::fs; use ignore::gitignore::Gitignore; -use super::{Entry, IgnoredPathStrategy}; +use super::{matches_gitignores, Entry, IgnoredPathStrategy}; #[test] fn test_git_path_filtering_allowlist() { use std::path::Path; @@ -173,6 +173,161 @@ fn test_git_path_filtering_allowlist() { } } +/// Writes a `.gitignore` with `content` at `root` and returns a [`Gitignore`] +/// rooted there. Uses only the repo-root gitignore (not the machine's global +/// gitignore) so tests are deterministic. +fn gitignore_rooted(root: &std::path::Path, content: &str) -> Gitignore { + fs::write(root.join(".gitignore"), content).unwrap(); + let (gitignore, _) = Gitignore::new(root.join(".gitignore")); + gitignore +} + +#[test] +fn should_watch_prunes_gitignored_directory() { + let temp_dir = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(temp_dir.path()).unwrap(); + fs::create_dir(root.join("node_modules")).unwrap(); + fs::create_dir(root.join("src")).unwrap(); + let gitignores = vec![gitignore_rooted(&root, "node_modules/\n")]; + + // Root and non-ignored dirs are watched; the gitignored dir is pruned. + assert!(super::should_watch_repo_directory(&root, &gitignores, &[])); + assert!(super::should_watch_repo_directory( + &root.join("src"), + &gitignores, + &[] + )); + assert!(!super::should_watch_repo_directory( + &root.join("node_modules"), + &gitignores, + &[] + )); + // Descendants of an ignored dir are also pruned (ancestor-aware), which is + // what preserves the watcher's monotonicity invariant. + assert!(!super::should_watch_repo_directory( + &root.join("node_modules/foo"), + &gitignores, + &[] + )); +} + +#[test] +fn should_watch_descends_to_force_included_under_ignored_ancestor() { + let temp_dir = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(temp_dir.path()).unwrap(); + fs::create_dir_all(root.join(".agents/skills/test")).unwrap(); + fs::create_dir(root.join(".agents/other")).unwrap(); + let gitignores = vec![gitignore_rooted(&root, ".agents/\n")]; + let force_included = vec![std::path::PathBuf::from(".agents/skills")]; + + // The whole `.agents` subtree is gitignored, but we still descend along the + // prefix to reach the force-included path, and into its subtree. + assert!(super::should_watch_repo_directory( + &root.join(".agents"), + &gitignores, + &force_included + )); + assert!(super::should_watch_repo_directory( + &root.join(".agents/skills"), + &gitignores, + &force_included + )); + assert!(super::should_watch_repo_directory( + &root.join(".agents/skills/test"), + &gitignores, + &force_included + )); + // A sibling ignored dir that is not force-included is still pruned. + assert!(!super::should_watch_repo_directory( + &root.join(".agents/other"), + &gitignores, + &force_included + )); +} + +#[test] +fn should_watch_handles_nested_ignored_ancestor_with_deeper_force_included() { + let temp_dir = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(temp_dir.path()).unwrap(); + fs::create_dir_all(root.join("a/b/c")).unwrap(); + fs::create_dir(root.join("a/b/other")).unwrap(); + let gitignores = vec![gitignore_rooted(&root, "a/b/\n")]; + let force_included = vec![std::path::PathBuf::from("a/b/c")]; + + // `a/b` is ignored but `a/b/c` is force-included: descend along the whole + // prefix and into it, while pruning the ignored sibling. + assert!(super::should_watch_repo_directory( + &root.join("a"), + &gitignores, + &force_included + )); + assert!(super::should_watch_repo_directory( + &root.join("a/b"), + &gitignores, + &force_included + )); + assert!(super::should_watch_repo_directory( + &root.join("a/b/c"), + &gitignores, + &force_included + )); + assert!(!super::should_watch_repo_directory( + &root.join("a/b/other"), + &gitignores, + &force_included + )); +} + +#[test] +fn should_watch_descends_dir_only_reinclude_negation() { + let temp_dir = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(temp_dir.path()).unwrap(); + fs::create_dir_all(root.join("parentdir/sub")).unwrap(); + fs::write(root.join("parentdir/loose.txt"), "").unwrap(); + // Ignore the loose files in `parentdir` but re-include its subdirectories. + let gitignores = vec![gitignore_rooted(&root, "parentdir/*\n!parentdir/*/\n")]; + + // `parentdir` itself is not matched by `parentdir/*`, so we descend. + assert!(super::should_watch_repo_directory( + &root.join("parentdir"), + &gitignores, + &[] + )); + // The subdirectory is re-included by the directory-only negation, so it is + // still watched even though `parentdir/*` matched it first. + assert!(super::should_watch_repo_directory( + &root.join("parentdir/sub"), + &gitignores, + &[] + )); + // The loose file remains gitignored (the negation is directory-only); the + // emit predicate filters it, but `parentdir` stays watched for its subdirs. + assert!(matches_gitignores( + &root.join("parentdir/loose.txt"), + false, + &gitignores, + true, + )); +} + +#[test] +fn should_watch_preserves_git_internal_allowlist() { + // No gitignores / force-included paths needed: `.git` handling + // short-circuits and is path-based, mirroring + // `should_watch_directory_in_git_path`. + let repo = std::path::Path::new("/home/user/project"); + assert!(super::should_watch_repo_directory( + &repo.join(".git/refs/heads"), + &[], + &[] + )); + assert!(!super::should_watch_repo_directory( + &repo.join(".git/objects"), + &[], + &[] + )); +} + fn find_entry<'a>(entry: &'a super::Entry, path: &std::path::Path) -> Option<&'a super::Entry> { let std_path = warp_util::standardized_path::StandardizedPath::try_from_local(path).ok()?; if entry.path() == &std_path { @@ -192,7 +347,7 @@ fn build_skill_tree_with_gitignore(root: &std::path::Path, gitignore: &str) -> s let mut files = Vec::new(); let mut gitignores = Vec::new(); let mut file_limit = 1000; - super::Entry::build_tree_with_ignored_path_interests( + super::Entry::build_tree_with_force_included_paths( root, &mut files, &mut gitignores, @@ -201,7 +356,7 @@ fn build_skill_tree_with_gitignore(root: &std::path::Path, gitignore: &str) -> s max_depth: 200, current_depth: 0, ignored_path_strategy: &super::IgnoredPathStrategy::IncludeLazy, - ignored_path_interests: &[std::path::PathBuf::from(".agents/skills")], + force_included_paths: &[std::path::PathBuf::from(".agents/skills")], budget_exceeded_behavior: super::BudgetExceededBehavior::StopAndLazyLoad, }, ) @@ -283,7 +438,7 @@ fn ignored_agents_skills_directory_is_loaded_for_registered_provider_path() { } #[test] -fn unrelated_ignored_directory_stays_lazy_without_registered_interest() { +fn unrelated_ignored_directory_stays_lazy_without_registered_force_included() { virtual_fs::VirtualFS::test("unrelated_ignored_dir_lazy", |dirs, mut vfs| { vfs.mkdir("repo/.agents/skills/test") .mkdir("repo/target/debug") @@ -518,17 +673,17 @@ fn test_extract_worktree_git_dir() { ); } -/// Builds a tree with an explicit file budget and ignored-path interests using +/// Builds a tree with an explicit file budget and force-included paths using /// the lazy ignored-path strategy. fn build_with_budget( root: &std::path::Path, budget: usize, - interests: &[std::path::PathBuf], + force_included_paths: &[std::path::PathBuf], ) -> super::Entry { let mut files = Vec::new(); let mut gitignores = Vec::new(); let mut file_limit = budget; - super::Entry::build_tree_with_ignored_path_interests( + super::Entry::build_tree_with_force_included_paths( root, &mut files, &mut gitignores, @@ -537,7 +692,7 @@ fn build_with_budget( max_depth: 200, current_depth: 0, ignored_path_strategy: &super::IgnoredPathStrategy::IncludeLazy, - ignored_path_interests: interests, + force_included_paths, budget_exceeded_behavior: super::BudgetExceededBehavior::StopAndLazyLoad, }, ) @@ -589,7 +744,7 @@ fn build_tree_budget_covers_breadth_first_and_leaves_remainder_unloaded() { } #[test] -fn build_tree_budget_does_not_prune_interest_paths() { +fn build_tree_budget_does_not_prune_force_included_paths() { let temp_dir = tempfile::tempdir().unwrap(); let root = dunce::canonicalize(temp_dir.path()).unwrap(); @@ -603,13 +758,13 @@ fn build_tree_budget_does_not_prune_interest_paths() { fs::write(skill_dir.join("SKILL.md"), "name: test").unwrap(); // Tiny budget: the root expands and is immediately exhausted, but the - // registered interest path must still be loaded all the way down. - let interests = [std::path::PathBuf::from(".agents/skills")]; - let tree = build_with_budget(&root, 1, &interests); + // force-included path must still be loaded all the way down. + let force_included = [std::path::PathBuf::from(".agents/skills")]; + let tree = build_with_budget(&root, 1, &force_included); assert!( find_entry(&tree, &skill_dir.join("SKILL.md")).is_some(), - "interest-path files must load even when the budget is exhausted" + "force-included path files must load even when the budget is exhausted" ); } diff --git a/crates/repo_metadata/src/local_model.rs b/crates/repo_metadata/src/local_model.rs index dee177014d..2ebf4a6d9c 100644 --- a/crates/repo_metadata/src/local_model.rs +++ b/crates/repo_metadata/src/local_model.rs @@ -159,10 +159,11 @@ pub struct LocalRepoMetadataModel { /// events after applying watcher mutations. Only the remote server /// variant enables this. emit_incremental_updates: bool, - /// Component-sequence paths that consumers need loaded even when ignored. - /// For example, a consumer can register `.foo/bar` so ignored `.foo`, - /// `.foo/bar`, and descendants of `.foo/bar` are loaded into the tree. - ignored_path_interests: Vec, + /// Paths that must be loaded even when gitignored or beyond the tree's size + /// limit. For example, a consumer can register `.foo/bar` so ignored + /// `.foo`, `.foo/bar`, and descendants of `.foo/bar` are loaded into the + /// tree. + force_included_paths: Vec, } #[derive(Debug, Clone, Default)] @@ -247,7 +248,7 @@ impl LocalRepoMetadataModel { #[cfg(feature = "local_fs")] watcher: None, emit_incremental_updates: false, - ignored_path_interests: Vec::new(), + force_included_paths: Vec::new(), }; cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { @@ -283,22 +284,20 @@ impl LocalRepoMetadataModel { self.emit_incremental_updates = enabled; } - /// Registers component-sequence paths that should be loaded even when ignored. + /// Registers paths that must be loaded even when gitignored or beyond the + /// tree's size limit. /// /// This stays intentionally generic: consumers own the meaning of the paths, /// while repo metadata only uses them to decide which ignored subtrees should /// be represented eagerly instead of as lazy placeholders. - pub fn register_ignored_path_interests( - &mut self, - interests: impl IntoIterator, - ) { - for interest in interests { + pub fn register_force_included_paths(&mut self, paths: impl IntoIterator) { + for path in paths { if !self - .ignored_path_interests + .force_included_paths .iter() - .any(|existing| existing == &interest) + .any(|existing| existing == &path) { - self.ignored_path_interests.push(interest); + self.force_included_paths.push(path); } } } @@ -354,14 +353,14 @@ impl LocalRepoMetadataModel { 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(); - let ignored_path_interests = self.ignored_path_interests.clone(); + let force_included_paths = self.force_included_paths.clone(); let lazy_load = self.lazy_loaded_paths.contains_key(&repo_path); ctx.spawn( async move { let mutations = Self::compute_file_tree_mutations( &repo_scoped_update, &gitignores_clone, - &ignored_path_interests, + &force_included_paths, ) .await; (mutations, repo_path_clone, lazy_load) @@ -435,15 +434,21 @@ impl LocalRepoMetadataModel { )); } - // Register this path with the watcher if we have one. + // Register this path with the watcher if we have one #[cfg(feature = "local_fs")] { if let Some(ref watcher) = self.watcher { let watch_path = local_path.clone(); + // Build the gitignore set (root + global) and force-included + // path list so the descend filter prunes gitignored subtrees + // while still watching registered force-included paths (e.g. + // skills). + let gitignores = crate::gitignores_for_directory(&watch_path); + let force_included_paths = self.force_included_paths.clone(); watcher.update(ctx, |watcher, _ctx| { std::mem::drop(watcher.register_path( &watch_path, - repo_watch_filter(), + repo_watch_filter(gitignores, force_included_paths), RecursiveMode::Recursive, )); }); @@ -635,7 +640,7 @@ impl LocalRepoMetadataModel { async fn compute_file_tree_mutations( update: &RepoUpdate, gitignores: &[Gitignore], - ignored_path_interests: &[PathBuf], + force_included_paths: &[PathBuf], ) -> Vec { let mut mutations = Vec::new(); @@ -656,7 +661,7 @@ impl LocalRepoMetadataModel { let mut files = Vec::new(); let mut gitignores = gitignores.to_owned(); let mut file_limit = MAX_FILES_PER_REPO; - match Entry::build_tree_with_ignored_path_interests_and_ancestor( + match Entry::build_tree_with_force_included_paths_and_ancestor( path_to_add, &mut files, &mut gitignores, @@ -665,7 +670,7 @@ impl LocalRepoMetadataModel { max_depth: MAX_TREE_DEPTH, current_depth: 0, ignored_path_strategy: &IgnoredPathStrategy::IncludeLazy, - ignored_path_interests, + force_included_paths, budget_exceeded_behavior: BudgetExceededBehavior::StopAndLazyLoad, }, is_ignored, @@ -948,7 +953,7 @@ impl LocalRepoMetadataModel { // Build the complete file tree for the repository asynchronously let repo_path_for_build = local_path; let gitignores_for_build = gitignores.clone(); - let ignored_path_interests = self.ignored_path_interests.clone(); + let force_included_paths = self.force_included_paths.clone(); let repo_path_str_for_log = std_path.to_string(); let std_path_for_completion = std_path; let repository_handle_for_completion = repository_handle.clone(); @@ -962,11 +967,11 @@ impl LocalRepoMetadataModel { // stops descending breadth-first and leaves the remaining // directories as unloaded placeholders (lazy-loaded on demand) // instead of failing the whole build. Gitignored subtrees stay - // lazy and registered ignored-path interests are always loaded; + // lazy and registered force-included paths are always loaded; // both are handled inside the builder. let mut file_limit = MAX_FILES_PER_REPO; - let build_result = Entry::build_tree_with_ignored_path_interests( + let build_result = Entry::build_tree_with_force_included_paths( &repo_path_for_build, &mut files, &mut gitignores_for_build, @@ -975,7 +980,7 @@ impl LocalRepoMetadataModel { max_depth: MAX_TREE_DEPTH, current_depth: 0, ignored_path_strategy: &IgnoredPathStrategy::IncludeLazy, - ignored_path_interests: &ignored_path_interests, + force_included_paths: &force_included_paths, budget_exceeded_behavior: BudgetExceededBehavior::StopAndLazyLoad, }, ); diff --git a/crates/repo_metadata/src/local_model_tests.rs b/crates/repo_metadata/src/local_model_tests.rs index 005d41ed4f..58f5b69711 100644 --- a/crates/repo_metadata/src/local_model_tests.rs +++ b/crates/repo_metadata/src/local_model_tests.rs @@ -32,7 +32,7 @@ impl LocalRepoMetadataModel { #[cfg(feature = "local_fs")] watcher: Default::default(), emit_incremental_updates: false, - ignored_path_interests: Vec::new(), + force_included_paths: Vec::new(), } } } diff --git a/crates/repo_metadata/src/repository.rs b/crates/repo_metadata/src/repository.rs index 67427d7d92..52ab184813 100644 --- a/crates/repo_metadata/src/repository.rs +++ b/crates/repo_metadata/src/repository.rs @@ -328,10 +328,15 @@ impl Repository { let registration_future: BoxFuture<'static, Result<(), RepoMetadataError>> = if should_start_watching { let directories_to_watch = self.watch_paths(); - - Box::pin(DirectoryWatcher::handle(ctx).update(ctx, |watcher, ctx| { - watcher.start_watching_directories(directories_to_watch, ctx) - })) + // Reuse the gitignores we already built at construction so the + // watch descend filter doesn't re-read `.gitignore` from disk. + let gitignores = self.gitignores.clone(); + + Box::pin( + DirectoryWatcher::handle(ctx).update(ctx, move |watcher, ctx| { + watcher.start_watching_directories(directories_to_watch, gitignores, ctx) + }), + ) } else { Box::pin(ready(Ok(()))) }; diff --git a/crates/repo_metadata/src/watcher.rs b/crates/repo_metadata/src/watcher.rs index 3790ff2e45..324a7444cc 100644 --- a/crates/repo_metadata/src/watcher.rs +++ b/crates/repo_metadata/src/watcher.rs @@ -15,6 +15,7 @@ use crate::{RepoMetadataError, Repository}; cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { + use ignore::gitignore::Gitignore; use watcher::{BulkFilesystemWatcher, BulkFilesystemWatcherEvent}; use crate::entry::{ extract_worktree_git_dir, is_commit_related_git_file, is_git_internal_path, @@ -41,6 +42,12 @@ pub struct DirectoryWatcher { /// Handle to the internal processing queue model that orders scan & update tasks. processing_queue: ModelHandle, + + /// Paths that must be watched (and indexed) even when they are gitignored + /// or beyond the tree's size limit — e.g. skill provider directories that + /// consumers (LSP, MCP) need live updates for. + #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] + force_included_paths: Vec, } impl DirectoryWatcher { @@ -68,6 +75,7 @@ impl DirectoryWatcher { #[cfg(feature = "local_fs")] watcher: Some(fs_watcher), processing_queue, + force_included_paths: Vec::new(), } } @@ -92,6 +100,20 @@ impl DirectoryWatcher { #[cfg(feature = "local_fs")] watcher: Some(fs_watcher), processing_queue, + force_included_paths: Vec::new(), + } + } + + /// Registers paths that must be watched even when gitignored. Mirrors + /// `LocalRepoMetadataModel::register_force_included_paths` but applies to + /// the watcher backing `Repository` subscribers (LSP, MCP). Must be called + /// before repositories begin watching to take effect on already-registered + /// watches. + pub fn register_force_included_paths(&mut self, paths: impl IntoIterator) { + for path in paths { + if !self.force_included_paths.contains(&path) { + self.force_included_paths.push(path); + } } } @@ -294,11 +316,12 @@ impl DirectoryWatcher { pub(crate) fn start_watching_directories( &mut self, directory_paths: Vec, + gitignores: Vec, ctx: &mut ModelContext, ) -> impl Future> { let futures: Vec<_> = directory_paths .into_iter() - .map(|path| self.start_watching_directory(&path, ctx)) + .map(|path| self.start_watching_directory(&path, gitignores.clone(), ctx)) .collect(); async move { @@ -316,11 +339,17 @@ impl DirectoryWatcher { pub(crate) fn start_watching_directory( &mut self, directory_path: &StandardizedPath, + gitignores: Vec, ctx: &mut ModelContext, ) -> impl Future> { let local_path = directory_path.to_local_path(); let registration_future = if let Some(ref watcher) = self.watcher { if let Some(local_path) = local_path.clone() { + // `gitignores` are the repo's cached root + global gitignores, + // threaded in from `Repository::start_watching` so we neither + // re-read `.gitignore` from disk nor re-enter the (already + // borrowed) `Repository` model here. + let force_included_paths = self.force_included_paths.clone(); watcher.update(ctx, |watcher, _ctx| { use notify_debouncer_full::notify::RecursiveMode; @@ -328,7 +357,7 @@ impl DirectoryWatcher { Some(watcher.register_path( &local_path, - repo_watch_filter(), + repo_watch_filter(gitignores, force_included_paths), RecursiveMode::Recursive, )) }) diff --git a/crates/repo_metadata/src/wrapper_model.rs b/crates/repo_metadata/src/wrapper_model.rs index 39a65fb74f..e210ca402b 100644 --- a/crates/repo_metadata/src/wrapper_model.rs +++ b/crates/repo_metadata/src/wrapper_model.rs @@ -305,19 +305,20 @@ impl RepoMetadataModel { }) } - /// Registers component-sequence paths that should be loaded even when ignored. + /// Registers paths that must be loaded even when gitignored or beyond the + /// tree's size limit. /// - /// This delegates to the local model because ignored-path matching happens - /// while building local file trees. Remote repositories receive the resulting - /// file-tree metadata over the existing remote sync protocol. - pub fn register_ignored_path_interests( + /// This delegates to the local model because force-included path matching + /// happens while building local file trees. Remote repositories receive the + /// resulting file-tree metadata over the existing remote sync protocol. + pub fn register_force_included_paths( &self, - interests: impl IntoIterator, + paths: impl IntoIterator, ctx: &mut ModelContext, ) { - let interests: Vec<_> = interests.into_iter().collect(); + let paths: Vec<_> = paths.into_iter().collect(); self.local.update(ctx, |local, _| { - local.register_ignored_path_interests(interests); + local.register_force_included_paths(paths); }); }