first pass of merging in warp (doesn't build)
This commit is contained in:
+676
-124
@@ -1,12 +1,20 @@
|
||||
#![cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
use ignore::gitignore::Gitignore;
|
||||
use std::collections::VecDeque;
|
||||
use std::io;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use std::sync::Arc;
|
||||
|
||||
use ignore::gitignore::Gitignore;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use notify_debouncer_full::notify::WatchFilter;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::standing_queries::{StandingQueryDefinitions, StandingQueryResults};
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
|
||||
/// Maximum file size allowed for treesitter parsing (3MB).
|
||||
const MAX_FILE_SIZE: usize = 3 * 1000 * 1000;
|
||||
|
||||
@@ -29,7 +37,7 @@ pub enum BuildTreeError {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum IgnoredPathStrategy {
|
||||
/// Do not include any ingored files or folders
|
||||
/// Do not include any ignored files or folders
|
||||
Exclude,
|
||||
|
||||
/// Lazy-load excluded directories
|
||||
@@ -42,12 +50,38 @@ pub enum IgnoredPathStrategy {
|
||||
Include,
|
||||
}
|
||||
|
||||
/// What the tree builder does when the per-build file budget is exhausted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BudgetExceededBehavior {
|
||||
/// Stop descending and leave the remaining directories as unloaded
|
||||
/// placeholders (lazy-loaded on demand). The build still succeeds with a
|
||||
/// partial, breadth-first tree. This is the default for the shared file
|
||||
/// tree, `@`-context, and skill discovery.
|
||||
StopAndLazyLoad,
|
||||
/// Abort the build and return [`BuildTreeError::ExceededMaxFileLimit`].
|
||||
/// Use this for consumers that must not operate on a partial tree — e.g.
|
||||
/// codebase embedding, where the file limit is an intentional cost cap.
|
||||
FailFast,
|
||||
}
|
||||
|
||||
/// Filesystem entry.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Entry {
|
||||
File(FileMetadata),
|
||||
Directory(DirectoryEntry),
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct BuildTreeOptions<'a> {
|
||||
pub max_depth: usize,
|
||||
pub current_depth: usize,
|
||||
pub ignored_path_strategy: &'a IgnoredPathStrategy,
|
||||
pub force_included_paths: &'a [PathBuf],
|
||||
pub budget_exceeded_behavior: BudgetExceededBehavior,
|
||||
}
|
||||
struct StandingQueryBuildState<'a> {
|
||||
results: &'a mut StandingQueryResults,
|
||||
definitions: &'a StandingQueryDefinitions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
|
||||
pub struct FileId(usize);
|
||||
@@ -85,91 +119,239 @@ impl Entry {
|
||||
}
|
||||
|
||||
/// Builds a tree of entries from a given path, handling gitignored files and directories.
|
||||
/// After max_depth is reached, all children are lazy-loaded to prevent deeply nested trees.
|
||||
/// After max_depth is reached, children outside force-included paths are lazy-loaded to
|
||||
/// prevent deeply nested trees.
|
||||
/// IgnoredPathStrategy determines what happens when ignored files are encountered.
|
||||
/// `budget_exceeded_behavior` controls what happens once the file budget is
|
||||
/// exhausted (see [`BudgetExceededBehavior`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_tree(
|
||||
path: impl Into<PathBuf>,
|
||||
files: &mut Vec<FileMetadata>,
|
||||
gitignores: &mut Vec<Gitignore>,
|
||||
mut remaining_file_quota: Option<&mut usize>,
|
||||
remaining_file_quota: Option<&mut usize>,
|
||||
max_depth: usize,
|
||||
current_depth: usize,
|
||||
ignored_path_strategy: &IgnoredPathStrategy,
|
||||
budget_exceeded_behavior: BudgetExceededBehavior,
|
||||
) -> Result<Self, BuildTreeError> {
|
||||
let curr_path: PathBuf = path.into();
|
||||
let is_dir = curr_path.is_dir();
|
||||
Self::build_tree_with_force_included_paths_and_ancestor(
|
||||
path,
|
||||
files,
|
||||
gitignores,
|
||||
remaining_file_quota,
|
||||
BuildTreeOptions {
|
||||
max_depth,
|
||||
current_depth,
|
||||
ignored_path_strategy,
|
||||
force_included_paths: &[],
|
||||
budget_exceeded_behavior,
|
||||
},
|
||||
false,
|
||||
None,
|
||||
)
|
||||
}
|
||||
/// Builds the materialized tree and standing results during the same filesystem traversal.
|
||||
pub(crate) fn build_tree_with_standing_queries(
|
||||
path: impl Into<PathBuf>,
|
||||
files: &mut Vec<FileMetadata>,
|
||||
gitignores: &mut Vec<Gitignore>,
|
||||
remaining_file_quota: Option<&mut usize>,
|
||||
options: BuildTreeOptions<'_>,
|
||||
standing_results: &mut StandingQueryResults,
|
||||
definitions: &StandingQueryDefinitions,
|
||||
) -> Result<Self, BuildTreeError> {
|
||||
let mut standing_queries = StandingQueryBuildState {
|
||||
results: standing_results,
|
||||
definitions,
|
||||
};
|
||||
Self::build_tree_with_force_included_paths_and_ancestor(
|
||||
path,
|
||||
files,
|
||||
gitignores,
|
||||
remaining_file_quota,
|
||||
options,
|
||||
false,
|
||||
Some(&mut standing_queries),
|
||||
)
|
||||
}
|
||||
|
||||
// Only ignore symlinks to directories. Symlinks to files are preserved (e.g. GALAXY.md).
|
||||
if curr_path.is_symlink() && is_dir {
|
||||
return Err(BuildTreeError::Symlink);
|
||||
/// 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`]).
|
||||
#[cfg(test)]
|
||||
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_force_included_paths_and_ancestor(
|
||||
path,
|
||||
files,
|
||||
gitignores,
|
||||
remaining_file_quota,
|
||||
options,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn build_tree_with_ignored_ancestor(
|
||||
path: impl Into<PathBuf>,
|
||||
files: &mut Vec<FileMetadata>,
|
||||
gitignores: &mut Vec<Gitignore>,
|
||||
remaining_file_quota: Option<&mut usize>,
|
||||
max_depth: usize,
|
||||
current_depth: usize,
|
||||
ignored_path_strategy: &IgnoredPathStrategy,
|
||||
ancestor_is_ignored: bool,
|
||||
) -> Result<Self, BuildTreeError> {
|
||||
Self::build_tree_with_force_included_paths_and_ancestor(
|
||||
path,
|
||||
files,
|
||||
gitignores,
|
||||
remaining_file_quota,
|
||||
BuildTreeOptions {
|
||||
max_depth,
|
||||
current_depth,
|
||||
ignored_path_strategy,
|
||||
force_included_paths: &[],
|
||||
budget_exceeded_behavior: BudgetExceededBehavior::StopAndLazyLoad,
|
||||
},
|
||||
ancestor_is_ignored,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_tree_with_force_included_paths_and_ancestor(
|
||||
path: impl Into<PathBuf>,
|
||||
files: &mut Vec<FileMetadata>,
|
||||
gitignores: &mut Vec<Gitignore>,
|
||||
remaining_file_quota: Option<&mut usize>,
|
||||
options: BuildTreeOptions<'_>,
|
||||
ancestor_is_ignored: bool,
|
||||
mut standing_queries: Option<&mut StandingQueryBuildState<'_>>,
|
||||
) -> Result<Self, BuildTreeError> {
|
||||
let root_path: PathBuf = path.into();
|
||||
|
||||
// Local copy of the file budget. The builder spends it breadth-first;
|
||||
// once it is exhausted, any remaining directories are left as unloaded
|
||||
// placeholders (lazy-loaded on demand) instead of aborting the whole
|
||||
// build. This keeps coverage even and shallow-biased rather than
|
||||
// collapsing the entire tree to a single level.
|
||||
let mut quota: Option<usize> = remaining_file_quota.as_deref().copied();
|
||||
|
||||
// Arena of partially-built nodes. A child is always discovered (and
|
||||
// pushed) while expanding its parent, so a child's index is always
|
||||
// greater than its parent's and the nested tree can be assembled
|
||||
// bottom-up at the end.
|
||||
let mut nodes: Vec<Option<NodeBuilder>> = Vec::new();
|
||||
|
||||
// Classify the root. Unlike child entries (which are simply omitted when
|
||||
// ignored/symlinked), a classification failure at the root propagates to
|
||||
// the caller, preserving existing error behavior.
|
||||
if let Some(state) = standing_queries.as_deref_mut() {
|
||||
state
|
||||
.results
|
||||
.record_path(&root_path, root_path.is_dir(), state.definitions);
|
||||
}
|
||||
|
||||
let gitignore_path = curr_path.join(".gitignore");
|
||||
if gitignore_path.exists() {
|
||||
let (gitignore, _) = Gitignore::new(gitignore_path);
|
||||
gitignores.push(gitignore);
|
||||
}
|
||||
|
||||
let path_is_ignored = matches_gitignores(
|
||||
&curr_path,
|
||||
is_dir,
|
||||
&*gitignores,
|
||||
true, /* check_ancestors */
|
||||
) || is_git_internal_path(&curr_path);
|
||||
|
||||
// If we've reached the max depth, force lazy-loading even of non-ignored folders.
|
||||
let mut lazy_load = current_depth >= max_depth;
|
||||
|
||||
if path_is_ignored {
|
||||
match ignored_path_strategy {
|
||||
IgnoredPathStrategy::Exclude => {
|
||||
return Err(BuildTreeError::Ignored);
|
||||
}
|
||||
IgnoredPathStrategy::IncludeOnly(patterns) => {
|
||||
if let Some(file_name) = curr_path.file_name().and_then(|n| n.to_str()) {
|
||||
if !patterns.iter().any(|pattern| file_name == pattern) {
|
||||
return Err(BuildTreeError::Ignored);
|
||||
}
|
||||
}
|
||||
}
|
||||
IgnoredPathStrategy::IncludeLazy => {
|
||||
lazy_load = true;
|
||||
}
|
||||
IgnoredPathStrategy::Include => {}
|
||||
}
|
||||
}
|
||||
|
||||
if is_dir {
|
||||
if lazy_load {
|
||||
return Ok(Self::Directory(DirectoryEntry {
|
||||
children: vec![],
|
||||
path: StandardizedPath::from_local_absolute_unchecked(&curr_path),
|
||||
ignored: path_is_ignored,
|
||||
loaded: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// If the path is a directory, process all the children under it.
|
||||
let entries = std::fs::read_dir(&curr_path)?;
|
||||
let mut children = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
if remaining_file_quota
|
||||
.as_ref()
|
||||
.is_some_and(|x| **x < children.len())
|
||||
match evaluate_entry(
|
||||
&root_path,
|
||||
gitignores,
|
||||
&options,
|
||||
options.current_depth,
|
||||
ancestor_is_ignored,
|
||||
)? {
|
||||
EvaluatedEntry::File { ignored } => {
|
||||
if quota == Some(0)
|
||||
&& options.budget_exceeded_behavior == BudgetExceededBehavior::FailFast
|
||||
{
|
||||
return Err(BuildTreeError::ExceededMaxFileLimit);
|
||||
}
|
||||
let metadata = consume_file(&root_path, ignored, files, &mut quota);
|
||||
write_back_quota(remaining_file_quota, quota);
|
||||
Ok(Self::File(metadata))
|
||||
}
|
||||
EvaluatedEntry::Directory { ignored, lazy } => {
|
||||
nodes.push(Some(NodeBuilder::Dir {
|
||||
path: root_path.clone(),
|
||||
ignored,
|
||||
loaded: false,
|
||||
children: Vec::new(),
|
||||
}));
|
||||
|
||||
if let Some(entry) = match entry {
|
||||
Ok(entry) => {
|
||||
let mut queue: VecDeque<DirJob> = VecDeque::new();
|
||||
if !lazy {
|
||||
queue.push_back(DirJob {
|
||||
index: 0,
|
||||
path: root_path,
|
||||
depth: options.current_depth,
|
||||
ignored,
|
||||
is_root: true,
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(job) = queue.pop_front() {
|
||||
// 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 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_force_included_path(
|
||||
&job.path,
|
||||
options.force_included_paths,
|
||||
)
|
||||
}
|
||||
};
|
||||
if !should_expand {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entries = match std::fs::read_dir(&job.path) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
// Preserve existing behavior: failing to read the
|
||||
// root directory propagates, while an unreadable
|
||||
// nested directory is left as an unloaded placeholder.
|
||||
if job.is_root {
|
||||
return Err(BuildTreeError::IOError(e));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(NodeBuilder::Dir { loaded, .. }) = nodes[job.index].as_mut() {
|
||||
*loaded = true;
|
||||
}
|
||||
|
||||
let child_depth = job.depth + 1;
|
||||
for entry in entries {
|
||||
let Ok(entry) = entry else {
|
||||
continue;
|
||||
};
|
||||
let entry_path = entry.path();
|
||||
|
||||
// Skip symlinks to folders before canonicalization to prevent duplicates.
|
||||
// If it's a symlink to a file, we keep the path as is since canonicalization would
|
||||
// point its path to the actual file.
|
||||
// Do not materialize directory symlinks in the canonical tree. Standing
|
||||
// project-skill queries still follow eligible provider children locally
|
||||
// and retain their lexical paths in the result set.
|
||||
let canonical_path = if entry_path.is_symlink() {
|
||||
if entry_path.is_dir() {
|
||||
if let Some(state) = standing_queries.as_deref_mut() {
|
||||
state.results.record_followed_project_skill_directory(
|
||||
&entry_path,
|
||||
state.definitions,
|
||||
);
|
||||
}
|
||||
None
|
||||
} else {
|
||||
Some(entry_path)
|
||||
@@ -177,52 +359,71 @@ impl Entry {
|
||||
} else {
|
||||
dunce::canonicalize(entry_path).ok()
|
||||
};
|
||||
let Some(child_path) = canonical_path else {
|
||||
continue;
|
||||
};
|
||||
if let Some(state) = standing_queries.as_deref_mut() {
|
||||
state.results.record_path(
|
||||
&child_path,
|
||||
child_path.is_dir(),
|
||||
state.definitions,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(canonical_path) = canonical_path {
|
||||
match Entry::build_tree(
|
||||
canonical_path,
|
||||
files,
|
||||
gitignores,
|
||||
remaining_file_quota.as_deref_mut(),
|
||||
max_depth,
|
||||
current_depth + 1,
|
||||
ignored_path_strategy,
|
||||
) {
|
||||
Ok(entry) => Some(entry),
|
||||
Err(BuildTreeError::ExceededMaxFileLimit) => {
|
||||
return Err(BuildTreeError::ExceededMaxFileLimit)
|
||||
match evaluate_entry(
|
||||
&child_path,
|
||||
gitignores,
|
||||
&options,
|
||||
child_depth,
|
||||
job.ignored,
|
||||
) {
|
||||
Ok(EvaluatedEntry::File { ignored }) => {
|
||||
if quota == Some(0)
|
||||
&& options.budget_exceeded_behavior
|
||||
== BudgetExceededBehavior::FailFast
|
||||
{
|
||||
return Err(BuildTreeError::ExceededMaxFileLimit);
|
||||
}
|
||||
Err(_) => None,
|
||||
let metadata =
|
||||
consume_file(&child_path, ignored, files, &mut quota);
|
||||
let child_index = nodes.len();
|
||||
nodes.push(Some(NodeBuilder::File(metadata)));
|
||||
push_child(&mut nodes, job.index, child_index);
|
||||
}
|
||||
Ok(EvaluatedEntry::Directory { ignored, lazy }) => {
|
||||
let child_index = nodes.len();
|
||||
nodes.push(Some(NodeBuilder::Dir {
|
||||
path: child_path.clone(),
|
||||
ignored,
|
||||
loaded: false,
|
||||
children: Vec::new(),
|
||||
}));
|
||||
push_child(&mut nodes, job.index, child_index);
|
||||
// Lazy directories (past max depth, or ignored
|
||||
// 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,
|
||||
path: child_path,
|
||||
depth: child_depth,
|
||||
ignored,
|
||||
is_root: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Ignored / excluded / symlinked-directory entries
|
||||
// are omitted from the tree.
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
} {
|
||||
children.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self::Directory(DirectoryEntry {
|
||||
children,
|
||||
path: StandardizedPath::from_local_absolute_unchecked(&curr_path),
|
||||
ignored: path_is_ignored,
|
||||
loaded: true,
|
||||
}))
|
||||
} else if curr_path.is_file() {
|
||||
if let Some(remaining_file_quota) = remaining_file_quota {
|
||||
if *remaining_file_quota == 0 {
|
||||
return Err(BuildTreeError::ExceededMaxFileLimit);
|
||||
}
|
||||
|
||||
*remaining_file_quota -= 1
|
||||
write_back_quota(remaining_file_quota, quota);
|
||||
Ok(assemble_node(&mut nodes, 0))
|
||||
}
|
||||
let metadata = FileMetadata::new(curr_path, path_is_ignored);
|
||||
files.push(metadata.clone());
|
||||
Ok(Self::File(metadata))
|
||||
} else {
|
||||
Err(BuildTreeError::Symlink)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,8 +463,9 @@ impl Entry {
|
||||
|
||||
let mut remaining_file_quota = LAZY_LOAD_FILE_LIMIT;
|
||||
let mut files = Vec::new();
|
||||
let ancestor_is_ignored = directory.ignored;
|
||||
|
||||
let result = Entry::build_tree(
|
||||
let result = Entry::build_tree_with_ignored_ancestor(
|
||||
directory.path.to_local_path_lossy(),
|
||||
&mut files,
|
||||
gitignores,
|
||||
@@ -271,6 +473,7 @@ impl Entry {
|
||||
1, /* max_depth */
|
||||
0, /* current_depth */
|
||||
&IgnoredPathStrategy::Include,
|
||||
ancestor_is_ignored,
|
||||
);
|
||||
|
||||
result.map(|entry| match entry {
|
||||
@@ -318,6 +521,162 @@ impl Entry {
|
||||
}
|
||||
}
|
||||
|
||||
/// A node in the breadth-first build arena. Directory children are referenced by
|
||||
/// arena index so the nested [`Entry`] tree can be assembled bottom-up.
|
||||
enum NodeBuilder {
|
||||
File(FileMetadata),
|
||||
Dir {
|
||||
path: PathBuf,
|
||||
ignored: bool,
|
||||
loaded: bool,
|
||||
children: Vec<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A directory queued for expansion during the breadth-first build.
|
||||
struct DirJob {
|
||||
index: usize,
|
||||
path: PathBuf,
|
||||
depth: usize,
|
||||
ignored: bool,
|
||||
is_root: bool,
|
||||
}
|
||||
|
||||
/// Classification of a single filesystem entry.
|
||||
enum EvaluatedEntry {
|
||||
File { ignored: bool },
|
||||
Directory { ignored: bool, lazy: bool },
|
||||
}
|
||||
|
||||
/// Classifies a single path: rejects directory symlinks, loads any local
|
||||
/// `.gitignore`, computes gitignore status, and applies the ignored-path
|
||||
/// strategy. Returns `Err(Ignored)`/`Err(Symlink)` for entries that should be
|
||||
/// omitted; callers decide whether that is fatal (root) or a skip (child).
|
||||
fn evaluate_entry(
|
||||
curr_path: &Path,
|
||||
gitignores: &mut Vec<Gitignore>,
|
||||
options: &BuildTreeOptions<'_>,
|
||||
current_depth: usize,
|
||||
ancestor_is_ignored: bool,
|
||||
) -> Result<EvaluatedEntry, BuildTreeError> {
|
||||
let is_dir = curr_path.is_dir();
|
||||
|
||||
// Only ignore symlinks to directories. Symlinks to files are preserved (e.g. WARP.md).
|
||||
if curr_path.is_symlink() && is_dir {
|
||||
return Err(BuildTreeError::Symlink);
|
||||
}
|
||||
|
||||
let gitignore_path = curr_path.join(".gitignore");
|
||||
if gitignore_path.exists() {
|
||||
let (gitignore, _) = Gitignore::new(gitignore_path);
|
||||
gitignores.push(gitignore);
|
||||
}
|
||||
|
||||
let path_is_ignored = ancestor_is_ignored
|
||||
|| is_git_internal_path(curr_path)
|
||||
|| matches_gitignores(
|
||||
curr_path,
|
||||
is_dir,
|
||||
&*gitignores,
|
||||
false, /* check_ancestors */
|
||||
);
|
||||
|
||||
let force_included = matches_force_included_path(curr_path, options.force_included_paths);
|
||||
|
||||
// If we've reached the max depth, force lazy-loading even of non-ignored folders unless the
|
||||
// folder is on the path to a force-included subtree.
|
||||
let mut lazy = current_depth >= options.max_depth && !force_included;
|
||||
|
||||
if path_is_ignored {
|
||||
match options.ignored_path_strategy {
|
||||
IgnoredPathStrategy::Exclude => return Err(BuildTreeError::Ignored),
|
||||
IgnoredPathStrategy::IncludeOnly(patterns) => {
|
||||
if let Some(file_name) = curr_path.file_name().and_then(|n| n.to_str()) {
|
||||
if !patterns.iter().any(|pattern| file_name == pattern) {
|
||||
return Err(BuildTreeError::Ignored);
|
||||
}
|
||||
}
|
||||
}
|
||||
IgnoredPathStrategy::IncludeLazy => {
|
||||
lazy = !force_included;
|
||||
}
|
||||
IgnoredPathStrategy::Include => {}
|
||||
}
|
||||
}
|
||||
|
||||
if is_dir {
|
||||
Ok(EvaluatedEntry::Directory {
|
||||
ignored: path_is_ignored,
|
||||
lazy,
|
||||
})
|
||||
} else if curr_path.is_file() {
|
||||
Ok(EvaluatedEntry::File {
|
||||
ignored: path_is_ignored,
|
||||
})
|
||||
} else {
|
||||
Err(BuildTreeError::Symlink)
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a file: decrements the budget (saturating), constructs metadata, and
|
||||
/// appends it to the flat `files` list.
|
||||
fn consume_file(
|
||||
path: &Path,
|
||||
ignored: bool,
|
||||
files: &mut Vec<FileMetadata>,
|
||||
quota: &mut Option<usize>,
|
||||
) -> FileMetadata {
|
||||
if let Some(remaining) = quota.as_mut() {
|
||||
*remaining = remaining.saturating_sub(1);
|
||||
}
|
||||
let metadata = FileMetadata::new(path.to_path_buf(), ignored);
|
||||
files.push(metadata.clone());
|
||||
metadata
|
||||
}
|
||||
|
||||
/// Appends `child` to `parent`'s child list in the build arena.
|
||||
fn push_child(nodes: &mut [Option<NodeBuilder>], parent: usize, child: usize) {
|
||||
if let Some(NodeBuilder::Dir { children, .. }) = nodes[parent].as_mut() {
|
||||
children.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively assembles the nested [`Entry`] tree from the build arena.
|
||||
/// Recursion depth is normally bounded by `BuildTreeOptions::max_depth`, except for force-included
|
||||
/// subtrees.
|
||||
fn assemble_node(nodes: &mut [Option<NodeBuilder>], index: usize) -> Entry {
|
||||
match nodes[index]
|
||||
.take()
|
||||
.expect("each arena node is assembled exactly once")
|
||||
{
|
||||
NodeBuilder::File(metadata) => Entry::File(metadata),
|
||||
NodeBuilder::Dir {
|
||||
path,
|
||||
ignored,
|
||||
loaded,
|
||||
children,
|
||||
} => {
|
||||
let children = children
|
||||
.into_iter()
|
||||
.map(|child| assemble_node(nodes, child))
|
||||
.collect();
|
||||
Entry::Directory(DirectoryEntry {
|
||||
path: StandardizedPath::from_local_absolute_unchecked(&path),
|
||||
children,
|
||||
ignored,
|
||||
loaded,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the remaining budget back into the caller-provided slot, if any.
|
||||
fn write_back_quota(remaining_file_quota: Option<&mut usize>, quota: Option<usize>) {
|
||||
if let (Some(slot), Some(value)) = (remaining_file_quota, quota) {
|
||||
*slot = value;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_git_internal_path(path: &Path) -> bool {
|
||||
path.components().any(|component| {
|
||||
if let Component::Normal(name) = component {
|
||||
@@ -328,6 +687,52 @@ pub fn is_git_internal_path(path: &Path) -> 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 {
|
||||
Component::Normal(name) => Some(name),
|
||||
Component::Prefix(_)
|
||||
| Component::RootDir
|
||||
| Component::CurDir
|
||||
| Component::ParentDir => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
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),
|
||||
Component::Prefix(_)
|
||||
| Component::RootDir
|
||||
| Component::CurDir
|
||||
| Component::ParentDir => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if force_included_components.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if path_components
|
||||
.windows(force_included_components.len())
|
||||
.any(|window| window == force_included_components.as_slice())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
(1..force_included_components.len()).any(|prefix_len| {
|
||||
path_components.len() >= prefix_len
|
||||
&& path_components[path_components.len() - prefix_len..]
|
||||
== force_included_components[..prefix_len]
|
||||
})
|
||||
})
|
||||
}
|
||||
/// Returns true if a path matches any of the gitignores.
|
||||
///
|
||||
/// For example, if the directory `/target` is ignored:
|
||||
@@ -426,6 +831,48 @@ pub(crate) fn is_shared_git_ref(path: &Path) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Returns `true` for loose remote-tracking refs under the shared `.git`
|
||||
/// directory, e.g. `.git/refs/remotes/origin/main`.
|
||||
pub(crate) fn is_remote_tracking_ref(path: &Path) -> bool {
|
||||
if extract_worktree_git_dir(path).is_some() {
|
||||
return false;
|
||||
}
|
||||
let components: Vec<_> = path.components().collect();
|
||||
let Some(git_index) = components.iter().position(|c| c.as_os_str() == ".git") else {
|
||||
return false;
|
||||
};
|
||||
let after_git = &components[git_index + 1..];
|
||||
after_git.len() >= 4
|
||||
&& after_git[0].as_os_str() == "refs"
|
||||
&& after_git[1].as_os_str() == "remotes"
|
||||
}
|
||||
|
||||
/// Returns true for Git files that can change the current branch's tracked
|
||||
/// upstream ref.
|
||||
pub(crate) fn is_tracking_state_git_file(path: &Path) -> bool {
|
||||
let Some(suffix) = git_suffix_components(path) else {
|
||||
return false;
|
||||
};
|
||||
suffix.len() == 1
|
||||
&& matches!(
|
||||
suffix[0].as_os_str().to_str(),
|
||||
Some("HEAD" | "config" | "config.worktree")
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns true for `.git/config` in the shared Git directory.
|
||||
pub(crate) fn is_common_git_config(path: &Path) -> bool {
|
||||
if extract_worktree_git_dir(path).is_some() {
|
||||
return false;
|
||||
}
|
||||
let components: Vec<_> = path.components().collect();
|
||||
let Some(git_index) = components.iter().position(|c| c.as_os_str() == ".git") else {
|
||||
return false;
|
||||
};
|
||||
let after_git = &components[git_index + 1..];
|
||||
after_git.len() == 1 && after_git[0].as_os_str() == "config"
|
||||
}
|
||||
|
||||
/// Returns true for `.git/HEAD` and `.git/refs/heads/*`
|
||||
/// (and their worktree equivalents `.git/worktrees/*/HEAD`, etc.).
|
||||
pub(crate) fn is_commit_related_git_file(path: &Path) -> bool {
|
||||
@@ -452,33 +899,138 @@ pub(crate) fn is_index_lock_file(path: &Path) -> bool {
|
||||
|
||||
/// Determines if a git-related path should be ignored by the filesystem watcher.
|
||||
///
|
||||
/// Uses an allowlist approach: only commit-related files (HEAD, refs/heads/*)
|
||||
/// and the index lock file are allowed through. Everything else inside `.git/`
|
||||
/// is ignored.
|
||||
/// Uses an allowlist approach: only commit-related files (HEAD, refs/heads/*),
|
||||
/// loose remote-tracking refs, tracked-upstream state files, and the index lock
|
||||
/// file are allowed through. Everything else inside `.git/` is ignored.
|
||||
pub fn should_ignore_git_path(path: &Path) -> bool {
|
||||
if !is_git_internal_path(path) {
|
||||
return false; // Not a git path, don't ignore
|
||||
}
|
||||
// Ignore everything inside .git/ except the allowlisted patterns.
|
||||
!is_commit_related_git_file(path) && !is_index_lock_file(path)
|
||||
!is_commit_related_git_file(path)
|
||||
&& !is_index_lock_file(path)
|
||||
&& !is_remote_tracking_ref(path)
|
||||
&& !is_tracking_state_git_file(path)
|
||||
}
|
||||
|
||||
pub fn path_passes_filters(path: &Path, gitignores: &[Gitignore]) -> bool {
|
||||
let to_check_path = if path.exists() {
|
||||
match dunce::canonicalize(path) {
|
||||
Ok(canonical_path) => canonical_path,
|
||||
Err(_) => return false,
|
||||
/// Returns `true` when the directory at `path` should be registered for watching.
|
||||
/// Specifically for prefixes that lead to an allowlisted file and `false` for everything else inside `.git/`.
|
||||
pub fn should_watch_directory_in_git_path(path: &Path) -> bool {
|
||||
if !is_git_internal_path(path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Worktree paths: `.git/worktrees/<name>/...` only descends along the
|
||||
// path needed to reach the allowlisted children (HEAD, index.lock,
|
||||
// config.worktree, refs/heads/*, refs/remotes/<r>/*).
|
||||
if let Some(worktree_dir) = extract_worktree_git_dir(path) {
|
||||
// `path` is either the worktree gitdir itself or something under it.
|
||||
// Anything up to and including `.git/worktrees/<name>` must
|
||||
// be descended into so we can reach children.
|
||||
if path == worktree_dir || worktree_dir.starts_with(path) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
// Inside `.git/worktrees/<name>/...`. Apply the same allowlist logic as for the shared `.git/`.
|
||||
let Some(suffix) = git_suffix_components(path) else {
|
||||
return false;
|
||||
};
|
||||
return descend_allowlist_matches(&suffix);
|
||||
}
|
||||
|
||||
// Common `.git/` directory: allow descending along the path to
|
||||
// `.git/`, `.git/refs/heads/`, `.git/refs/remotes/<remote>/`, and
|
||||
// `.git/worktrees/<name>/`.
|
||||
let Some(suffix) = git_suffix_components(path) else {
|
||||
// Path is `.git/` itself — needed so we can reach allowlisted children.
|
||||
return true;
|
||||
};
|
||||
descend_allowlist_matches(&suffix)
|
||||
}
|
||||
|
||||
/// Returns `true` for an in-`.git/` directory suffix that lies on the way to an allowlisted file.
|
||||
/// `suffix` is the component sequence after the `.git` component (worktree indirection already stripped),
|
||||
/// so e.g. `.git/worktrees/<name>/refs/heads` is seen here as just `["refs", "heads"]`.
|
||||
///
|
||||
/// Only the first two components are inspected:
|
||||
/// - `top_level_dir` is the directory immediately under `.git/` (e.g. `refs`, `objects`, `worktrees`)
|
||||
/// and decides which subtree we're descending into.
|
||||
/// - `refs_subdir` is meaningful only when `top_level_dir == "refs"`, where it distinguishes
|
||||
/// the watched ref subtrees (`heads`, `remotes`) from pruned ones (`tags`, etc.).
|
||||
fn descend_allowlist_matches(suffix: &[Component<'_>]) -> bool {
|
||||
let top_level_dir = suffix.first().and_then(|c| c.as_os_str().to_str());
|
||||
let refs_subdir = suffix.get(1).and_then(|c| c.as_os_str().to_str());
|
||||
match top_level_dir {
|
||||
// `.git/refs`, `.git/refs/heads[/...]`, `.git/refs/remotes[/<r>[/...]]`.
|
||||
// `.git/refs/tags/*` and other refs subtrees stay pruned.
|
||||
Some("refs") => matches!(refs_subdir, None | Some("heads") | Some("remotes")),
|
||||
// Worktree dispatcher — needed to reach `.git/worktrees/<name>/...`.
|
||||
Some("worktrees") => true,
|
||||
// All other `.git/` subdirectories (objects, hooks, logs, info, lfs, …) are pruned.
|
||||
Some(_) => false,
|
||||
// `.git/` itself — descend so allowlisted children stay reachable.
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
&to_check_path,
|
||||
to_check_path.is_dir(),
|
||||
path,
|
||||
path.is_dir(),
|
||||
gitignores,
|
||||
true, /* check_ancestors */
|
||||
) && !should_ignore_git_path(&to_check_path)
|
||||
/* 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.
|
||||
///
|
||||
/// `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(
|
||||
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),
|
||||
Arc::new(|path: &Path| !should_ignore_git_path(path)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Determines whether a file should be parsed by a treesitter query. For now the main criteria is it shouldn't
|
||||
@@ -614,5 +1166,5 @@ impl DirectoryEntry {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "entry_test.rs"]
|
||||
#[path = "entry_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
Reference in New Issue
Block a user