@@ -439,4 +439,257 @@ mod tests {
439439 cleanup_test_repo ( temp_path) ;
440440 Ok ( ( ) )
441441 }
442+
443+ // -----------------------------------------------------------------------
444+ // Helpers and tests for filter_by_attributes
445+ // -----------------------------------------------------------------------
446+
447+ /// Initializes a non-bare repository so that `.gitattributes` written to
448+ /// its working directory are picked up by `repo.get_attr(…)`.
449+ fn setup_attr_test_repo ( ) -> ( Repository , PathBuf ) {
450+ let thread_id = std:: thread:: current ( ) . id ( ) ;
451+ let temp_path = std:: env:: temp_dir ( ) . join ( format ! ( "git-filter-attr-test-{:?}" , thread_id) ) ;
452+ let _ = fs:: remove_dir_all ( & temp_path) ;
453+ fs:: create_dir_all ( & temp_path) . unwrap ( ) ;
454+ let repo = Repository :: init ( & temp_path) . unwrap ( ) ;
455+ ( repo, temp_path)
456+ }
457+
458+ fn write_gitattributes ( repo_path : & Path , content : & str ) {
459+ fs:: write ( repo_path. join ( ".gitattributes" ) , content) . unwrap ( ) ;
460+ }
461+
462+ // --- filter_by_attributes: error cases ---------------------------------
463+
464+ #[ test]
465+ fn test_filter_by_attributes_empty_returns_error ( ) {
466+ let ( repo, temp_path) = setup_attr_test_repo ( ) ;
467+ write_gitattributes ( & temp_path, "" ) ;
468+
469+ let tree = create_test_tree ( & repo) . unwrap ( ) ;
470+ let result = repo. filter_by_attributes ( & tree, & [ ] ) ;
471+ assert ! ( result. is_err( ) ) ;
472+ assert_eq ! (
473+ result. unwrap_err( ) . message( ) ,
474+ "at least one attribute is required"
475+ ) ;
476+
477+ cleanup_test_repo ( temp_path) ;
478+ }
479+
480+ // --- filter_by_attributes: single attribute ----------------------------
481+
482+ #[ test]
483+ fn test_filter_by_attributes_set_attribute_includes_matching_files ( ) -> Result < ( ) , Error > {
484+ let ( repo, temp_path) = setup_attr_test_repo ( ) ;
485+ // Only .txt files carry the export-ignore attribute.
486+ write_gitattributes ( & temp_path, "*.txt export-ignore\n " ) ;
487+
488+ let blob = repo. blob ( b"content" ) ?;
489+ let mut builder = repo. treebuilder ( None ) ?;
490+ builder. insert ( "readme.txt" , blob, 0o100644 ) ?;
491+ builder. insert ( "main.rs" , blob, 0o100644 ) ?;
492+ builder. insert ( "data.json" , blob, 0o100644 ) ?;
493+ let tree = repo. find_tree ( builder. write ( ) ?) ?;
494+
495+ let filtered = repo. filter_by_attributes ( & tree, & [ "export-ignore" ] ) ?;
496+ assert_eq ! ( filtered. len( ) , 1 ) ;
497+ assert ! ( filtered. get_name( "readme.txt" ) . is_some( ) ) ;
498+ assert ! ( filtered. get_name( "main.rs" ) . is_none( ) ) ;
499+ assert ! ( filtered. get_name( "data.json" ) . is_none( ) ) ;
500+
501+ cleanup_test_repo ( temp_path) ;
502+ Ok ( ( ) )
503+ }
504+
505+ #[ test]
506+ fn test_filter_by_attributes_explicitly_unset_attribute_excluded ( ) -> Result < ( ) , Error > {
507+ let ( repo, temp_path) = setup_attr_test_repo ( ) ;
508+ // .txt gets the attribute; .md explicitly has it unset with `-`.
509+ write_gitattributes ( & temp_path, "*.txt custom-attr\n *.md -custom-attr\n " ) ;
510+
511+ let blob = repo. blob ( b"content" ) ?;
512+ let mut builder = repo. treebuilder ( None ) ?;
513+ builder. insert ( "readme.txt" , blob, 0o100644 ) ?;
514+ builder. insert ( "notes.md" , blob, 0o100644 ) ?;
515+ builder. insert ( "main.rs" , blob, 0o100644 ) ?;
516+ let tree = repo. find_tree ( builder. write ( ) ?) ?;
517+
518+ let filtered = repo. filter_by_attributes ( & tree, & [ "custom-attr" ] ) ?;
519+ // .txt is set, .md is explicitly unset, .rs is unspecified
520+ assert_eq ! ( filtered. len( ) , 1 ) ;
521+ assert ! ( filtered. get_name( "readme.txt" ) . is_some( ) ) ;
522+ assert ! ( filtered. get_name( "notes.md" ) . is_none( ) ) ;
523+ assert ! ( filtered. get_name( "main.rs" ) . is_none( ) ) ;
524+
525+ cleanup_test_repo ( temp_path) ;
526+ Ok ( ( ) )
527+ }
528+
529+ #[ test]
530+ fn test_filter_by_attributes_no_attributes_set_returns_empty_tree ( ) -> Result < ( ) , Error > {
531+ let ( repo, temp_path) = setup_attr_test_repo ( ) ;
532+ // Empty .gitattributes — nothing is attributed.
533+ write_gitattributes ( & temp_path, "" ) ;
534+
535+ let blob = repo. blob ( b"content" ) ?;
536+ let mut builder = repo. treebuilder ( None ) ?;
537+ builder. insert ( "file.txt" , blob, 0o100644 ) ?;
538+ builder. insert ( "file.rs" , blob, 0o100644 ) ?;
539+ let tree = repo. find_tree ( builder. write ( ) ?) ?;
540+
541+ let filtered = repo. filter_by_attributes ( & tree, & [ "export-ignore" ] ) ?;
542+ assert_eq ! ( filtered. len( ) , 0 ) ;
543+
544+ cleanup_test_repo ( temp_path) ;
545+ Ok ( ( ) )
546+ }
547+
548+ #[ test]
549+ fn test_filter_by_attributes_multiple_attributes_all_required ( ) -> Result < ( ) , Error > {
550+ let ( repo, temp_path) = setup_attr_test_repo ( ) ;
551+ // .txt has both attributes; .rs has only one.
552+ write_gitattributes ( & temp_path, "*.txt attr-a attr-b\n *.rs attr-a\n " ) ;
553+
554+ let blob = repo. blob ( b"content" ) ?;
555+ let mut builder = repo. treebuilder ( None ) ?;
556+ builder. insert ( "file.txt" , blob, 0o100644 ) ?;
557+ builder. insert ( "file.rs" , blob, 0o100644 ) ?;
558+ builder. insert ( "file.md" , blob, 0o100644 ) ?;
559+ let tree = repo. find_tree ( builder. write ( ) ?) ?;
560+
561+ // Both attributes must be present for a file to be included.
562+ let filtered = repo. filter_by_attributes ( & tree, & [ "attr-a" , "attr-b" ] ) ?;
563+ assert_eq ! ( filtered. len( ) , 1 ) ;
564+ assert ! ( filtered. get_name( "file.txt" ) . is_some( ) ) ;
565+ assert ! ( filtered. get_name( "file.rs" ) . is_none( ) ) ;
566+ assert ! ( filtered. get_name( "file.md" ) . is_none( ) ) ;
567+
568+ cleanup_test_repo ( temp_path) ;
569+ Ok ( ( ) )
570+ }
571+
572+ #[ test]
573+ fn test_filter_by_attributes_attribute_with_value ( ) -> Result < ( ) , Error > {
574+ let ( repo, temp_path) = setup_attr_test_repo ( ) ;
575+ // linguist-language is set to a string value on .rs files.
576+ write_gitattributes ( & temp_path, "*.rs linguist-language=Rust\n " ) ;
577+
578+ let blob = repo. blob ( b"content" ) ?;
579+ let mut builder = repo. treebuilder ( None ) ?;
580+ builder. insert ( "main.rs" , blob, 0o100644 ) ?;
581+ builder. insert ( "main.py" , blob, 0o100644 ) ?;
582+ let tree = repo. find_tree ( builder. write ( ) ?) ?;
583+
584+ // An attribute with any value (including a string) counts as "set".
585+ let filtered = repo. filter_by_attributes ( & tree, & [ "linguist-language" ] ) ?;
586+ assert_eq ! ( filtered. len( ) , 1 ) ;
587+ assert ! ( filtered. get_name( "main.rs" ) . is_some( ) ) ;
588+ assert ! ( filtered. get_name( "main.py" ) . is_none( ) ) ;
589+
590+ cleanup_test_repo ( temp_path) ;
591+ Ok ( ( ) )
592+ }
593+
594+ #[ test]
595+ fn test_filter_by_attributes_all_files_match ( ) -> Result < ( ) , Error > {
596+ let ( repo, temp_path) = setup_attr_test_repo ( ) ;
597+ // Wildcard rule sets the attribute on every file.
598+ write_gitattributes ( & temp_path, "* generated\n " ) ;
599+
600+ let blob = repo. blob ( b"content" ) ?;
601+ let mut builder = repo. treebuilder ( None ) ?;
602+ builder. insert ( "a.txt" , blob, 0o100644 ) ?;
603+ builder. insert ( "b.rs" , blob, 0o100644 ) ?;
604+ builder. insert ( "c.md" , blob, 0o100644 ) ?;
605+ let tree = repo. find_tree ( builder. write ( ) ?) ?;
606+
607+ let filtered = repo. filter_by_attributes ( & tree, & [ "generated" ] ) ?;
608+ assert_eq ! ( filtered. len( ) , 3 ) ;
609+
610+ cleanup_test_repo ( temp_path) ;
611+ Ok ( ( ) )
612+ }
613+
614+ #[ test]
615+ fn test_filter_by_attributes_nested_tree_filters_recursively ( ) -> Result < ( ) , Error > {
616+ let ( repo, temp_path) = setup_attr_test_repo ( ) ;
617+ // Only .proto files carry the attribute.
618+ write_gitattributes ( & temp_path, "*.proto linguist-generated\n " ) ;
619+
620+ let blob = repo. blob ( b"content" ) ?;
621+
622+ // src/api.proto and src/main.rs
623+ let mut src_builder = repo. treebuilder ( None ) ?;
624+ src_builder. insert ( "api.proto" , blob, 0o100644 ) ?;
625+ src_builder. insert ( "main.rs" , blob, 0o100644 ) ?;
626+ let src_oid = src_builder. write ( ) ?;
627+
628+ let mut root_builder = repo. treebuilder ( None ) ?;
629+ root_builder. insert ( "src" , src_oid, 0o040000 ) ?;
630+ root_builder. insert ( "README.md" , blob, 0o100644 ) ?;
631+ let tree = repo. find_tree ( root_builder. write ( ) ?) ?;
632+
633+ let filtered = repo. filter_by_attributes ( & tree, & [ "linguist-generated" ] ) ?;
634+
635+ // Top-level README.md must be gone; src/ must survive because it has
636+ // at least one matching descendant.
637+ assert_eq ! ( filtered. len( ) , 1 ) ;
638+ assert ! ( filtered. get_name( "src" ) . is_some( ) ) ;
639+ assert ! ( filtered. get_name( "README.md" ) . is_none( ) ) ;
640+
641+ let src_entry = filtered. get_name ( "src" ) . unwrap ( ) ;
642+ let src_tree = repo. find_tree ( src_entry. id ( ) ) ?;
643+ assert_eq ! ( src_tree. len( ) , 1 ) ;
644+ assert ! ( src_tree. get_name( "api.proto" ) . is_some( ) ) ;
645+ assert ! ( src_tree. get_name( "main.rs" ) . is_none( ) ) ;
646+
647+ cleanup_test_repo ( temp_path) ;
648+ Ok ( ( ) )
649+ }
650+
651+ #[ test]
652+ fn test_filter_by_attributes_empty_tree_stays_empty ( ) -> Result < ( ) , Error > {
653+ let ( repo, temp_path) = setup_attr_test_repo ( ) ;
654+ write_gitattributes ( & temp_path, "* export-ignore\n " ) ;
655+
656+ let tree = repo. find_tree ( repo. treebuilder ( None ) ?. write ( ) ?) ?;
657+ assert_eq ! ( tree. len( ) , 0 ) ;
658+
659+ let filtered = repo. filter_by_attributes ( & tree, & [ "export-ignore" ] ) ?;
660+ assert_eq ! ( filtered. len( ) , 0 ) ;
661+
662+ cleanup_test_repo ( temp_path) ;
663+ Ok ( ( ) )
664+ }
665+
666+ #[ test]
667+ fn test_filter_by_attributes_subdirectory_excluded_when_all_children_unmatched ( )
668+ -> Result < ( ) , Error > {
669+ let ( repo, temp_path) = setup_attr_test_repo ( ) ;
670+ // Only .txt files match; the `docs/` sub-tree contains only .md files.
671+ write_gitattributes ( & temp_path, "*.txt export-ignore\n " ) ;
672+
673+ let blob = repo. blob ( b"content" ) ?;
674+
675+ let mut docs_builder = repo. treebuilder ( None ) ?;
676+ docs_builder. insert ( "guide.md" , blob, 0o100644 ) ?;
677+ docs_builder. insert ( "api.md" , blob, 0o100644 ) ?;
678+ let docs_oid = docs_builder. write ( ) ?;
679+
680+ let mut root_builder = repo. treebuilder ( None ) ?;
681+ root_builder. insert ( "docs" , docs_oid, 0o040000 ) ?;
682+ root_builder. insert ( "notes.txt" , blob, 0o100644 ) ?;
683+ let tree = repo. find_tree ( root_builder. write ( ) ?) ?;
684+
685+ let filtered = repo. filter_by_attributes ( & tree, & [ "export-ignore" ] ) ?;
686+
687+ // `docs/` should be pruned entirely because none of its children matched.
688+ assert_eq ! ( filtered. len( ) , 1 ) ;
689+ assert ! ( filtered. get_name( "notes.txt" ) . is_some( ) ) ;
690+ assert ! ( filtered. get_name( "docs" ) . is_none( ) ) ;
691+
692+ cleanup_test_repo ( temp_path) ;
693+ Ok ( ( ) )
694+ }
442695}
0 commit comments