Skip to main content

git_filter_tree/
lib.rs

1//! Filter Git tree objects by glob patterns or gitattributes.
2//!
3//! This crate exposes the [`FilterTree`] trait, implemented on
4//! [`git2::Repository`], which produces a new tree containing only the entries
5//! that match either a set of **glob patterns** or a set of **gitattributes**.
6//! Trees are walked recursively; patterns are matched against full paths from
7//! the tree root.
8//!
9//! It is the plumbing library behind the `git filter-tree` command and the
10//! [`git-rewrite`](https://docs.rs/git-rewrite) porcelain.
11//!
12//! # Filter by Pattern
13//!
14//! ```no_run
15//! use git_filter_tree::FilterTree as _;
16//!
17//! let repo = git2::Repository::open_from_env()?;
18//! let tree = repo.head()?.peel_to_tree()?;
19//!
20//! // Produce a new tree that contains only Rust source files.
21//! let filtered = repo.filter_by_patterns(&tree, &["**/*.rs"])?;
22//! println!("tree sha: {}", filtered.id());
23//! # Ok::<(), Box<dyn std::error::Error>>(())
24//! ```
25//!
26//! A trailing `/` is expanded to `dir/**`, so `"src/"` keeps all files under
27//! `src/`. Multiple patterns are OR-ed together.
28//!
29//! # Filter by Attributes
30//!
31//! ```no_run
32//! use git_filter_tree::FilterTree as _;
33//!
34//! let repo = git2::Repository::open_from_env()?;
35//! let tree = repo.head()?.peel_to_tree()?;
36//!
37//! // Keep only entries that have the `export` attribute set in .gitattributes.
38//! let filtered = repo.filter_by_attributes(&tree, &["export"])?;
39//! println!("tree sha: {}", filtered.id());
40//! # Ok::<(), Box<dyn std::error::Error>>(())
41//! ```
42//!
43//! All listed attributes must be set (AND semantics). Entries with an
44//! attribute explicitly unset (`-export`) or unspecified are excluded.
45pub mod exe;
46use std::path::{Path, PathBuf};
47
48pub use git2::{Error, Repository};
49use globset::GlobSetBuilder;
50
51pub trait FilterTree {
52    /// Filters tree entries by gitattributes-style patterns and returns a new tree with contents
53    /// filtered through the provided patterns. Recursively walks the tree and matches patterns
54    /// against full paths from the tree root.
55    ///
56    /// The `patterns` type is an array of string slices and not a glob type because Git has
57    /// specific glob syntax that differs from standard shell syntax.
58    fn filter_by_patterns<'a>(
59        &'a self,
60        tree: &'a git2::Tree<'a>,
61        patterns: &[&str], // TODO create a `git-glob` crate to handle patterns more gracefully
62    ) -> Result<git2::Tree<'a>, Error>;
63
64    /// Filters tree entries by gitattributes and returns a new tree with contents filtered.
65    /// Recursively walks the tree and matches attributes against full paths from the tree root.
66    ///
67    /// The `attributes` type is an array of string slices. For attributes which haves values,
68    /// not simply set or unset, use typical `.gitattributes` syntax.
69    fn filter_by_attributes<'a>(
70        &'a self,
71        tree: &'a git2::Tree<'a>,
72        attributes: &[&str],
73    ) -> Result<git2::Tree<'a>, Error>;
74}
75
76impl FilterTree for git2::Repository {
77    fn filter_by_patterns<'a>(
78        &'a self,
79        tree: &'a git2::Tree<'a>,
80        patterns: &[&str],
81    ) -> Result<git2::Tree<'a>, Error> {
82        if patterns.is_empty() {
83            return Err(Error::from_str("At least one pattern is required"));
84        }
85
86        // Build GlobSet matcher
87        let mut glob_builder = GlobSetBuilder::new();
88        for pattern in patterns {
89            // A trailing `/` means "this directory" in gitattributes/gitignore
90            // semantics.  Normalize to `dir/**` so globset matches all files
91            // under the directory recursively.
92            let normalized: String;
93            let pat = if pattern.ends_with('/') {
94                normalized = format!("{}**", pattern);
95                normalized.as_str()
96            } else {
97                pattern
98            };
99            let glob = globset::Glob::new(pat)
100                .map_err(|e| Error::from_str(&format!("Invalid pattern '{}': {}", pattern, e)))?;
101            glob_builder.add(glob);
102        }
103
104        let matcher = glob_builder
105            .build()
106            .map_err(|e| Error::from_str(&e.to_string()))?;
107
108        // Recursively filter the tree
109        filter_tree_recursive(self, tree, None, &|_repo, path| matcher.is_match(path))
110    }
111
112    fn filter_by_attributes<'a>(
113        &'a self,
114        tree: &'a git2::Tree<'a>,
115        attributes: &[&str],
116    ) -> Result<git2::Tree<'a>, Error> {
117        if attributes.is_empty() {
118            return Err(git2::Error::from_str("at least one attribute is required"));
119        }
120
121        filter_tree_recursive(self, tree, None, &|repo, path| {
122            for attribute in attributes {
123                match repo.get_attr(path, attribute, git2::AttrCheckFlags::FILE_THEN_INDEX) {
124                    Ok(Some(value)) => {
125                        let value = git2::AttrValue::from_string(Some(value));
126                        match value {
127                            git2::AttrValue::Unspecified => return false,
128                            git2::AttrValue::False => return false,
129                            _ => {}
130                        }
131                    }
132                    Ok(None) => return false,
133                    Err(_) => return false,
134                }
135            }
136
137            true
138        })
139    }
140}
141
142/// Recursively filters a tree, matching patterns against full paths.
143/// Returns a new tree containing only entries that match or have matching descendants.
144fn filter_tree_recursive<'a, F>(
145    repo: &'a Repository,
146    tree: &'a git2::Tree<'a>,
147    prefix: Option<&Path>,
148    predicate: &F,
149) -> Result<git2::Tree<'a>, Error>
150where
151    F: Fn(&Repository, &Path) -> bool,
152{
153    let mut builder = repo.treebuilder(None)?;
154
155    for entry in tree.iter() {
156        let Some(name) = entry.name() else {
157            return Err(Error::from_str("name has invalid UTF-8"));
158        };
159
160        let full_path = match prefix {
161            Some(subdir) => subdir.join(name),
162            None => PathBuf::from(name.to_string()),
163        };
164
165        match entry.kind() {
166            Some(git2::ObjectType::Blob) => {
167                if predicate(repo, &full_path) {
168                    builder.insert(name, entry.id(), entry.filemode())?;
169                }
170            }
171            Some(git2::ObjectType::Tree) => {
172                let subtree = entry.to_object(repo)?.peel_to_tree()?;
173                let filtered_subtree =
174                    filter_tree_recursive(repo, &subtree, Some(&full_path), predicate)?;
175                if !filtered_subtree.is_empty() {
176                    builder.insert(name, filtered_subtree.id(), entry.filemode())?;
177                }
178            }
179            // Skip submodule commit pointers, tags, and any other unexpected
180            // object types that can appear as tree entries.
181            _ => continue,
182        }
183    }
184
185    let tree_oid = builder.write()?;
186    repo.find_tree(tree_oid)
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use std::fs;
193    use std::path::PathBuf;
194
195    fn setup_test_repo() -> (Repository, PathBuf) {
196        let thread_id = std::thread::current().id();
197        let temp_path = std::env::temp_dir().join(format!("git-filter-tree-test-{:?}", thread_id));
198        let _ = fs::remove_dir_all(&temp_path);
199        fs::create_dir_all(&temp_path).unwrap();
200        let repo = Repository::init_bare(&temp_path).unwrap();
201        (repo, temp_path)
202    }
203
204    fn cleanup_test_repo(path: PathBuf) {
205        let _ = fs::remove_dir_all(path);
206    }
207
208    fn create_test_tree<'a>(repo: &'a Repository) -> Result<git2::Tree<'a>, Error> {
209        let mut tree_builder = repo.treebuilder(None)?;
210
211        // Create some blob entries
212        let blob1 = repo.blob(b"content1")?;
213        let blob2 = repo.blob(b"content2")?;
214        let blob3 = repo.blob(b"content3")?;
215
216        tree_builder.insert("file1.txt", blob1, 0o100644)?;
217        tree_builder.insert("file2.rs", blob2, 0o100644)?;
218        tree_builder.insert("test.md", blob3, 0o100644)?;
219
220        let tree_oid = tree_builder.write()?;
221        repo.find_tree(tree_oid)
222    }
223
224    #[test]
225    fn test_filter_single_pattern() -> Result<(), Error> {
226        let (repo, temp_path) = setup_test_repo();
227
228        let tree = create_test_tree(&repo)?;
229        assert_eq!(tree.len(), 3);
230
231        // Filter for .txt files only
232        let filtered = repo.filter_by_patterns(&tree, &["*.txt"])?;
233        assert_eq!(filtered.len(), 1);
234        assert!(filtered.get_name("file1.txt").is_some());
235        assert!(filtered.get_name("file2.rs").is_none());
236        assert!(filtered.get_name("test.md").is_none());
237
238        cleanup_test_repo(temp_path);
239        Ok(())
240    }
241
242    #[test]
243    fn test_filter_multiple_patterns() -> Result<(), Error> {
244        let (repo, temp_path) = setup_test_repo();
245
246        let tree = create_test_tree(&repo)?;
247
248        // Filter for .txt and .rs files
249        let filtered = repo.filter_by_patterns(&tree, &["*.txt", "*.rs"])?;
250        assert_eq!(filtered.len(), 2);
251        assert!(filtered.get_name("file1.txt").is_some());
252        assert!(filtered.get_name("file2.rs").is_some());
253        assert!(filtered.get_name("test.md").is_none());
254
255        cleanup_test_repo(temp_path);
256        Ok(())
257    }
258
259    #[test]
260    fn test_filter_exact_match() -> Result<(), Error> {
261        let (repo, temp_path) = setup_test_repo();
262
263        let tree = create_test_tree(&repo)?;
264
265        // Filter for exact filename
266        let filtered = repo.filter_by_patterns(&tree, &["file1.txt"])?;
267        assert_eq!(filtered.len(), 1);
268        assert!(filtered.get_name("file1.txt").is_some());
269
270        cleanup_test_repo(temp_path);
271        Ok(())
272    }
273
274    #[test]
275    fn test_filter_wildcard_patterns() -> Result<(), Error> {
276        let (repo, temp_path) = setup_test_repo();
277
278        let tree = create_test_tree(&repo)?;
279
280        // Filter with wildcard pattern
281        let filtered = repo.filter_by_patterns(&tree, &["file*"])?;
282        assert_eq!(filtered.len(), 2);
283        assert!(filtered.get_name("file1.txt").is_some());
284        assert!(filtered.get_name("file2.rs").is_some());
285        assert!(filtered.get_name("test.md").is_none());
286
287        cleanup_test_repo(temp_path);
288        Ok(())
289    }
290
291    #[test]
292    fn test_filter_no_matches() -> Result<(), Error> {
293        let (repo, temp_path) = setup_test_repo();
294
295        let tree = create_test_tree(&repo)?;
296
297        // Filter with pattern that matches nothing
298        let filtered = repo.filter_by_patterns(&tree, &["*.nonexistent"])?;
299        assert_eq!(filtered.len(), 0);
300
301        cleanup_test_repo(temp_path);
302        Ok(())
303    }
304
305    #[test]
306    fn test_filter_all_matches() -> Result<(), Error> {
307        let (repo, temp_path) = setup_test_repo();
308
309        let tree = create_test_tree(&repo)?;
310
311        // Filter with pattern that matches everything
312        let filtered = repo.filter_by_patterns(&tree, &["*"])?;
313        assert_eq!(filtered.len(), 3);
314
315        cleanup_test_repo(temp_path);
316        Ok(())
317    }
318
319    #[test]
320    fn test_filter_empty_patterns_error() {
321        let (repo, temp_path) = setup_test_repo();
322
323        let tree = create_test_tree(&repo).unwrap();
324
325        // Empty patterns should return an error
326        let result = repo.filter_by_patterns(&tree, &[]);
327        assert!(result.is_err());
328        assert_eq!(
329            result.unwrap_err().message(),
330            "At least one pattern is required"
331        );
332
333        cleanup_test_repo(temp_path);
334    }
335
336    #[test]
337    fn test_filter_invalid_pattern_error() {
338        let (repo, temp_path) = setup_test_repo();
339
340        let tree = create_test_tree(&repo).unwrap();
341
342        // Invalid glob pattern should return an error
343        let result = repo.filter_by_patterns(&tree, &["[invalid"]);
344        assert!(result.is_err());
345
346        cleanup_test_repo(temp_path);
347    }
348
349    #[test]
350    fn test_filter_with_nested_tree() -> Result<(), Error> {
351        let (repo, temp_path) = setup_test_repo();
352
353        let mut tree_builder = repo.treebuilder(None)?;
354
355        // Create a nested tree
356        let mut subtree_builder = repo.treebuilder(None)?;
357        let blob = repo.blob(b"nested content")?;
358        subtree_builder.insert("nested.txt", blob, 0o100644)?;
359        let subtree_oid = subtree_builder.write()?;
360
361        // Add files and subtree to main tree
362        let blob1 = repo.blob(b"content1")?;
363        tree_builder.insert("file1.txt", blob1, 0o100644)?;
364        tree_builder.insert("subdir", subtree_oid, 0o040000)?;
365
366        let tree_oid = tree_builder.write()?;
367        let tree = repo.find_tree(tree_oid)?;
368
369        // Filter - should keep both file and directory
370        let filtered = repo.filter_by_patterns(&tree, &["*"])?;
371        assert_eq!(filtered.len(), 2);
372
373        cleanup_test_repo(temp_path);
374        Ok(())
375    }
376
377    #[test]
378    fn test_filter_preserves_empty_tree() -> Result<(), Error> {
379        let (repo, temp_path) = setup_test_repo();
380
381        // Create an empty tree
382        let tree_builder = repo.treebuilder(None)?;
383        let tree_oid = tree_builder.write()?;
384        let tree = repo.find_tree(tree_oid)?;
385
386        assert_eq!(tree.len(), 0);
387
388        // Filter empty tree
389        let filtered = repo.filter_by_patterns(&tree, &["*"])?;
390        assert_eq!(filtered.len(), 0);
391
392        cleanup_test_repo(temp_path);
393        Ok(())
394    }
395
396    #[test]
397    fn test_filter_case_sensitive() -> Result<(), Error> {
398        let (repo, temp_path) = setup_test_repo();
399
400        let mut tree_builder = repo.treebuilder(None)?;
401        let blob1 = repo.blob(b"content1")?;
402        let blob2 = repo.blob(b"content2")?;
403
404        tree_builder.insert("File.txt", blob1, 0o100644)?;
405        tree_builder.insert("file.txt", blob2, 0o100644)?;
406
407        let tree_oid = tree_builder.write()?;
408        let tree = repo.find_tree(tree_oid)?;
409
410        // Filter with exact case match
411        let filtered = repo.filter_by_patterns(&tree, &["file.txt"])?;
412        assert_eq!(filtered.len(), 1);
413        assert!(filtered.get_name("file.txt").is_some());
414
415        cleanup_test_repo(temp_path);
416        Ok(())
417    }
418
419    #[test]
420    fn test_filter_complex_patterns() -> Result<(), Error> {
421        let (repo, temp_path) = setup_test_repo();
422
423        let mut tree_builder = repo.treebuilder(None)?;
424        let blob = repo.blob(b"content")?;
425
426        tree_builder.insert("test1.txt", blob, 0o100644)?;
427        tree_builder.insert("test2.rs", blob, 0o100644)?;
428        tree_builder.insert("data.json", blob, 0o100644)?;
429        tree_builder.insert("README.md", blob, 0o100644)?;
430
431        let tree_oid = tree_builder.write()?;
432        let tree = repo.find_tree(tree_oid)?;
433
434        // Multiple patterns with different wildcards
435        let filtered = repo.filter_by_patterns(&tree, &["test*", "*.md"])?;
436        assert_eq!(filtered.len(), 3);
437        assert!(filtered.get_name("test1.txt").is_some());
438        assert!(filtered.get_name("test2.rs").is_some());
439        assert!(filtered.get_name("README.md").is_some());
440        assert!(filtered.get_name("data.json").is_none());
441
442        cleanup_test_repo(temp_path);
443        Ok(())
444    }
445
446    #[test]
447    fn test_filter_trailing_slash_matches_directory_contents() -> Result<(), Error> {
448        let (repo, temp_path) = setup_test_repo();
449
450        // Build a tree with a subdirectory: pyo3/Cargo.toml, pyo3/src/lib.rs,
451        // and a top-level file that should NOT match.
452        let blob = repo.blob(b"content")?;
453
454        let mut src_builder = repo.treebuilder(None)?;
455        src_builder.insert("lib.rs", blob, 0o100644)?;
456        let src_oid = src_builder.write()?;
457
458        let mut pyo3_builder = repo.treebuilder(None)?;
459        pyo3_builder.insert("Cargo.toml", blob, 0o100644)?;
460        pyo3_builder.insert("src", src_oid, 0o040000)?;
461        let pyo3_oid = pyo3_builder.write()?;
462
463        let mut root_builder = repo.treebuilder(None)?;
464        root_builder.insert("pyo3", pyo3_oid, 0o040000)?;
465        root_builder.insert("README.md", blob, 0o100644)?;
466        let root_oid = root_builder.write()?;
467        let tree = repo.find_tree(root_oid)?;
468
469        // "pyo3/" (trailing slash) must match all files under pyo3/.
470        let filtered = repo.filter_by_patterns(&tree, &["pyo3/"])?;
471        assert_eq!(filtered.len(), 1, "only the pyo3 dir should remain");
472        assert!(filtered.get_name("pyo3").is_some());
473        assert!(filtered.get_name("README.md").is_none());
474
475        // The pyo3 subtree itself must retain both entries.
476        let pyo3_entry = filtered.get_name("pyo3").unwrap();
477        let pyo3_tree = repo.find_tree(pyo3_entry.id())?;
478        assert!(pyo3_tree.get_name("Cargo.toml").is_some());
479        assert!(pyo3_tree.get_name("src").is_some());
480
481        cleanup_test_repo(temp_path);
482        Ok(())
483    }
484
485    // -----------------------------------------------------------------------
486    // Helpers and tests for filter_by_attributes
487    // -----------------------------------------------------------------------
488
489    /// Initializes a non-bare repository so that `.gitattributes` written to
490    /// its working directory are picked up by `repo.get_attr(…)`.
491    fn setup_attr_test_repo() -> (Repository, PathBuf) {
492        let thread_id = std::thread::current().id();
493        let temp_path = std::env::temp_dir().join(format!("git-filter-attr-test-{:?}", thread_id));
494        let _ = fs::remove_dir_all(&temp_path);
495        fs::create_dir_all(&temp_path).unwrap();
496        let repo = Repository::init(&temp_path).unwrap();
497        (repo, temp_path)
498    }
499
500    fn write_gitattributes(repo_path: &Path, content: &str) {
501        fs::write(repo_path.join(".gitattributes"), content).unwrap();
502    }
503
504    // --- filter_by_attributes: error cases ---------------------------------
505
506    #[test]
507    fn test_filter_by_attributes_empty_returns_error() {
508        let (repo, temp_path) = setup_attr_test_repo();
509        write_gitattributes(&temp_path, "");
510
511        let tree = create_test_tree(&repo).unwrap();
512        let result = repo.filter_by_attributes(&tree, &[]);
513        assert!(result.is_err());
514        assert_eq!(
515            result.unwrap_err().message(),
516            "at least one attribute is required"
517        );
518
519        cleanup_test_repo(temp_path);
520    }
521
522    // --- filter_by_attributes: single attribute ----------------------------
523
524    #[test]
525    fn test_filter_by_attributes_set_attribute_includes_matching_files() -> Result<(), Error> {
526        let (repo, temp_path) = setup_attr_test_repo();
527        // Only .txt files carry the export-ignore attribute.
528        write_gitattributes(&temp_path, "*.txt export-ignore\n");
529
530        let blob = repo.blob(b"content")?;
531        let mut builder = repo.treebuilder(None)?;
532        builder.insert("readme.txt", blob, 0o100644)?;
533        builder.insert("main.rs", blob, 0o100644)?;
534        builder.insert("data.json", blob, 0o100644)?;
535        let tree = repo.find_tree(builder.write()?)?;
536
537        let filtered = repo.filter_by_attributes(&tree, &["export-ignore"])?;
538        assert_eq!(filtered.len(), 1);
539        assert!(filtered.get_name("readme.txt").is_some());
540        assert!(filtered.get_name("main.rs").is_none());
541        assert!(filtered.get_name("data.json").is_none());
542
543        cleanup_test_repo(temp_path);
544        Ok(())
545    }
546
547    #[test]
548    fn test_filter_by_attributes_explicitly_unset_attribute_excluded() -> Result<(), Error> {
549        let (repo, temp_path) = setup_attr_test_repo();
550        // .txt gets the attribute; .md explicitly has it unset with `-`.
551        write_gitattributes(&temp_path, "*.txt custom-attr\n*.md -custom-attr\n");
552
553        let blob = repo.blob(b"content")?;
554        let mut builder = repo.treebuilder(None)?;
555        builder.insert("readme.txt", blob, 0o100644)?;
556        builder.insert("notes.md", blob, 0o100644)?;
557        builder.insert("main.rs", blob, 0o100644)?;
558        let tree = repo.find_tree(builder.write()?)?;
559
560        let filtered = repo.filter_by_attributes(&tree, &["custom-attr"])?;
561        // .txt is set, .md is explicitly unset, .rs is unspecified
562        assert_eq!(filtered.len(), 1);
563        assert!(filtered.get_name("readme.txt").is_some());
564        assert!(filtered.get_name("notes.md").is_none());
565        assert!(filtered.get_name("main.rs").is_none());
566
567        cleanup_test_repo(temp_path);
568        Ok(())
569    }
570
571    #[test]
572    fn test_filter_by_attributes_no_attributes_set_returns_empty_tree() -> Result<(), Error> {
573        let (repo, temp_path) = setup_attr_test_repo();
574        // Empty .gitattributes — nothing is attributed.
575        write_gitattributes(&temp_path, "");
576
577        let blob = repo.blob(b"content")?;
578        let mut builder = repo.treebuilder(None)?;
579        builder.insert("file.txt", blob, 0o100644)?;
580        builder.insert("file.rs", blob, 0o100644)?;
581        let tree = repo.find_tree(builder.write()?)?;
582
583        let filtered = repo.filter_by_attributes(&tree, &["export-ignore"])?;
584        assert_eq!(filtered.len(), 0);
585
586        cleanup_test_repo(temp_path);
587        Ok(())
588    }
589
590    #[test]
591    fn test_filter_by_attributes_multiple_attributes_all_required() -> Result<(), Error> {
592        let (repo, temp_path) = setup_attr_test_repo();
593        // .txt has both attributes; .rs has only one.
594        write_gitattributes(&temp_path, "*.txt attr-a attr-b\n*.rs attr-a\n");
595
596        let blob = repo.blob(b"content")?;
597        let mut builder = repo.treebuilder(None)?;
598        builder.insert("file.txt", blob, 0o100644)?;
599        builder.insert("file.rs", blob, 0o100644)?;
600        builder.insert("file.md", blob, 0o100644)?;
601        let tree = repo.find_tree(builder.write()?)?;
602
603        // Both attributes must be present for a file to be included.
604        let filtered = repo.filter_by_attributes(&tree, &["attr-a", "attr-b"])?;
605        assert_eq!(filtered.len(), 1);
606        assert!(filtered.get_name("file.txt").is_some());
607        assert!(filtered.get_name("file.rs").is_none());
608        assert!(filtered.get_name("file.md").is_none());
609
610        cleanup_test_repo(temp_path);
611        Ok(())
612    }
613
614    #[test]
615    fn test_filter_by_attributes_attribute_with_value() -> Result<(), Error> {
616        let (repo, temp_path) = setup_attr_test_repo();
617        // linguist-language is set to a string value on .rs files.
618        write_gitattributes(&temp_path, "*.rs linguist-language=Rust\n");
619
620        let blob = repo.blob(b"content")?;
621        let mut builder = repo.treebuilder(None)?;
622        builder.insert("main.rs", blob, 0o100644)?;
623        builder.insert("main.py", blob, 0o100644)?;
624        let tree = repo.find_tree(builder.write()?)?;
625
626        // An attribute with any value (including a string) counts as "set".
627        let filtered = repo.filter_by_attributes(&tree, &["linguist-language"])?;
628        assert_eq!(filtered.len(), 1);
629        assert!(filtered.get_name("main.rs").is_some());
630        assert!(filtered.get_name("main.py").is_none());
631
632        cleanup_test_repo(temp_path);
633        Ok(())
634    }
635
636    #[test]
637    fn test_filter_by_attributes_all_files_match() -> Result<(), Error> {
638        let (repo, temp_path) = setup_attr_test_repo();
639        // Wildcard rule sets the attribute on every file.
640        write_gitattributes(&temp_path, "* generated\n");
641
642        let blob = repo.blob(b"content")?;
643        let mut builder = repo.treebuilder(None)?;
644        builder.insert("a.txt", blob, 0o100644)?;
645        builder.insert("b.rs", blob, 0o100644)?;
646        builder.insert("c.md", blob, 0o100644)?;
647        let tree = repo.find_tree(builder.write()?)?;
648
649        let filtered = repo.filter_by_attributes(&tree, &["generated"])?;
650        assert_eq!(filtered.len(), 3);
651
652        cleanup_test_repo(temp_path);
653        Ok(())
654    }
655
656    #[test]
657    fn test_filter_by_attributes_nested_tree_filters_recursively() -> Result<(), Error> {
658        let (repo, temp_path) = setup_attr_test_repo();
659        // Only .proto files carry the attribute.
660        write_gitattributes(&temp_path, "*.proto linguist-generated\n");
661
662        let blob = repo.blob(b"content")?;
663
664        // src/api.proto and src/main.rs
665        let mut src_builder = repo.treebuilder(None)?;
666        src_builder.insert("api.proto", blob, 0o100644)?;
667        src_builder.insert("main.rs", blob, 0o100644)?;
668        let src_oid = src_builder.write()?;
669
670        let mut root_builder = repo.treebuilder(None)?;
671        root_builder.insert("src", src_oid, 0o040000)?;
672        root_builder.insert("README.md", blob, 0o100644)?;
673        let tree = repo.find_tree(root_builder.write()?)?;
674
675        let filtered = repo.filter_by_attributes(&tree, &["linguist-generated"])?;
676
677        // Top-level README.md must be gone; src/ must survive because it has
678        // at least one matching descendant.
679        assert_eq!(filtered.len(), 1);
680        assert!(filtered.get_name("src").is_some());
681        assert!(filtered.get_name("README.md").is_none());
682
683        let src_entry = filtered.get_name("src").unwrap();
684        let src_tree = repo.find_tree(src_entry.id())?;
685        assert_eq!(src_tree.len(), 1);
686        assert!(src_tree.get_name("api.proto").is_some());
687        assert!(src_tree.get_name("main.rs").is_none());
688
689        cleanup_test_repo(temp_path);
690        Ok(())
691    }
692
693    #[test]
694    fn test_filter_by_attributes_empty_tree_stays_empty() -> Result<(), Error> {
695        let (repo, temp_path) = setup_attr_test_repo();
696        write_gitattributes(&temp_path, "* export-ignore\n");
697
698        let tree = repo.find_tree(repo.treebuilder(None)?.write()?)?;
699        assert_eq!(tree.len(), 0);
700
701        let filtered = repo.filter_by_attributes(&tree, &["export-ignore"])?;
702        assert_eq!(filtered.len(), 0);
703
704        cleanup_test_repo(temp_path);
705        Ok(())
706    }
707
708    #[test]
709    fn test_filter_by_attributes_subdirectory_excluded_when_all_children_unmatched()
710    -> Result<(), Error> {
711        let (repo, temp_path) = setup_attr_test_repo();
712        // Only .txt files match; the `docs/` sub-tree contains only .md files.
713        write_gitattributes(&temp_path, "*.txt export-ignore\n");
714
715        let blob = repo.blob(b"content")?;
716
717        let mut docs_builder = repo.treebuilder(None)?;
718        docs_builder.insert("guide.md", blob, 0o100644)?;
719        docs_builder.insert("api.md", blob, 0o100644)?;
720        let docs_oid = docs_builder.write()?;
721
722        let mut root_builder = repo.treebuilder(None)?;
723        root_builder.insert("docs", docs_oid, 0o040000)?;
724        root_builder.insert("notes.txt", blob, 0o100644)?;
725        let tree = repo.find_tree(root_builder.write()?)?;
726
727        let filtered = repo.filter_by_attributes(&tree, &["export-ignore"])?;
728
729        // `docs/` should be pruned entirely because none of its children matched.
730        assert_eq!(filtered.len(), 1);
731        assert!(filtered.get_name("notes.txt").is_some());
732        assert!(filtered.get_name("docs").is_none());
733
734        cleanup_test_repo(temp_path);
735        Ok(())
736    }
737}