1use std::collections::HashMap;
22use std::ffi::OsStr;
23use std::fs;
24use std::io;
25use std::path::{Path, PathBuf};
26
27use bstr::ByteSlice;
28use gix_attributes::{
29 Search, StateRef,
30 search::{MetadataCollection, Outcome},
31};
32use gix_glob::pattern::Case;
33
34pub struct AttrSet {
36 search: Search,
37 collection: MetadataCollection,
38 macros: HashMap<String, Vec<String>>,
45}
46
47impl AttrSet {
48 pub fn empty() -> Self {
51 let mut collection = MetadataCollection::default();
52 let mut search = Search::default();
53 search.add_patterns_buffer(
54 b"[attr]binary -diff -merge -text",
55 "[builtin]".into(),
56 None,
57 &mut collection,
58 true,
59 );
60 let mut macros = HashMap::new();
61 macros.insert(
62 "binary".to_string(),
63 vec!["diff".into(), "merge".into(), "text".into()],
64 );
65 Self {
66 search,
67 collection,
68 macros,
69 }
70 }
71
72 pub fn from_buffer(bytes: &[u8]) -> Self {
74 let mut me = Self::empty();
75 let rewritten = me.intake_buffer(bytes);
76 me.search.add_patterns_buffer(
77 &rewritten,
78 "<memory>".into(),
79 None,
80 &mut me.collection,
81 true,
82 );
83 me
84 }
85
86 pub fn add_buffer_at(&mut self, bytes: &[u8], dir: &str) {
95 let virtual_root = std::path::PathBuf::from("/__lfs_virt");
96 let source = if dir.is_empty() {
97 virtual_root.join(".gitattributes")
98 } else {
99 virtual_root.join(dir).join(".gitattributes")
100 };
101 let rewritten = self.intake_buffer(bytes);
102 self.search.add_patterns_buffer(
103 &rewritten,
104 source,
105 Some(&virtual_root),
106 &mut self.collection,
107 true,
108 );
109 }
110
111 pub fn from_workdir(repo_root: &Path) -> io::Result<Self> {
115 let mut me = Self::empty();
116
117 let info = repo_root.join(".git").join("info").join("attributes");
118 if info.exists() {
119 let bytes = fs::read(&info)?;
120 let rewritten = me.intake_buffer(&bytes);
121 me.search
122 .add_patterns_buffer(&rewritten, info, None, &mut me.collection, true);
123 }
124
125 let mut found = Vec::new();
126 walk_for_gitattributes(repo_root, &mut found)?;
127 found.sort_by_key(|p| p.components().count());
131 for path in found {
132 let bytes = fs::read(&path)?;
133 let rewritten = me.intake_buffer(&bytes);
134 me.search.add_patterns_buffer(
140 &rewritten,
141 path,
142 Some(repo_root),
143 &mut me.collection,
144 true,
145 );
146 }
147 Ok(me)
148 }
149
150 fn intake_buffer(&mut self, bytes: &[u8]) -> Vec<u8> {
161 let Ok(s) = std::str::from_utf8(bytes) else {
162 return bytes.to_vec();
166 };
167 let mut out = Vec::with_capacity(bytes.len());
168 for line in s.split('\n') {
169 let trimmed = line.trim_start();
170 if let Some(rest) = trimmed.strip_prefix("[attr]") {
171 let mut tokens = rest.split_whitespace();
174 if let Some(name) = tokens.next() {
175 let attrs: Vec<String> = tokens
176 .map(|t| {
177 let key = t.trim_start_matches(['-', '!']);
181 key.split_once('=')
182 .map(|(k, _)| k)
183 .unwrap_or(key)
184 .to_string()
185 })
186 .filter(|k| !k.is_empty())
187 .collect();
188 if !attrs.is_empty() {
189 self.macros.insert(name.to_string(), attrs);
190 }
191 }
192 out.extend_from_slice(line.as_bytes());
193 out.push(b'\n');
194 continue;
195 }
196 if trimmed.is_empty() || trimmed.starts_with('#') {
197 out.extend_from_slice(line.as_bytes());
198 out.push(b'\n');
199 continue;
200 }
201 let leading_ws_len = line.len() - trimmed.len();
213 out.extend_from_slice(&line.as_bytes()[..leading_ws_len]);
214 let mut tokens = trimmed.split_whitespace();
215 if let Some(pattern) = tokens.next() {
216 out.extend_from_slice(pattern.as_bytes());
217 for tok in tokens {
218 if let Some(name) = tok.strip_prefix('!')
219 && let Some(macro_attrs) = self.macros.get(name)
220 {
221 for k in macro_attrs {
222 out.push(b' ');
223 out.push(b'!');
224 out.extend_from_slice(k.as_bytes());
225 }
226 continue;
228 }
229 out.push(b' ');
230 out.extend_from_slice(tok.as_bytes());
231 }
232 }
233 out.push(b'\n');
234 }
235 out
236 }
237
238 pub fn value(&self, path: &str, attr: &str) -> Option<String> {
242 let mut out = Outcome::default();
243 out.initialize_with_selection(&self.collection, [attr]);
244 self.search
245 .pattern_matching_relative_path(path.into(), Case::Sensitive, None, &mut out);
246 for m in out.iter_selected() {
247 if m.assignment.name.as_str() != attr {
248 continue;
249 }
250 return match m.assignment.state {
251 StateRef::Set => Some("true".into()),
252 StateRef::Value(v) => Some(v.as_bstr().to_str_lossy().into_owned()),
253 StateRef::Unset | StateRef::Unspecified => None,
254 };
255 }
256 None
257 }
258
259 pub fn is_set(&self, path: &str, attr: &str) -> bool {
262 matches!(self.value(path, attr).as_deref(), Some(v) if v != "false")
263 }
264
265 pub fn is_lfs_tracked(&self, path: &str) -> bool {
267 self.value(path, "filter").as_deref() == Some("lfs")
268 }
269
270 pub fn is_lockable(&self, path: &str) -> bool {
272 self.is_set(path, "lockable")
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Eq)]
278pub struct PatternEntry {
279 pub pattern: String,
282 pub source: String,
286 pub tracked: bool,
290 pub lockable: bool,
293}
294
295#[derive(Debug, Default, PartialEq, Eq)]
299pub struct PatternListing {
300 pub patterns: Vec<PatternEntry>,
301}
302
303impl PatternListing {
304 pub fn tracked(&self) -> impl Iterator<Item = &PatternEntry> {
306 self.patterns.iter().filter(|p| p.tracked)
307 }
308
309 pub fn excluded(&self) -> impl Iterator<Item = &PatternEntry> {
311 self.patterns.iter().filter(|p| !p.tracked)
312 }
313}
314
315#[derive(Default)]
320struct MacroState {
321 enables_lfs: std::collections::HashSet<String>,
326}
327
328impl MacroState {
329 fn ingest(&mut self, line: &str) {
332 let trimmed = line.trim_start();
333 let Some(rest) = trimmed.strip_prefix("[attr]") else {
334 return;
335 };
336 let mut tokens = rest.split_whitespace();
337 let Some(name) = tokens.next() else {
338 return;
339 };
340 let mut enables = false;
343 for tok in tokens {
344 match self.classify(tok) {
345 FilterEffect::SetLfs => enables = true,
346 FilterEffect::Clear => enables = false,
347 FilterEffect::None => {}
348 }
349 }
350 if enables {
351 self.enables_lfs.insert(name.to_owned());
352 } else {
353 self.enables_lfs.remove(name);
354 }
355 }
356
357 fn classify(&self, tok: &str) -> FilterEffect {
360 if tok == "filter=lfs" {
361 return FilterEffect::SetLfs;
362 }
363 if tok == "-filter" || tok == "!filter" || tok.starts_with("-filter=") {
364 return FilterEffect::Clear;
365 }
366 if let Some(name) = tok.strip_prefix('-').or_else(|| tok.strip_prefix('!')) {
370 if self.enables_lfs.contains(name) {
371 return FilterEffect::Clear;
372 }
373 return FilterEffect::None;
374 }
375 if self.enables_lfs.contains(tok) {
376 return FilterEffect::SetLfs;
377 }
378 FilterEffect::None
379 }
380}
381
382enum FilterEffect {
383 SetLfs,
384 Clear,
385 None,
386}
387
388pub fn list_lfs_patterns(repo_root: &Path) -> io::Result<PatternListing> {
396 let mut listing = PatternListing::default();
397 let mut macros = MacroState::default();
398
399 if let Some((path, bytes)) = read_global_attributes(repo_root) {
406 scan_attr_lines(&bytes, &path, &mut listing, &mut macros, true);
407 }
408
409 let info = repo_root.join(".git").join("info").join("attributes");
410 if info.exists() {
411 let bytes = fs::read(&info)?;
412 scan_attr_lines(
413 &bytes,
414 ".git/info/attributes",
415 &mut listing,
416 &mut macros,
417 true,
418 );
419 }
420
421 let mut found = Vec::new();
422 walk_for_gitattributes(repo_root, &mut found)?;
423 found.sort_by_key(|p| p.components().count());
424 for path in found {
425 let bytes = fs::read(&path)?;
426 let rel = path
427 .strip_prefix(repo_root)
428 .unwrap_or(&path)
429 .to_string_lossy()
430 .replace('\\', "/");
431 let is_root = !rel.contains('/');
435 scan_attr_lines(&bytes, &rel, &mut listing, &mut macros, is_root);
436 }
437 Ok(listing)
438}
439
440fn read_global_attributes(repo_root: &Path) -> Option<(String, Vec<u8>)> {
446 if let Ok(Some(path)) = crate::config::get_effective(repo_root, "core.attributesfile") {
447 let expanded = expand_tilde(&path);
448 if let Ok(bytes) = fs::read(&expanded) {
449 return Some((path, bytes));
450 }
451 }
452 let xdg = std::env::var_os("XDG_CONFIG_HOME")
453 .filter(|v| !v.is_empty())
454 .map(PathBuf::from)
455 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
456 let path = xdg.join("git").join("attributes");
457 let bytes = fs::read(&path).ok()?;
458 Some((path.to_string_lossy().into_owned(), bytes))
459}
460
461fn expand_tilde(path: &str) -> PathBuf {
465 if let Some(rest) = path.strip_prefix("~/") {
466 if let Some(home) = std::env::var_os("HOME") {
467 return PathBuf::from(home).join(rest);
468 }
469 } else if path == "~"
470 && let Some(home) = std::env::var_os("HOME")
471 {
472 return PathBuf::from(home);
473 }
474 PathBuf::from(path)
475}
476
477fn scan_attr_lines(
478 bytes: &[u8],
479 source: &str,
480 listing: &mut PatternListing,
481 macros: &mut MacroState,
482 allow_macros: bool,
483) {
484 for raw in bytes.split(|&b| b == b'\n') {
485 let line = String::from_utf8_lossy(raw);
486 let body = line.trim();
490 if body.is_empty() || body.starts_with('#') {
491 continue;
492 }
493 if body.starts_with("[attr]") {
494 if allow_macros {
499 macros.ingest(body);
500 }
501 continue;
502 }
503 let mut tokens = body.split_whitespace();
504 let Some(pattern) = tokens.next() else {
505 continue;
506 };
507 let mut filter: Option<bool> = None;
508 let mut lockable = false;
509 for tok in tokens {
510 match macros.classify(tok) {
511 FilterEffect::SetLfs => filter = Some(true),
512 FilterEffect::Clear => filter = Some(false),
513 FilterEffect::None => {}
514 }
515 if tok == "lockable" {
516 lockable = true;
517 }
518 }
519 if let Some(tracked) = filter {
520 listing.patterns.push(PatternEntry {
521 pattern: pattern.to_owned(),
522 source: source.to_owned(),
523 tracked,
524 lockable,
525 });
526 }
527 }
528}
529
530fn walk_for_gitattributes(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
531 for entry in fs::read_dir(dir)? {
532 let entry = entry?;
533 let ft = entry.file_type()?;
534 let name = entry.file_name();
535 if name == OsStr::new(".git") {
536 continue;
537 }
538 let path = entry.path();
539 if ft.is_dir() {
540 walk_for_gitattributes(&path, out)?;
541 } else if ft.is_file() && name == OsStr::new(".gitattributes") {
542 out.push(path);
543 }
544 }
545 Ok(())
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551 use tempfile::TempDir;
552
553 #[test]
554 fn negated_macro_unsets_constituent_attrs() {
555 let s = AttrSet::from_buffer(
562 b"[attr]lfs filter=lfs diff=lfs merge=lfs -text\n\
563 *.dat lfs\n\
564 b.dat !lfs\n",
565 );
566 assert_eq!(s.value("a.dat", "filter").as_deref(), Some("lfs"));
567 assert_eq!(s.value("b.dat", "filter"), None);
568 assert!(s.is_lfs_tracked("a.dat"));
569 assert!(!s.is_lfs_tracked("b.dat"));
570 }
571
572 #[test]
573 fn empty_set_has_no_matches() {
574 let s = AttrSet::empty();
575 assert_eq!(s.value("foo.txt", "filter"), None);
576 assert!(!s.is_lfs_tracked("foo.txt"));
577 assert!(!s.is_lockable("foo.txt"));
578 }
579
580 #[test]
581 fn buffer_basename_match() {
582 let s = AttrSet::from_buffer(b"*.bin filter=lfs diff=lfs merge=lfs -text\n");
583 assert!(s.is_lfs_tracked("foo.bin"));
584 assert!(s.is_lfs_tracked("nested/dir/foo.bin"));
585 assert!(!s.is_lfs_tracked("foo.txt"));
586 }
587
588 #[test]
589 fn value_returns_raw_string() {
590 let s = AttrSet::from_buffer(b"*.txt eol=lf\n");
591 assert_eq!(s.value("a.txt", "eol").as_deref(), Some("lf"));
592 }
593
594 #[test]
595 fn unset_attribute_via_dash_prefix() {
596 let s = AttrSet::from_buffer(
597 b"*.txt filter=lfs\n\
598 special.txt -filter\n",
599 );
600 assert!(s.is_lfs_tracked("a.txt"));
601 assert_eq!(s.value("special.txt", "filter"), None);
603 assert!(!s.is_lfs_tracked("special.txt"));
604 }
605
606 #[test]
607 fn lockable_set_form() {
608 let s = AttrSet::from_buffer(b"*.psd lockable\n");
609 assert!(s.is_lockable("art/cover.psd"));
610 assert!(!s.is_lockable("readme.txt"));
611 }
612
613 #[test]
614 fn is_set_treats_false_value_as_unset() {
615 let s = AttrSet::from_buffer(
616 b"truthy lockable\n\
617 falsy lockable=false\n",
618 );
619 assert!(s.is_set("truthy", "lockable"));
620 assert!(!s.is_set("falsy", "lockable"));
621 }
622
623 #[test]
624 fn rooted_pattern_only_matches_top_level() {
625 let s = AttrSet::from_buffer(b"/top.bin filter=lfs\n");
626 assert!(s.is_lfs_tracked("top.bin"));
627 assert!(!s.is_lfs_tracked("nested/top.bin"));
628 }
629
630 #[test]
631 fn workdir_loads_root_gitattributes() {
632 let tmp = TempDir::new().unwrap();
633 std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap();
634 std::fs::write(
635 tmp.path().join(".gitattributes"),
636 "*.bin filter=lfs diff=lfs merge=lfs -text\n",
637 )
638 .unwrap();
639
640 let s = AttrSet::from_workdir(tmp.path()).unwrap();
641 assert!(s.is_lfs_tracked("a.bin"));
642 assert!(s.is_lfs_tracked("sub/a.bin"));
643 }
644
645 #[test]
646 fn deeper_gitattributes_overrides_root() {
647 let tmp = TempDir::new().unwrap();
648 std::fs::create_dir_all(tmp.path().join("sub/.git_placeholder")).unwrap();
649 std::fs::write(tmp.path().join(".gitattributes"), "*.bin filter=lfs\n").unwrap();
650 std::fs::write(tmp.path().join("sub/.gitattributes"), "*.bin -filter\n").unwrap();
651
652 let s = AttrSet::from_workdir(tmp.path()).unwrap();
653 assert!(s.is_lfs_tracked("a.bin"));
654 assert!(!s.is_lfs_tracked("sub/a.bin"));
656 }
657
658 #[test]
659 fn info_attributes_loaded_from_dotgit() {
660 let tmp = TempDir::new().unwrap();
661 std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap();
662 std::fs::write(
663 tmp.path().join(".git/info/attributes"),
664 "*.bin filter=lfs\n",
665 )
666 .unwrap();
667
668 let s = AttrSet::from_workdir(tmp.path()).unwrap();
669 assert!(s.is_lfs_tracked("a.bin"));
670 }
671
672 #[test]
673 fn list_lfs_patterns_recursive() {
674 let tmp = TempDir::new().unwrap();
678 std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap();
679 std::fs::create_dir_all(tmp.path().join("a/b")).unwrap();
680 std::fs::write(
681 tmp.path().join(".gitattributes"),
682 "* text=auto\n\
683 *.jpg filter=lfs diff=lfs merge=lfs -text\n",
684 )
685 .unwrap();
686 std::fs::write(
687 tmp.path().join(".git/info/attributes"),
688 "*.mov filter=lfs -text\n",
689 )
690 .unwrap();
691 std::fs::write(
692 tmp.path().join("a/.gitattributes"),
693 "*.gif filter=lfs -text\n",
694 )
695 .unwrap();
696 std::fs::write(
697 tmp.path().join("a/b/.gitattributes"),
698 "*.png filter=lfs -text\n\
699 *.gif -filter -text\n\
700 *.mov -filter=lfs -text\n",
701 )
702 .unwrap();
703
704 let listing = list_lfs_patterns(tmp.path()).unwrap();
705 let tracked: Vec<(&str, &str)> = listing
706 .tracked()
707 .map(|p| (p.pattern.as_str(), p.source.as_str()))
708 .collect();
709 let excluded: Vec<(&str, &str)> = listing
710 .excluded()
711 .map(|p| (p.pattern.as_str(), p.source.as_str()))
712 .collect();
713
714 assert_eq!(
716 tracked,
717 vec![
718 ("*.mov", ".git/info/attributes"),
719 ("*.jpg", ".gitattributes"),
720 ("*.gif", "a/.gitattributes"),
721 ("*.png", "a/b/.gitattributes"),
722 ]
723 );
724 assert_eq!(
725 excluded,
726 vec![
727 ("*.gif", "a/b/.gitattributes"),
728 ("*.mov", "a/b/.gitattributes"),
729 ]
730 );
731 }
732
733 #[test]
734 fn list_lfs_patterns_skips_macros_and_comments() {
735 let tmp = TempDir::new().unwrap();
736 std::fs::write(
737 tmp.path().join(".gitattributes"),
738 "[attr]binary -diff -merge -text\n\
739 # *.jpg filter=lfs\n\
740 *.bin filter=lfs -text\n",
741 )
742 .unwrap();
743 let listing = list_lfs_patterns(tmp.path()).unwrap();
744 let tracked: Vec<&PatternEntry> = listing.tracked().collect();
745 assert_eq!(tracked.len(), 1);
746 assert_eq!(tracked[0].pattern, "*.bin");
747 }
748
749 #[test]
750 fn list_picks_up_lockable_attribute() {
751 let tmp = TempDir::new().unwrap();
752 std::fs::write(
753 tmp.path().join(".gitattributes"),
754 "*.psd filter=lfs diff=lfs merge=lfs lockable\n\
755 *.bin filter=lfs diff=lfs merge=lfs\n",
756 )
757 .unwrap();
758 let listing = list_lfs_patterns(tmp.path()).unwrap();
759 assert_eq!(listing.patterns.len(), 2);
760 assert_eq!(listing.patterns[0].pattern, "*.psd");
761 assert!(listing.patterns[0].lockable);
762 assert_eq!(listing.patterns[1].pattern, "*.bin");
763 assert!(!listing.patterns[1].lockable);
764 }
765
766 #[test]
767 fn list_expands_macro_to_lfs() {
768 let tmp = TempDir::new().unwrap();
773 std::fs::write(
774 tmp.path().join(".gitattributes"),
775 "[attr]lfs filter=lfs diff=lfs merge=lfs -text\n\
776 *.dat lfs\n",
777 )
778 .unwrap();
779 let listing = list_lfs_patterns(tmp.path()).unwrap();
780 let tracked: Vec<&str> = listing.tracked().map(|p| p.pattern.as_str()).collect();
781 assert_eq!(tracked, vec!["*.dat"]);
782 }
783
784 #[test]
785 fn list_expands_macro_defined_in_earlier_file() {
786 let tmp = TempDir::new().unwrap();
791 std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap();
792 std::fs::write(
793 tmp.path().join(".git/info/attributes"),
794 "[attr]lfs filter=lfs diff=lfs merge=lfs -text\n",
795 )
796 .unwrap();
797 std::fs::write(tmp.path().join(".gitattributes"), "*.dat lfs\n").unwrap();
798 let listing = list_lfs_patterns(tmp.path()).unwrap();
799 let tracked: Vec<&str> = listing.tracked().map(|p| p.pattern.as_str()).collect();
800 assert_eq!(tracked, vec!["*.dat"]);
801 }
802
803 #[test]
804 fn list_negated_macro_marks_excluded() {
805 let tmp = TempDir::new().unwrap();
810 std::fs::write(
811 tmp.path().join(".gitattributes"),
812 "[attr]lfs filter=lfs diff=lfs merge=lfs -text\n\
813 **/*.dat lfs\n\
814 other.dat !lfs\n",
815 )
816 .unwrap();
817 let listing = list_lfs_patterns(tmp.path()).unwrap();
818 let tracked: Vec<&str> = listing.tracked().map(|p| p.pattern.as_str()).collect();
819 let excluded: Vec<&str> = listing.excluded().map(|p| p.pattern.as_str()).collect();
820 assert_eq!(tracked, vec!["**/*.dat"]);
821 assert_eq!(excluded, vec!["other.dat"]);
822 }
823
824 #[test]
825 fn bang_filter_treated_as_excluded() {
826 let tmp = TempDir::new().unwrap();
827 std::fs::write(
828 tmp.path().join(".gitattributes"),
829 "*.dat filter=lfs\n\
830 a.dat !filter\n",
831 )
832 .unwrap();
833 let listing = list_lfs_patterns(tmp.path()).unwrap();
834 assert_eq!(listing.patterns.len(), 2);
835 assert!(listing.patterns[0].tracked);
836 assert_eq!(listing.patterns[1].pattern, "a.dat");
837 assert!(!listing.patterns[1].tracked);
838 }
839
840 #[test]
841 fn workdir_skips_dotgit_directory() {
842 let tmp = TempDir::new().unwrap();
845 std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
846 std::fs::write(tmp.path().join(".git/.gitattributes"), "*.bin filter=lfs\n").unwrap();
847
848 let s = AttrSet::from_workdir(tmp.path()).unwrap();
849 assert!(!s.is_lfs_tracked("a.bin"));
850 }
851}