objects/worktree/worktree_ignore.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Ignore pattern helpers for worktree operations.
3//!
4//! `.heddleignore` follows the same syntax as `.gitignore`: literal
5//! names, leading `/` for root-anchored rules, trailing `/` for
6//! directory-only matches, `*` and `**` glob wildcards, character
7//! classes (`[abc]`), and `!` negation (whitelist) rules. The matcher
8//! delegates to the `ignore` crate's gitignore implementation so the
9//! semantics are spec-compliant; only the patterns themselves are
10//! sourced from `.heddleignore` instead of `.gitignore`.
11//!
12//! Three "root-admin" pattern names — `.heddle`, `.heddleignore`,
13//! and `.git` — get an implicit leading `/` so they match only at
14//! the repo root. This preserves the long-standing invariant that a
15//! nested `.heddle/` directory (e.g. an `examples/calculator/.heddle`
16//! fixture) is *captured*, not silently dropped. Operators who want
17//! the gitignore-spec "match anywhere" behavior for those names can
18//! write `**/<name>` explicitly.
19//!
20//! Root `.heddle/` is also a reserved-path hard-deny evaluated after
21//! user rules. A later `!.heddle/` (or similar) cannot un-ignore
22//! identity material. Nested fixture `.heddle/` trees are not reserved.
23
24use std::path::Path;
25
26use ignore::gitignore::{Gitignore, GitignoreBuilder};
27
28use super::worktree_reserved::is_reserved_worktree_path;
29
30/// Whether `path` is covered by any of the `.heddleignore` patterns.
31///
32/// `is_dir = true` is passed to the underlying gitignore matcher so
33/// trailing-slash rules (`target/`, `build/`) match the bare directory
34/// entry itself — not just paths *inside* it. This preserves the
35/// pre-existing in-house matcher's behavior, where `build/` on a bare
36/// `build` path returned `true`. Walker callers depend on this to
37/// prune entire directory subtrees before descending; the alternative
38/// (`is_dir = false`) caused unnecessary traversal of `target/`,
39/// `node_modules/`, and other build trees.
40///
41/// Non-directory rules (`*.log`, `node_modules`, `[Mm]akefile`) are
42/// unaffected — gitignore-spec rules without a trailing slash match
43/// regardless of the `is_dir` flag.
44pub fn should_ignore(path: &Path, patterns: &[String]) -> bool {
45 build_worktree_ignore(patterns).is_ignored(path)
46}
47
48/// A compiled `.heddleignore` matcher. Compiling the glob set is the
49/// expensive part; matching a single path against an already-built
50/// matcher is cheap. Callers that test many paths against the same
51/// patterns (e.g. counting unignored entries across a large diff)
52/// should build the matcher once and reuse it, rather than paying the
53/// per-path compile cost that the [`should_ignore`] convenience wrapper
54/// incurs.
55pub struct WorktreeIgnoreMatcher {
56 gi: Gitignore,
57}
58
59impl WorktreeIgnoreMatcher {
60 /// Whether `path` is covered by any of the compiled patterns. See
61 /// [`should_ignore`] for the matching semantics (`is_dir = true`,
62 /// negation handling).
63 pub fn is_ignored(&self, path: &Path) -> bool {
64 matched(&self.gi, path)
65 }
66}
67
68/// Compile a [`WorktreeIgnoreMatcher`] from the given pattern strings,
69/// once, for reuse across many path checks. This is the compile-once
70/// counterpart to [`should_ignore`], which rebuilds the matcher on
71/// every call.
72pub fn build_worktree_ignore(patterns: &[String]) -> WorktreeIgnoreMatcher {
73 WorktreeIgnoreMatcher {
74 gi: build_matcher(patterns),
75 }
76}
77
78/// Build a `Gitignore` matcher from the given pattern strings,
79/// translating the root-admin special cases (`.heddle`,
80/// `.heddleignore`, `.git`) into root-anchored gitignore syntax.
81fn build_matcher(patterns: &[String]) -> Gitignore {
82 // Root path is symbolic — paths fed to `matched` are interpreted
83 // relative to it. Callers always pass repo-relative paths, so the
84 // root just needs to be a stable, in-memory anchor.
85 let mut builder = GitignoreBuilder::new("");
86 for pattern in patterns {
87 let line = canonical_line(pattern);
88 // `add_line` returns Err only on malformed glob syntax. We
89 // silently skip malformed user patterns — heddle's ingest path
90 // shouldn't error on a typo'd `.heddleignore` line; it should
91 // ignore the bad rule and keep going.
92 let _ = builder.add_line(None, &line);
93 }
94 // `build()` only fails on internal compile errors. The empty
95 // matcher (`Gitignore::empty()`) matches nothing — the right
96 // failure mode if we get here.
97 builder.build().unwrap_or_else(|_| Gitignore::empty())
98}
99
100/// Rewrite root-admin special-case names into root-anchored
101/// gitignore syntax. Pass-through for every other pattern, so
102/// gitignore semantics (`*`, `**`, `[abc]`, `!negation`, trailing
103/// `/`, leading `/`) all flow through verbatim.
104fn canonical_line(pattern: &str) -> String {
105 match pattern {
106 ".heddle" => "/.heddle".to_string(),
107 ".heddleignore" => "/.heddleignore".to_string(),
108 ".git" => "/.git".to_string(),
109 other => other.to_string(),
110 }
111}
112
113/// Apply the matcher to a relative path. Whitelist (`!negation`)
114/// rules unset the match; we surface only the `Ignore` outcome.
115///
116/// `is_dir = true`: trailing-slash rules (`build/`) match the bare
117/// directory entry as well as paths inside it. See the docstring on
118/// `should_ignore` for the migration rationale.
119fn matched(gi: &Gitignore, path: &Path) -> bool {
120 if is_reserved_worktree_path(path) {
121 return true;
122 }
123 matches!(
124 gi.matched_path_or_any_parents(path, /* is_dir */ true),
125 ignore::Match::Ignore(_)
126 )
127}
128
129#[cfg(test)]
130mod tests {
131 use std::path::PathBuf;
132
133 use super::*;
134
135 #[test]
136 fn test_glob_extension() {
137 let patterns = vec!["*.log".to_string()];
138 assert!(should_ignore(&PathBuf::from("test.log"), &patterns));
139 assert!(should_ignore(&PathBuf::from("debug.log"), &patterns));
140 assert!(!should_ignore(&PathBuf::from("test.txt"), &patterns));
141 }
142
143 #[test]
144 fn test_directory_pattern() {
145 let patterns = vec!["build/".to_string()];
146 assert!(should_ignore(&PathBuf::from("build/output.txt"), &patterns));
147 // Bare directory match: walker callers ask `should_ignore` to
148 // decide whether to prune `build/` before descending. With
149 // `is_dir = true` plumbed into the gitignore matcher, the
150 // trailing-slash rule fires on the directory entry itself.
151 // Without this, walks of large dependency / build trees
152 // (`target/`, `node_modules/`) recurse unnecessarily.
153 assert!(should_ignore(&PathBuf::from("build"), &patterns));
154 assert!(should_ignore(&PathBuf::from("build/anything"), &patterns));
155 assert!(!should_ignore(&PathBuf::from("builder.txt"), &patterns));
156 }
157
158 #[test]
159 fn dir_only_rule_covers_symlinked_deps_dir() {
160 // heddle#303: a `node_modules` *symlink* (used as a workaround
161 // for the isolated-checkout hydrate gap) must be covered by a
162 // `node_modules/` (dir-only) rule, not treated as an uncaptured
163 // path that silently blocks `ready`. The matcher is path-based
164 // and always probes with `is_dir = true`, so it cannot — and
165 // must not — distinguish a symlink-to-dir from a real directory:
166 // the trailing-slash rule fires on the bare `node_modules` entry
167 // either way. Walker/scan callers never descend a symlink, so
168 // this is the entry that decides whether the link is ignored.
169 let patterns = vec!["node_modules/".to_string()];
170 assert!(should_ignore(&PathBuf::from("node_modules"), &patterns));
171 assert!(should_ignore(
172 &PathBuf::from("nested/node_modules"),
173 &patterns
174 ));
175 }
176
177 #[test]
178 fn test_simple_pattern() {
179 let patterns = vec!["node_modules".to_string()];
180 assert!(should_ignore(
181 &PathBuf::from("node_modules/package.json"),
182 &patterns
183 ));
184 assert!(!should_ignore(&PathBuf::from("src/main.rs"), &patterns));
185 }
186
187 #[test]
188 fn test_simple_pattern_does_not_match_prefixes() {
189 let patterns = vec!["target".to_string()];
190 assert!(should_ignore(
191 &PathBuf::from("target/output.txt"),
192 &patterns
193 ));
194 assert!(should_ignore(&PathBuf::from("build/target/app"), &patterns));
195 assert!(!should_ignore(&PathBuf::from("target.txt"), &patterns));
196 assert!(!should_ignore(
197 &PathBuf::from("targeted/output.txt"),
198 &patterns
199 ));
200 }
201
202 #[test]
203 fn test_root_admin_patterns_do_not_ignore_nested_paths() {
204 let patterns = vec![".heddle".to_string(), ".heddleignore".to_string()];
205 assert!(should_ignore(&PathBuf::from(".heddle/objects"), &patterns));
206 assert!(should_ignore(
207 &PathBuf::from(".heddle/state/index.bin"),
208 &patterns
209 ));
210 assert!(should_ignore(&PathBuf::from(".heddleignore"), &patterns));
211 assert!(!should_ignore(
212 &PathBuf::from("examples/calculator/.heddle/objects"),
213 &patterns
214 ));
215 assert!(!should_ignore(
216 &PathBuf::from("examples/calculator/.heddle/state/index.bin"),
217 &patterns
218 ));
219 assert!(!should_ignore(
220 &PathBuf::from("examples/calculator/.heddleignore"),
221 &patterns
222 ));
223 }
224
225 // ---- New gitignore-spec coverage ----
226
227 #[test]
228 fn test_path_relative_glob_matches_specific_directory_only() {
229 // `config/*.toml` is the case the user called out — a glob
230 // anchored to a specific subdirectory, with `*` matching one
231 // path segment. Plain `secrets.toml` at the root must NOT be
232 // ignored.
233 let patterns = vec!["config/*.toml".to_string()];
234 assert!(should_ignore(
235 &PathBuf::from("config/secrets.toml"),
236 &patterns
237 ));
238 assert!(should_ignore(
239 &PathBuf::from("config/database.toml"),
240 &patterns
241 ));
242 assert!(!should_ignore(&PathBuf::from("secrets.toml"), &patterns));
243 assert!(!should_ignore(
244 &PathBuf::from("other/secrets.toml"),
245 &patterns
246 ));
247 }
248
249 #[test]
250 fn test_double_star_recursive_glob_descends_directories() {
251 // `**/*.pem` matches at any depth — the canonical "find every
252 // PEM key under any directory" pattern.
253 let patterns = vec!["**/*.pem".to_string()];
254 assert!(should_ignore(&PathBuf::from("dev.pem"), &patterns));
255 assert!(should_ignore(&PathBuf::from("keys/dev.pem"), &patterns));
256 assert!(should_ignore(
257 &PathBuf::from("nested/deeper/key.pem"),
258 &patterns
259 ));
260 assert!(!should_ignore(&PathBuf::from("dev.txt"), &patterns));
261 }
262
263 #[test]
264 fn test_negation_rule_whitelists_a_path() {
265 // `*.log` then `!keep.log` — the negation rule unsets the
266 // earlier match for that specific name.
267 let patterns = vec!["*.log".to_string(), "!keep.log".to_string()];
268 assert!(should_ignore(&PathBuf::from("debug.log"), &patterns));
269 assert!(!should_ignore(&PathBuf::from("keep.log"), &patterns));
270 }
271
272 #[test]
273 fn test_leading_slash_anchors_to_root_only() {
274 // `/build` (root-anchored) ignores the top-level `build/` but
275 // not a nested `nested/build/` directory. Distinct semantics
276 // from the bare `build` pattern, which matches anywhere.
277 let patterns = vec!["/build".to_string()];
278 assert!(should_ignore(&PathBuf::from("build/output"), &patterns));
279 assert!(!should_ignore(
280 &PathBuf::from("nested/build/file"),
281 &patterns
282 ));
283 }
284
285 #[test]
286 fn test_character_class_matches_set() {
287 // `[Mm]akefile` — matches uppercase or lowercase variants.
288 // Standard gitignore character class.
289 let patterns = vec!["[Mm]akefile".to_string()];
290 assert!(should_ignore(&PathBuf::from("Makefile"), &patterns));
291 assert!(should_ignore(&PathBuf::from("makefile"), &patterns));
292 assert!(!should_ignore(&PathBuf::from("Rakefile"), &patterns));
293 }
294
295 #[test]
296 fn test_comments_and_blank_lines_are_handled_upstream() {
297 // The matcher itself accepts every line it's given verbatim
298 // (gitignore-spec treats `#` as a comment marker). Repository
299 // strips comments before calling, but verify the matcher
300 // tolerates them so a future refactor can stop stripping
301 // without behavior change.
302 let patterns = vec!["# comment".to_string(), "".to_string(), "*.log".to_string()];
303 assert!(should_ignore(&PathBuf::from("foo.log"), &patterns));
304 assert!(!should_ignore(&PathBuf::from("foo.txt"), &patterns));
305 }
306
307 #[test]
308 fn prebuilt_matcher_matches_same_as_should_ignore() {
309 // The compile-once API must produce identical match results to
310 // the per-call `should_ignore` wrapper — it only hoists WHEN
311 // the glob set is compiled, not WHAT it matches.
312 let patterns = vec!["node_modules".to_string(), "*.log".to_string()];
313 let matcher = build_worktree_ignore(&patterns);
314 let cases = [
315 "node_modules/left-pad/index.js",
316 "debug.log",
317 "src/main.rs",
318 "config/app.toml",
319 ];
320 for case in cases {
321 let p = PathBuf::from(case);
322 assert_eq!(
323 matcher.is_ignored(&p),
324 should_ignore(&p, &patterns),
325 "prebuilt matcher and should_ignore disagree on {case}"
326 );
327 }
328 // Reusing the same matcher across many paths is the whole point;
329 // assert a couple of explicit outcomes too.
330 assert!(matcher.is_ignored(&PathBuf::from("node_modules/x")));
331 assert!(!matcher.is_ignored(&PathBuf::from("src/lib.rs")));
332 }
333
334 #[test]
335 fn test_malformed_pattern_does_not_break_matcher() {
336 // Unbalanced bracket: builder errors silently and the
337 // pattern is dropped. Other rules continue to apply.
338 let patterns = vec!["[unbalanced".to_string(), "*.log".to_string()];
339 assert!(should_ignore(&PathBuf::from("foo.log"), &patterns));
340 }
341
342 #[test]
343 fn user_negation_cannot_unignore_reserved_heddle_tree() {
344 // heddle#1413: last-match-wins would otherwise let `!.heddle/`
345 // pull identity.toml into capture. Reserved-path hard-deny
346 // wins after user rules, including an empty pattern list.
347 let negations = [
348 vec!["!.heddle/".to_string()],
349 vec!["!.heddle".to_string()],
350 vec!["!.heddle/**".to_string()],
351 vec!["!.heddle/identity.toml".to_string()],
352 vec!["**".to_string(), "!.heddle/".to_string()],
353 Vec::new(),
354 ];
355 for patterns in &negations {
356 assert!(
357 should_ignore(&PathBuf::from(".heddle/identity.toml"), patterns),
358 "reserved identity must stay ignored under {patterns:?}"
359 );
360 assert!(
361 should_ignore(&PathBuf::from(".heddle"), patterns),
362 "reserved root .heddle must stay ignored under {patterns:?}"
363 );
364 }
365 // Nested fixtures are not reserved. User rules still apply, so
366 // only assert capturable when the pattern set does not ignore
367 // them on its own (`**` would).
368 for patterns in [
369 vec!["!.heddle/".to_string()],
370 vec!["!.heddle".to_string()],
371 Vec::new(),
372 ] {
373 assert!(
374 !should_ignore(
375 &PathBuf::from("examples/calculator/.heddle/identity.toml"),
376 &patterns
377 ),
378 "nested fixture .heddle must stay capturable under {patterns:?}"
379 );
380 }
381 }
382}