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