Skip to main content

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