Files
galaxy/crates/repo_metadata/src/entry_tests.rs
T

1175 lines
42 KiB
Rust

use std::fs;
use std::path::Path;
use ignore::gitignore::Gitignore;
use super::{matches_gitignores, Entry, IgnoredPathStrategy};
#[cfg(unix)]
use crate::StandingQueryContent;
use crate::{StandingQueryDefinitions, StandingQueryResults};
#[test]
fn test_git_path_filtering_allowlist() {
use std::path::Path;
use super::{
is_commit_related_git_file, is_common_git_config, is_index_lock_file,
is_remote_tracking_ref, is_tracking_state_git_file, should_ignore_git_path,
};
// Non-git paths should not be ignored
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/src/main.rs"
)));
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/README.md"
)));
// .git directory itself should be ignored
assert!(should_ignore_git_path(Path::new("/home/user/project/.git")));
// Allowlisted: commit-related files are NOT ignored
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/.git/HEAD"
)));
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/.git/refs/heads/main"
)));
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/.git/refs/heads/feature-branch"
)));
// Allowlisted: index.lock is NOT ignored
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/.git/index.lock"
)));
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/.git/config"
)));
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/.git/refs/remotes/origin/main"
)));
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/.git/refs/remotes/origin/feature/nested"
)));
// Everything else in .git/ IS ignored
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/index"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/COMMIT_EDITMSG"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/FETCH_HEAD"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/ORIG_HEAD"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/refs/tags/v1.0"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/refs/remotes/origin"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/objects/abc123"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/hooks/pre-commit"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/logs/HEAD"
)));
// Worktree paths: allowlisted patterns under .git/worktrees/<name>/
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/.git/worktrees/my-wt/HEAD"
)));
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/.git/worktrees/my-wt/index.lock"
)));
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/.git/worktrees/my-wt/config.worktree"
)));
// Non-allowlisted worktree paths are still ignored
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/worktrees/my-wt/index"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/worktrees/my-wt/COMMIT_EDITMSG"
)));
// worktrees dir itself (no content after worktree name) is ignored
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/worktrees"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/worktrees/my-wt"
)));
// is_commit_related_git_file
assert!(is_commit_related_git_file(Path::new("/repo/.git/HEAD")));
assert!(is_commit_related_git_file(Path::new(
"/repo/.git/refs/heads/main"
)));
assert!(is_commit_related_git_file(Path::new(
"/repo/.git/worktrees/wt/HEAD"
)));
assert!(!is_commit_related_git_file(Path::new(
"/repo/.git/index.lock"
)));
assert!(!is_commit_related_git_file(Path::new(
"/repo/.git/refs/tags/v1"
)));
// is_index_lock_file
assert!(is_index_lock_file(Path::new("/repo/.git/index.lock")));
assert!(is_index_lock_file(Path::new(
"/repo/.git/worktrees/wt/index.lock"
)));
assert!(!is_index_lock_file(Path::new("/repo/.git/HEAD")));
assert!(!is_index_lock_file(Path::new("/repo/.git/index")));
// Remote-tracking refs
assert!(is_remote_tracking_ref(Path::new(
"/repo/.git/refs/remotes/origin/main"
)));
assert!(is_remote_tracking_ref(Path::new(
"/repo/.git/refs/remotes/origin/feature/nested"
)));
assert!(!is_remote_tracking_ref(Path::new(
"/repo/.git/refs/remotes/origin"
)));
assert!(!is_remote_tracking_ref(Path::new(
"/repo/.git/worktrees/wt/refs/remotes/origin/main"
)));
assert!(!is_remote_tracking_ref(Path::new(
"/repo/.git/refs/heads/main"
)));
// Tracking-state files
assert!(is_tracking_state_git_file(Path::new("/repo/.git/HEAD")));
assert!(is_tracking_state_git_file(Path::new("/repo/.git/config")));
assert!(is_tracking_state_git_file(Path::new(
"/repo/.git/worktrees/wt/config.worktree"
)));
assert!(!is_tracking_state_git_file(Path::new(
"/repo/.git/refs/remotes/origin/main"
)));
// Common config
assert!(is_common_git_config(Path::new("/repo/.git/config")));
assert!(!is_common_git_config(Path::new(
"/repo/.git/worktrees/wt/config.worktree"
)));
// Test Windows-style paths (only on Windows, as path parsing is platform-specific)
#[cfg(windows)]
{
assert!(!should_ignore_git_path(Path::new(
r"C:\Users\user\project\.git\HEAD"
)));
assert!(!should_ignore_git_path(Path::new(
r"C:\Users\user\project\.git\index.lock"
)));
assert!(should_ignore_git_path(Path::new(
r"C:\Users\user\project\.git\index"
)));
}
}
/// 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 = galaxy_util::standardized_path::StandardizedPath::try_from_local(path).ok()?;
if entry.path() == &std_path {
return Some(entry);
}
let super::Entry::Directory(directory) = entry else {
return None;
};
directory
.children
.iter()
.find_map(|child| find_entry(child, path))
}
fn build_skill_tree_with_gitignore(root: &std::path::Path, gitignore: &str) -> super::Entry {
std::fs::write(root.join(".gitignore"), gitignore).unwrap();
let mut files = Vec::new();
let mut gitignores = Vec::new();
let mut file_limit = 1000;
super::Entry::build_tree_with_force_included_paths(
root,
&mut files,
&mut gitignores,
Some(&mut file_limit),
super::BuildTreeOptions {
max_depth: 200,
current_depth: 0,
ignored_path_strategy: &super::IgnoredPathStrategy::IncludeLazy,
force_included_paths: &[std::path::PathBuf::from(".agents/skills")],
budget_exceeded_behavior: super::BudgetExceededBehavior::StopAndLazyLoad,
},
)
.unwrap()
}
#[test]
fn standing_queries_report_skills_below_an_ignored_directory() {
virtual_fs::VirtualFS::test("standing_queries_report_ignored_skills", |dirs, mut vfs| {
vfs.mkdir("repo/.agents/skills/test")
.with_files(vec![virtual_fs::Stub::FileWithContent(
"repo/.agents/skills/test/SKILL.md",
"name: test",
)]);
let repo = dirs.tests().join("repo");
std::fs::write(repo.join(".gitignore"), ".agents/\n").unwrap();
let mut files = Vec::new();
let mut gitignores = Vec::new();
let mut results = StandingQueryResults::default();
let mut definitions = StandingQueryDefinitions::default();
definitions.set_project_skill_provider_paths([std::path::PathBuf::from(".agents/skills")]);
let tree = Entry::build_tree_with_standing_queries(
&repo,
&mut files,
&mut gitignores,
None,
super::BuildTreeOptions {
max_depth: 200,
current_depth: 0,
ignored_path_strategy: &IgnoredPathStrategy::IncludeLazy,
force_included_paths: &[std::path::PathBuf::from(".agents/skills")],
budget_exceeded_behavior: super::BudgetExceededBehavior::StopAndLazyLoad,
},
&mut results,
&definitions,
)
.unwrap();
let agents = find_entry(&tree, &repo.join(".agents")).expect(".agents should be present");
assert!(agents.loaded());
assert!(find_entry(&tree, &repo.join(".agents/skills/test/SKILL.md")).is_some());
let skill_path = galaxy_util::standardized_path::StandardizedPath::try_from_local(
&repo.join(".agents/skills/test/SKILL.md"),
)
.unwrap();
assert!(results
.project_skills()
.any(|content| content.path == skill_path && !content.is_directory));
});
}
#[cfg(unix)]
#[test]
fn standing_queries_report_symlinked_skills_without_materializing_symlinked_directories() {
virtual_fs::VirtualFS::test(
"standing_queries_report_symlinked_skills",
|dirs, mut vfs| {
vfs.mkdir("repo/.agents/skills")
.mkdir("targets/linked")
.with_files(vec![virtual_fs::Stub::FileWithContent(
"targets/linked/SKILL.md",
"name: linked",
)]);
let repo = dirs.tests().join("repo");
let linked_directory = repo.join(".agents/skills/linked");
std::os::unix::fs::symlink(dirs.tests().join("targets/linked"), &linked_directory)
.unwrap();
let mut files = Vec::new();
let mut gitignores = Vec::new();
let mut results = StandingQueryResults::default();
let mut definitions = StandingQueryDefinitions::default();
definitions
.set_project_skill_provider_paths([std::path::PathBuf::from(".agents/skills")]);
let tree = Entry::build_tree_with_standing_queries(
&repo,
&mut files,
&mut gitignores,
None,
super::BuildTreeOptions {
max_depth: 200,
current_depth: 0,
ignored_path_strategy: &IgnoredPathStrategy::IncludeLazy,
force_included_paths: &[],
budget_exceeded_behavior: super::BudgetExceededBehavior::StopAndLazyLoad,
},
&mut results,
&definitions,
)
.unwrap();
assert!(find_entry(&tree, &linked_directory).is_none());
assert!(results.project_skills().any(|content| {
content
== &StandingQueryContent::file(
galaxy_util::standardized_path::StandardizedPath::try_from_local(
&linked_directory.join("SKILL.md"),
)
.unwrap(),
)
}));
},
);
}
#[test]
fn standing_queries_do_not_report_rules_below_an_unloaded_shallow_directory() {
virtual_fs::VirtualFS::test("standing_queries_report_shallow_rules", |dirs, mut vfs| {
vfs.mkdir("repo/src/deep")
.with_files(vec![virtual_fs::Stub::FileWithContent(
"repo/src/deep/WARP.md",
"project rules",
)]);
let repo = dirs.tests().join("repo");
let mut files = Vec::new();
let mut gitignores = Vec::new();
let mut results = StandingQueryResults::default();
let tree = Entry::build_tree_with_standing_queries(
&repo,
&mut files,
&mut gitignores,
None,
super::BuildTreeOptions {
max_depth: 1,
current_depth: 0,
ignored_path_strategy: &IgnoredPathStrategy::IncludeLazy,
force_included_paths: &[],
budget_exceeded_behavior: super::BudgetExceededBehavior::StopAndLazyLoad,
},
&mut results,
&StandingQueryDefinitions::default(),
)
.unwrap();
let src = find_entry(&tree, &repo.join("src")).expect("src should be represented");
assert!(!src.loaded());
assert!(find_entry(&tree, &repo.join("src/deep/WARP.md")).is_none());
let rule_path = galaxy_util::standardized_path::StandardizedPath::try_from_local(
&repo.join("src/deep/WARP.md"),
)
.unwrap();
assert!(!results
.project_rules()
.any(|content| content.path == rule_path));
});
}
#[test]
fn shallow_tree_expands_force_included_skill_branch_only() {
virtual_fs::VirtualFS::test("shallow_tree_force_included_skills", |dirs, mut vfs| {
vfs.mkdir("workspace/.agents/skills/review")
.mkdir("workspace/src/deep")
.with_files(vec![
virtual_fs::Stub::FileWithContent(
"workspace/.agents/skills/review/SKILL.md",
"name: review",
),
virtual_fs::Stub::FileWithContent("workspace/src/deep/WARP.md", "project rules"),
]);
let workspace = dirs.tests().join("workspace");
let skill_path = workspace.join(".agents/skills/review/SKILL.md");
let rule_path = workspace.join("src/deep/WARP.md");
let mut files = Vec::new();
let mut gitignores = Vec::new();
let mut results = StandingQueryResults::default();
let mut definitions = StandingQueryDefinitions::default();
definitions.set_project_skill_provider_paths([std::path::PathBuf::from(".agents/skills")]);
let tree = Entry::build_tree_with_standing_queries(
&workspace,
&mut files,
&mut gitignores,
None,
super::BuildTreeOptions {
max_depth: 1,
current_depth: 0,
ignored_path_strategy: &IgnoredPathStrategy::IncludeLazy,
force_included_paths: &[std::path::PathBuf::from(".agents/skills")],
budget_exceeded_behavior: super::BudgetExceededBehavior::StopAndLazyLoad,
},
&mut results,
&definitions,
)
.unwrap();
let agents = find_entry(&tree, &workspace.join(".agents"))
.expect("force-included ancestor should be represented");
assert!(agents.loaded());
assert!(find_entry(&tree, &skill_path).is_some());
let src = find_entry(&tree, &workspace.join("src"))
.expect("unrelated shallow directory should be represented");
assert!(!src.loaded());
assert!(find_entry(&tree, &rule_path).is_none());
let skill_path =
galaxy_util::standardized_path::StandardizedPath::try_from_local(&skill_path).unwrap();
let rule_path =
galaxy_util::standardized_path::StandardizedPath::try_from_local(&rule_path).unwrap();
assert!(results
.project_skills()
.any(|content| content.path == skill_path && !content.is_directory));
assert!(!results
.project_rules()
.any(|content| content.path == rule_path));
});
}
#[test]
fn ignored_directory_stays_lazy() {
virtual_fs::VirtualFS::test("ignored_dir_lazy", |dirs, mut vfs| {
vfs.mkdir("repo/target/debug")
.with_files(vec![virtual_fs::Stub::FileWithContent(
"repo/target/debug/app",
"binary",
)]);
let repo = dirs.tests().join("repo");
std::fs::write(repo.join(".gitignore"), "target/\n").unwrap();
let mut files = Vec::new();
let mut gitignores = Vec::new();
let tree = Entry::build_tree(
&repo,
&mut files,
&mut gitignores,
None,
200,
0,
&IgnoredPathStrategy::IncludeLazy,
super::BudgetExceededBehavior::StopAndLazyLoad,
)
.unwrap();
let target_dir = find_entry(&tree, &repo.join("target"))
.expect("ignored unrelated directory should be present as lazy");
assert!(target_dir.ignored());
assert!(!target_dir.loaded());
assert!(find_entry(&tree, &repo.join("target/debug/app")).is_none());
});
}
#[test]
fn ignored_skill_file_is_loaded_for_registered_provider_path() {
virtual_fs::VirtualFS::test("ignored_skill_file_loaded", |dirs, mut vfs| {
vfs.mkdir("repo/.agents/skills/test")
.with_files(vec![virtual_fs::Stub::FileWithContent(
"repo/.agents/skills/test/SKILL.md",
"name: test",
)]);
let repo = dirs.tests().join("repo");
let tree = build_skill_tree_with_gitignore(&repo, ".agents/skills/test/SKILL.md\n");
let skill_file = find_entry(&tree, &repo.join(".agents/skills/test/SKILL.md"))
.expect("ignored skill file should be present");
assert!(skill_file.ignored());
});
}
#[test]
fn ignored_skill_directory_is_loaded_for_registered_provider_path() {
virtual_fs::VirtualFS::test("ignored_skill_dir_loaded", |dirs, mut vfs| {
vfs.mkdir("repo/.agents/skills/test")
.with_files(vec![virtual_fs::Stub::FileWithContent(
"repo/.agents/skills/test/SKILL.md",
"name: test",
)]);
let repo = dirs.tests().join("repo");
let tree = build_skill_tree_with_gitignore(&repo, ".agents/skills/test/\n");
let skill_dir = find_entry(&tree, &repo.join(".agents/skills/test"))
.expect("ignored skill directory should be present");
assert!(skill_dir.ignored());
assert!(skill_dir.loaded());
assert!(find_entry(&tree, &repo.join(".agents/skills/test/SKILL.md")).is_some());
});
}
#[test]
fn ignored_agents_directory_is_loaded_for_registered_provider_path() {
virtual_fs::VirtualFS::test("ignored_agents_dir_loaded", |dirs, mut vfs| {
vfs.mkdir("repo/.agents/skills/test")
.with_files(vec![virtual_fs::Stub::FileWithContent(
"repo/.agents/skills/test/SKILL.md",
"name: test",
)]);
let repo = dirs.tests().join("repo");
let tree = build_skill_tree_with_gitignore(&repo, ".agents/\n");
let agents_dir = find_entry(&tree, &repo.join(".agents"))
.expect("ignored .agents directory should be present");
assert!(agents_dir.ignored());
assert!(agents_dir.loaded());
assert!(find_entry(&tree, &repo.join(".agents/skills/test/SKILL.md")).is_some());
});
}
#[test]
fn ignored_agents_skills_directory_is_loaded_for_registered_provider_path() {
virtual_fs::VirtualFS::test("ignored_agents_skills_dir_loaded", |dirs, mut vfs| {
vfs.mkdir("repo/.agents/skills/test")
.with_files(vec![virtual_fs::Stub::FileWithContent(
"repo/.agents/skills/test/SKILL.md",
"name: test",
)]);
let repo = dirs.tests().join("repo");
let tree = build_skill_tree_with_gitignore(&repo, ".agents/skills/\n");
let skills_dir = find_entry(&tree, &repo.join(".agents/skills"))
.expect("ignored .agents/skills directory should be present");
assert!(skills_dir.ignored());
assert!(skills_dir.loaded());
assert!(find_entry(&tree, &repo.join(".agents/skills/test/SKILL.md")).is_some());
});
}
#[test]
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")
.with_files(vec![
virtual_fs::Stub::FileWithContent(
"repo/.agents/skills/test/SKILL.md",
"name: test",
),
virtual_fs::Stub::FileWithContent("repo/target/debug/app", "binary"),
]);
let repo = dirs.tests().join("repo");
let tree = build_skill_tree_with_gitignore(&repo, "target/\n");
let target_dir = find_entry(&tree, &repo.join("target"))
.expect("ignored unrelated directory should be present as lazy");
assert!(target_dir.ignored());
assert!(!target_dir.loaded());
assert!(find_entry(&tree, &repo.join("target/debug/app")).is_none());
});
}
#[test]
fn build_tree_marks_descendants_of_ignored_directory_as_ignored() {
let temp_dir = tempfile::tempdir().unwrap();
let root_path = dunce::canonicalize(temp_dir.path()).unwrap();
fs::write(root_path.join(".gitignore"), "ignored-dir/\n").unwrap();
fs::create_dir(root_path.join("ignored-dir")).unwrap();
fs::write(root_path.join("ignored-dir").join("ignored-file.txt"), "").unwrap();
let mut files = Vec::new();
let mut gitignores = Vec::<Gitignore>::new();
let tree = Entry::build_tree(
&root_path,
&mut files,
&mut gitignores,
None,
10,
0,
&IgnoredPathStrategy::Include,
super::BudgetExceededBehavior::StopAndLazyLoad,
)
.unwrap();
let Entry::Directory(root) = tree else {
panic!("root should be a directory");
};
let ignored_dir = root
.children
.iter()
.find(|entry| entry.path().file_name() == Some("ignored-dir"))
.unwrap();
let Entry::Directory(ignored_dir) = ignored_dir else {
panic!("ignored child should be a directory");
};
assert!(ignored_dir.ignored);
let ignored_file = ignored_dir
.children
.iter()
.find(|entry| entry.path().file_name() == Some("ignored-file.txt"))
.unwrap();
assert!(ignored_file.ignored());
}
#[test]
fn lazy_loaded_ignored_directory_marks_loaded_children_as_ignored() {
let temp_dir = tempfile::tempdir().unwrap();
let root_path = dunce::canonicalize(temp_dir.path()).unwrap();
fs::write(root_path.join(".gitignore"), "ignored-dir/\n").unwrap();
fs::create_dir(root_path.join("ignored-dir")).unwrap();
fs::write(root_path.join("ignored-dir").join("ignored-file.txt"), "").unwrap();
let mut files = Vec::new();
let mut gitignores = Vec::<Gitignore>::new();
let mut tree = Entry::build_tree(
&root_path,
&mut files,
&mut gitignores,
None,
10,
0,
&IgnoredPathStrategy::IncludeLazy,
super::BudgetExceededBehavior::StopAndLazyLoad,
)
.unwrap();
let ignored_path = root_path.join("ignored-dir");
let ignored_dir = tree.find_mut(&ignored_path).unwrap();
let Entry::Directory(directory) = ignored_dir else {
panic!("ignored child should be a directory");
};
assert!(directory.ignored);
assert!(!directory.loaded);
assert!(directory.children.is_empty());
ignored_dir.load(&mut gitignores).unwrap();
let Entry::Directory(directory) = ignored_dir else {
panic!("ignored child should still be a directory");
};
assert!(directory.ignored);
assert!(directory.loaded);
let ignored_file = directory
.children
.iter()
.find(|entry| entry.path().file_name() == Some("ignored-file.txt"))
.unwrap();
assert!(ignored_file.ignored());
}
#[test]
fn should_watch_directory_in_git_path_prunes_non_allowlisted_subtrees() {
use super::should_watch_directory_in_git_path;
for path in [
"/repo/.git",
"/repo/.git/refs",
"/repo/.git/refs/heads",
"/repo/.git/refs/remotes",
"/repo/.git/refs/remotes/origin",
"/repo/.git/worktrees",
"/repo/.git/worktrees/my-wt",
"/repo/.git/worktrees/my-wt/refs",
"/repo/.git/worktrees/my-wt/refs/heads",
] {
assert!(
should_watch_directory_in_git_path(Path::new(path)),
"{path} should remain traversable so allowlisted git children stay reachable"
);
}
for path in [
"/repo/.git/objects",
"/repo/.git/hooks",
"/repo/.git/logs",
"/repo/.git/info",
"/repo/.git/lfs",
"/repo/.git/refs/tags",
"/repo/.git/worktrees/my-wt/objects",
"/repo/.git/worktrees/my-wt/logs",
] {
assert!(
!should_watch_directory_in_git_path(Path::new(path)),
"{path} should be pruned from recursive watcher registration"
);
}
assert!(!should_watch_directory_in_git_path(Path::new(
"/repo/.git/objects/ab/blob"
)));
// The predicate is only consulted on directories during recursive registration;
// file paths like `.git/HEAD` would never actually reach it, but the default
// false return here documents that they're not treated as descend roots.
assert!(!should_watch_directory_in_git_path(Path::new(
"/repo/.git/HEAD"
)));
assert!(!should_watch_directory_in_git_path(Path::new(
"/repo/.git/config"
)));
}
/// Documents the watch-filter invariant: gitignore is applied only by the
/// descend predicate (`should_watch_repo_directory`), so gitignored paths are
/// pruned from recursive registration but their events are still emitted (the
/// emit predicate only suppresses non-allowlisted `.git/` internals).
#[test]
fn gitignore_affects_descend_predicate_but_not_emitted_events() {
use super::{gitignores_for_directory, should_ignore_git_path, should_watch_repo_directory};
let temp_dir = tempfile::tempdir().unwrap();
let root_path = dunce::canonicalize(temp_dir.path()).unwrap();
fs::write(root_path.join(".gitignore"), "node_modules/\n").unwrap();
fs::create_dir(root_path.join("node_modules")).unwrap();
fs::create_dir(root_path.join("src")).unwrap();
let gitignores = gitignores_for_directory(&root_path);
let node_modules = root_path.join("node_modules");
let src = root_path.join("src");
// Descend predicate: the gitignored dir is pruned from recursive
// registration, while a tracked dir is still descended into.
assert!(!should_watch_repo_directory(
&node_modules,
&gitignores,
&[]
));
assert!(should_watch_repo_directory(&src, &gitignores, &[]));
// Emit predicate building block (`!should_ignore_git_path`): gitignored,
// non-`.git` paths are NOT suppressed, so their events still flow. Only
// non-allowlisted `.git/` internals are suppressed.
assert!(!should_ignore_git_path(&node_modules));
assert!(!should_ignore_git_path(&node_modules.join("pkg/index.js")));
assert!(should_ignore_git_path(Path::new(
"/repo/.git/objects/ab/blob"
)));
}
#[test]
fn test_is_shared_git_ref() {
use super::is_shared_git_ref;
// Shared refs — broadcast to all repos
assert!(is_shared_git_ref(Path::new("/repo/.git/refs/heads/main")));
assert!(is_shared_git_ref(Path::new(
"/repo/.git/refs/heads/feature"
)));
// Repo-specific — NOT shared
assert!(!is_shared_git_ref(Path::new("/repo/.git/HEAD")));
assert!(!is_shared_git_ref(Path::new("/repo/.git/index.lock")));
// Worktree paths — NOT shared
assert!(!is_shared_git_ref(Path::new(
"/repo/.git/worktrees/foo/HEAD"
)));
assert!(!is_shared_git_ref(Path::new(
"/repo/.git/worktrees/foo/refs/heads/main"
)));
// Other .git internals — NOT shared
assert!(!is_shared_git_ref(Path::new("/repo/.git/refs/tags/v1")));
assert!(!is_shared_git_ref(Path::new(
"/repo/.git/refs/remotes/origin/main"
)));
assert!(!is_shared_git_ref(Path::new("/repo/.git/config")));
// Not a git path at all
assert!(!is_shared_git_ref(Path::new("/repo/src/main.rs")));
}
#[test]
fn test_extract_worktree_git_dir() {
use std::path::{Path, PathBuf};
use super::extract_worktree_git_dir;
// Standard worktree path extracts the per-worktree gitdir
assert_eq!(
extract_worktree_git_dir(Path::new("/repo/.git/worktrees/foo/HEAD")),
Some(PathBuf::from("/repo/.git/worktrees/foo"))
);
assert_eq!(
extract_worktree_git_dir(Path::new("/repo/.git/worktrees/bar/index.lock")),
Some(PathBuf::from("/repo/.git/worktrees/bar"))
);
// Non-worktree paths return None
assert_eq!(extract_worktree_git_dir(Path::new("/repo/.git/HEAD")), None);
assert_eq!(
extract_worktree_git_dir(Path::new("/repo/.git/refs/heads/main")),
None
);
assert_eq!(
extract_worktree_git_dir(Path::new("/repo/src/main.rs")),
None
);
// Edge case: not enough depth after worktrees/
assert_eq!(
extract_worktree_git_dir(Path::new("/repo/.git/worktrees")),
None
);
assert_eq!(
extract_worktree_git_dir(Path::new("/repo/.git/worktrees/foo")),
None
);
}
/// 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,
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_force_included_paths(
root,
&mut files,
&mut gitignores,
Some(&mut file_limit),
super::BuildTreeOptions {
max_depth: 200,
current_depth: 0,
ignored_path_strategy: &super::IgnoredPathStrategy::IncludeLazy,
force_included_paths,
budget_exceeded_behavior: super::BudgetExceededBehavior::StopAndLazyLoad,
},
)
.unwrap()
}
#[test]
fn build_tree_budget_covers_breadth_first_and_leaves_remainder_unloaded() {
let temp_dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(temp_dir.path()).unwrap();
// 5 top-level dirs, each with 2 direct files and a `sub` dir of 3 files.
for i in 0..5 {
let d = root.join(format!("d{i}"));
fs::create_dir(&d).unwrap();
fs::write(d.join("f0.txt"), "").unwrap();
fs::write(d.join("f1.txt"), "").unwrap();
let sub = d.join("sub");
fs::create_dir(&sub).unwrap();
for j in 0..3 {
fs::write(sub.join(format!("g{j}.txt")), "").unwrap();
}
}
// Budget exactly covers the 10 level-1 files; level-2 `sub` dirs are cut.
let tree = build_with_budget(&root, 10, &[]);
// Root stays loaded (no whole-tree depth-1 collapse on budget exhaustion).
let Entry::Directory(root_dir) = &tree else {
panic!("root should be a directory");
};
assert!(root_dir.loaded);
for i in 0..5 {
let d_path = root.join(format!("d{i}"));
let d = find_entry(&tree, &d_path).expect("level-1 dir present");
// All level-1 dirs are expanded before the budget is spent on level 2.
assert!(d.loaded(), "all level-1 dirs are covered breadth-first");
assert!(find_entry(&tree, &d_path.join("f0.txt")).is_some());
// Level-2 dirs beyond the budget remain unloaded placeholders.
let sub = find_entry(&tree, &d_path.join("sub")).expect("sub placeholder present");
assert!(
!sub.loaded(),
"level-2 dirs beyond the budget stay unloaded"
);
assert!(find_entry(&tree, &d_path.join("sub").join("g0.txt")).is_none());
}
}
#[test]
fn build_tree_budget_does_not_prune_force_included_paths() {
let temp_dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(temp_dir.path()).unwrap();
// Files at the root so the budget is exhausted almost immediately.
for i in 0..5 {
fs::write(root.join(format!("f{i}.txt")), "").unwrap();
}
// A skill provider directory nested several levels under the root.
let skill_dir = root.join(".agents").join("skills").join("test");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("SKILL.md"), "name: test").unwrap();
// Tiny budget: the root expands and is immediately exhausted, but the
// 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(),
"force-included path files must load even when the budget is exhausted"
);
}
#[test]
fn build_tree_full_coverage_reaches_full_depth_within_budget() {
let temp_dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(temp_dir.path()).unwrap();
// Nested chain a/b/c/d with a file at the deepest level.
let deep = root.join("a").join("b").join("c").join("d");
fs::create_dir_all(&deep).unwrap();
fs::write(deep.join("leaf.txt"), "").unwrap();
fs::write(root.join("top.txt"), "").unwrap();
// A generous budget must fully cover this small tree to its full depth.
let tree = build_with_budget(&root, 1000, &[]);
for dir in [
root.join("a"),
root.join("a").join("b"),
root.join("a").join("b").join("c"),
deep.clone(),
] {
let entry = find_entry(&tree, &dir).expect("dir present");
assert!(
entry.loaded(),
"dirs are fully loaded under a generous budget"
);
}
assert!(find_entry(&tree, &deep.join("leaf.txt")).is_some());
assert!(find_entry(&tree, &root.join("top.txt")).is_some());
}
#[test]
fn build_tree_directories_do_not_consume_budget() {
let temp_dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(temp_dir.path()).unwrap();
// A deep chain of empty directories (no files at all).
let deep = root.join("l1").join("l2").join("l3").join("l4");
fs::create_dir_all(&deep).unwrap();
// Even a budget of 1 fully expands the tree, because only files — not
// directories — draw down the budget.
let tree = build_with_budget(&root, 1, &[]);
let leaf = find_entry(&tree, &deep).expect("deepest dir present");
assert!(
leaf.loaded(),
"directories must not consume the file budget"
);
}
#[test]
fn build_tree_gitignored_files_do_not_consume_budget() {
let temp_dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(temp_dir.path()).unwrap();
fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
// A gitignored directory with many files (e.g. node_modules/target).
let ignored = root.join("ignored");
fs::create_dir(&ignored).unwrap();
for i in 0..50 {
fs::write(ignored.join(format!("big{i}.txt")), "").unwrap();
}
// Plus the tracked files at the root (`.gitignore` itself counts as one).
fs::write(root.join("tracked0.txt"), "").unwrap();
fs::write(root.join("tracked1.txt"), "").unwrap();
// Budget only covers the 3 tracked root files. The 50 gitignored files must
// not draw it down, so both tracked files are still indexed and the ignored
// directory stays a lazy (unloaded) placeholder.
let tree = build_with_budget(&root, 3, &[]);
assert!(find_entry(&tree, &root.join("tracked0.txt")).is_some());
assert!(find_entry(&tree, &root.join("tracked1.txt")).is_some());
let ignored_dir = find_entry(&tree, &ignored).expect("ignored dir placeholder present");
assert!(ignored_dir.ignored());
assert!(
!ignored_dir.loaded(),
"gitignored dirs stay lazy and never consume the budget"
);
}
#[test]
fn build_tree_fail_fast_errors_when_budget_exceeded() {
let temp_dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(temp_dir.path()).unwrap();
for i in 0..10 {
fs::write(root.join(format!("f{i}.txt")), "").unwrap();
}
let mut files = Vec::new();
let mut gitignores = Vec::new();
let mut file_limit = 5;
let result = Entry::build_tree(
&root,
&mut files,
&mut gitignores,
Some(&mut file_limit),
200,
0,
&IgnoredPathStrategy::Exclude,
super::BudgetExceededBehavior::FailFast,
);
assert!(
matches!(result, Err(super::BuildTreeError::ExceededMaxFileLimit)),
"FailFast must abort when the file budget is exceeded"
);
}
#[test]
fn build_tree_fail_fast_succeeds_within_budget() {
let temp_dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(temp_dir.path()).unwrap();
for i in 0..3 {
fs::write(root.join(format!("f{i}.txt")), "").unwrap();
}
let mut files = Vec::new();
let mut gitignores = Vec::new();
let mut file_limit = 10;
let result = Entry::build_tree(
&root,
&mut files,
&mut gitignores,
Some(&mut file_limit),
200,
0,
&IgnoredPathStrategy::Exclude,
super::BudgetExceededBehavior::FailFast,
);
assert!(result.is_ok(), "FailFast must succeed when within budget");
}