Skip to main content

code_moniker_workspace/
gitignore.rs

1use std::path::Path;
2
3use ignore::Match;
4use ignore::gitignore::{Gitignore, GitignoreBuilder};
5
6/// The `.gitignore`/`.ignore` rules in effect under a root directory, kept as
7/// one matcher per directory and evaluated deepest-first so a nested rule wins
8/// over a shallower one (standard gitignore precedence).
9#[derive(Clone, Debug)]
10pub struct GitignoreStack {
11	layers: Vec<Gitignore>,
12}
13
14impl GitignoreStack {
15	pub fn for_root(root: &Path) -> Self {
16		let mut layers = Vec::new();
17		collect_layers(root, &mut layers);
18		layers.sort_by_key(|layer| std::cmp::Reverse(layer.path().components().count()));
19		Self { layers }
20	}
21
22	pub fn for_path(root: &Path, path: &Path) -> Self {
23		let mut layers = Vec::new();
24		push_layer(root, &mut layers);
25		let parent = path.parent().unwrap_or(path);
26		if let Ok(relative) = parent.strip_prefix(root) {
27			let mut directory = root.to_path_buf();
28			for component in relative.components() {
29				directory.push(component.as_os_str());
30				push_layer(&directory, &mut layers);
31			}
32		}
33		layers.sort_by_key(|layer| std::cmp::Reverse(layer.path().components().count()));
34		Self { layers }
35	}
36
37	pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
38		for layer in &self.layers {
39			if !path.starts_with(layer.path()) {
40				continue;
41			}
42			match layer.matched_path_or_any_parents(path, is_dir) {
43				Match::Ignore(_) => return true,
44				Match::Whitelist(_) => return false,
45				Match::None => {}
46			}
47		}
48		false
49	}
50}
51
52fn collect_layers(dir: &Path, out: &mut Vec<Gitignore>) {
53	push_layer(dir, out);
54	if let Ok(entries) = std::fs::read_dir(dir) {
55		for entry in entries.flatten() {
56			let path = entry.path();
57			let name = path.file_name().and_then(|n| n.to_str());
58			if path.is_dir() && !name.is_some_and(is_ignored_dir_name) {
59				collect_layers(&path, out);
60			}
61		}
62	}
63}
64
65fn push_layer(dir: &Path, out: &mut Vec<Gitignore>) {
66	let mut builder = GitignoreBuilder::new(dir);
67	let mut has_rules = false;
68	for name in [".gitignore", ".ignore"] {
69		let path = dir.join(name);
70		if path.is_file() {
71			let _ = builder.add(&path);
72			has_rules = true;
73		}
74	}
75	if has_rules {
76		if let Ok(gitignore) = builder.build() {
77			out.push(gitignore);
78		}
79	}
80}
81
82/// Directory names never worth descending into when collecting ignore rules or
83/// classifying live events: VCS, build, and cache dirs that are always ignored.
84pub fn is_ignored_dir_name(name: &str) -> bool {
85	matches!(
86		name,
87		".code-moniker-cache" | ".git" | ".gradle" | "target" | "node_modules" | "build" | "dist"
88	)
89}
90
91#[cfg(test)]
92mod tests {
93	use super::*;
94
95	#[test]
96	fn nested_anchored_pattern_stays_in_its_directory() {
97		let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("temp workspace");
98		let root = temp.path();
99		std::fs::write(root.join(".gitignore"), "*.log\n").expect("root gitignore");
100		std::fs::create_dir_all(root.join("nested")).expect("nested dir");
101		std::fs::write(root.join("nested/.gitignore"), "/keep.rs\n").expect("nested gitignore");
102
103		let rules = GitignoreStack::for_root(root);
104
105		assert!(rules.is_ignored(&root.join("nested/keep.rs"), false));
106		assert!(!rules.is_ignored(&root.join("keep.rs"), false));
107		assert!(rules.is_ignored(&root.join("server.log"), false));
108	}
109
110	#[test]
111	fn nested_whitelist_overrides_shallower_ignore() {
112		let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("temp workspace");
113		let root = temp.path();
114		std::fs::write(root.join(".gitignore"), "*.rs\n").expect("root gitignore");
115		std::fs::create_dir_all(root.join("keep")).expect("keep dir");
116		std::fs::write(root.join("keep/.gitignore"), "!*.rs\n").expect("nested gitignore");
117
118		let rules = GitignoreStack::for_root(root);
119
120		assert!(rules.is_ignored(&root.join("top.rs"), false));
121		assert!(!rules.is_ignored(&root.join("keep/lib.rs"), false));
122	}
123
124	#[test]
125	fn path_stack_loads_only_the_candidate_ancestor_chain() {
126		let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("temp workspace");
127		let root = temp.path();
128		std::fs::write(root.join(".gitignore"), "*.rs\n").expect("root gitignore");
129		std::fs::create_dir_all(root.join("keep/deep")).expect("candidate dirs");
130		std::fs::write(root.join("keep/.gitignore"), "!*.rs\n").expect("nested gitignore");
131		std::fs::create_dir_all(root.join("unrelated")).expect("unrelated dir");
132		std::fs::write(root.join("unrelated/.ignore"), "*.txt\n").expect("unrelated ignore");
133
134		let candidate = root.join("keep/deep/lib.rs");
135		let rules = GitignoreStack::for_path(root, &candidate);
136
137		assert!(!rules.is_ignored(&candidate, false));
138		assert_eq!(rules.layers.len(), 2);
139	}
140}