1pub mod exe;
46use std::path::{Path, PathBuf};
47
48pub use git2::{Error, Repository};
49use globset::GlobSetBuilder;
50
51pub trait FilterTree {
52 fn filter_by_patterns<'a>(
59 &'a self,
60 tree: &'a git2::Tree<'a>,
61 patterns: &[&str], ) -> Result<git2::Tree<'a>, Error>;
63
64 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 let mut glob_builder = GlobSetBuilder::new();
88 for pattern in patterns {
89 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 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
142fn 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 _ => 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[test]
525 fn test_filter_by_attributes_set_attribute_includes_matching_files() -> Result<(), Error> {
526 let (repo, temp_path) = setup_attr_test_repo();
527 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 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 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 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 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 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 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 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 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 write_gitattributes(&temp_path, "*.proto linguist-generated\n");
661
662 let blob = repo.blob(b"content")?;
663
664 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 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 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 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}