Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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()),
Expand Down
118 changes: 82 additions & 36 deletions crates/repo_metadata/src/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -128,7 +128,7 @@ impl Entry {
ignored_path_strategy: &IgnoredPathStrategy,
budget_exceeded_behavior: BudgetExceededBehavior,
) -> Result<Self, BuildTreeError> {
Self::build_tree_with_ignored_path_interests_and_ancestor(
Self::build_tree_with_force_included_paths_and_ancestor(
path,
files,
gitignores,
Expand All @@ -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<PathBuf>,
files: &mut Vec<FileMetadata>,
gitignores: &mut Vec<Gitignore>,
remaining_file_quota: Option<&mut usize>,
options: BuildTreeOptions<'_>,
) -> Result<Self, BuildTreeError> {
Self::build_tree_with_ignored_path_interests_and_ancestor(
Self::build_tree_with_force_included_paths_and_ancestor(
path,
files,
gitignores,
Expand All @@ -174,7 +175,7 @@ impl Entry {
ignored_path_strategy: &IgnoredPathStrategy,
ancestor_is_ignored: bool,
) -> Result<Self, BuildTreeError> {
Self::build_tree_with_ignored_path_interests_and_ancestor(
Self::build_tree_with_force_included_paths_and_ancestor(
path,
files,
gitignores,
Expand All @@ -183,15 +184,15 @@ impl Entry {
max_depth,
current_depth,
ignored_path_strategy,
ignored_path_interests: &[],
force_included_paths: &[],
budget_exceeded_behavior: BudgetExceededBehavior::StopAndLazyLoad,
},
ancestor_is_ignored,
)
}

#[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<PathBuf>,
files: &mut Vec<FileMetadata>,
gitignores: &mut Vec<Gitignore>,
Expand Down Expand Up @@ -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,
)
}
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 => {}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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),
Expand All @@ -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]
})
})
}
Expand Down Expand Up @@ -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/<r>/*, 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/<r>/`, and `.git/worktrees/<n>/`
/// 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<Gitignore>,
force_included_paths: Vec<PathBuf>,
) -> 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)),
)
}
Expand Down
Loading
Loading