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
315pub fn list_lfs_patterns(repo_root: &Path) -> io::Result<PatternListing> {
323 let mut listing = PatternListing::default();
324
325 if let Ok(Some(path)) = crate::config::get_effective(repo_root, "core.attributesfile") {
330 let expanded = expand_tilde(&path);
331 if let Ok(bytes) = fs::read(&expanded) {
332 scan_attr_lines(&bytes, &path, &mut listing);
333 }
334 }
335
336 let info = repo_root.join(".git").join("info").join("attributes");
337 if info.exists() {
338 let bytes = fs::read(&info)?;
339 scan_attr_lines(&bytes, ".git/info/attributes", &mut listing);
340 }
341
342 let mut found = Vec::new();
343 walk_for_gitattributes(repo_root, &mut found)?;
344 found.sort_by_key(|p| p.components().count());
345 for path in found {
346 let bytes = fs::read(&path)?;
347 let rel = path
348 .strip_prefix(repo_root)
349 .unwrap_or(&path)
350 .to_string_lossy()
351 .replace('\\', "/");
352 scan_attr_lines(&bytes, &rel, &mut listing);
353 }
354 Ok(listing)
355}
356
357fn expand_tilde(path: &str) -> PathBuf {
361 if let Some(rest) = path.strip_prefix("~/") {
362 if let Some(home) = std::env::var_os("HOME") {
363 return PathBuf::from(home).join(rest);
364 }
365 } else if path == "~"
366 && let Some(home) = std::env::var_os("HOME")
367 {
368 return PathBuf::from(home);
369 }
370 PathBuf::from(path)
371}
372
373fn scan_attr_lines(bytes: &[u8], source: &str, listing: &mut PatternListing) {
374 for raw in bytes.split(|&b| b == b'\n') {
375 let line = String::from_utf8_lossy(raw);
376 let body = line.trim();
380 if body.is_empty() || body.starts_with('#') || body.starts_with("[attr]") {
381 continue;
382 }
383 let mut tokens = body.split_whitespace();
384 let Some(pattern) = tokens.next() else {
385 continue;
386 };
387 let mut filter: Option<bool> = None;
388 let mut lockable = false;
389 for tok in tokens {
390 if tok == "filter=lfs" {
391 filter = Some(true);
392 } else if tok == "-filter" || tok == "!filter" || tok.starts_with("-filter=") {
393 filter = Some(false);
394 } else if tok == "lockable" {
395 lockable = true;
396 }
397 }
398 if let Some(tracked) = filter {
399 listing.patterns.push(PatternEntry {
400 pattern: pattern.to_owned(),
401 source: source.to_owned(),
402 tracked,
403 lockable,
404 });
405 }
406 }
407}
408
409fn walk_for_gitattributes(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
410 for entry in fs::read_dir(dir)? {
411 let entry = entry?;
412 let ft = entry.file_type()?;
413 let name = entry.file_name();
414 if name == OsStr::new(".git") {
415 continue;
416 }
417 let path = entry.path();
418 if ft.is_dir() {
419 walk_for_gitattributes(&path, out)?;
420 } else if ft.is_file() && name == OsStr::new(".gitattributes") {
421 out.push(path);
422 }
423 }
424 Ok(())
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use tempfile::TempDir;
431
432 #[test]
433 fn negated_macro_unsets_constituent_attrs() {
434 let s = AttrSet::from_buffer(
441 b"[attr]lfs filter=lfs diff=lfs merge=lfs -text\n\
442 *.dat lfs\n\
443 b.dat !lfs\n",
444 );
445 assert_eq!(s.value("a.dat", "filter").as_deref(), Some("lfs"));
446 assert_eq!(s.value("b.dat", "filter"), None);
447 assert!(s.is_lfs_tracked("a.dat"));
448 assert!(!s.is_lfs_tracked("b.dat"));
449 }
450
451 #[test]
452 fn empty_set_has_no_matches() {
453 let s = AttrSet::empty();
454 assert_eq!(s.value("foo.txt", "filter"), None);
455 assert!(!s.is_lfs_tracked("foo.txt"));
456 assert!(!s.is_lockable("foo.txt"));
457 }
458
459 #[test]
460 fn buffer_basename_match() {
461 let s = AttrSet::from_buffer(b"*.bin filter=lfs diff=lfs merge=lfs -text\n");
462 assert!(s.is_lfs_tracked("foo.bin"));
463 assert!(s.is_lfs_tracked("nested/dir/foo.bin"));
464 assert!(!s.is_lfs_tracked("foo.txt"));
465 }
466
467 #[test]
468 fn value_returns_raw_string() {
469 let s = AttrSet::from_buffer(b"*.txt eol=lf\n");
470 assert_eq!(s.value("a.txt", "eol").as_deref(), Some("lf"));
471 }
472
473 #[test]
474 fn unset_attribute_via_dash_prefix() {
475 let s = AttrSet::from_buffer(
476 b"*.txt filter=lfs\n\
477 special.txt -filter\n",
478 );
479 assert!(s.is_lfs_tracked("a.txt"));
480 assert_eq!(s.value("special.txt", "filter"), None);
482 assert!(!s.is_lfs_tracked("special.txt"));
483 }
484
485 #[test]
486 fn lockable_set_form() {
487 let s = AttrSet::from_buffer(b"*.psd lockable\n");
488 assert!(s.is_lockable("art/cover.psd"));
489 assert!(!s.is_lockable("readme.txt"));
490 }
491
492 #[test]
493 fn is_set_treats_false_value_as_unset() {
494 let s = AttrSet::from_buffer(
495 b"truthy lockable\n\
496 falsy lockable=false\n",
497 );
498 assert!(s.is_set("truthy", "lockable"));
499 assert!(!s.is_set("falsy", "lockable"));
500 }
501
502 #[test]
503 fn rooted_pattern_only_matches_top_level() {
504 let s = AttrSet::from_buffer(b"/top.bin filter=lfs\n");
505 assert!(s.is_lfs_tracked("top.bin"));
506 assert!(!s.is_lfs_tracked("nested/top.bin"));
507 }
508
509 #[test]
510 fn workdir_loads_root_gitattributes() {
511 let tmp = TempDir::new().unwrap();
512 std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap();
513 std::fs::write(
514 tmp.path().join(".gitattributes"),
515 "*.bin filter=lfs diff=lfs merge=lfs -text\n",
516 )
517 .unwrap();
518
519 let s = AttrSet::from_workdir(tmp.path()).unwrap();
520 assert!(s.is_lfs_tracked("a.bin"));
521 assert!(s.is_lfs_tracked("sub/a.bin"));
522 }
523
524 #[test]
525 fn deeper_gitattributes_overrides_root() {
526 let tmp = TempDir::new().unwrap();
527 std::fs::create_dir_all(tmp.path().join("sub/.git_placeholder")).unwrap();
528 std::fs::write(tmp.path().join(".gitattributes"), "*.bin filter=lfs\n").unwrap();
529 std::fs::write(tmp.path().join("sub/.gitattributes"), "*.bin -filter\n").unwrap();
530
531 let s = AttrSet::from_workdir(tmp.path()).unwrap();
532 assert!(s.is_lfs_tracked("a.bin"));
533 assert!(!s.is_lfs_tracked("sub/a.bin"));
535 }
536
537 #[test]
538 fn info_attributes_loaded_from_dotgit() {
539 let tmp = TempDir::new().unwrap();
540 std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap();
541 std::fs::write(
542 tmp.path().join(".git/info/attributes"),
543 "*.bin filter=lfs\n",
544 )
545 .unwrap();
546
547 let s = AttrSet::from_workdir(tmp.path()).unwrap();
548 assert!(s.is_lfs_tracked("a.bin"));
549 }
550
551 #[test]
552 fn list_lfs_patterns_recursive() {
553 let tmp = TempDir::new().unwrap();
557 std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap();
558 std::fs::create_dir_all(tmp.path().join("a/b")).unwrap();
559 std::fs::write(
560 tmp.path().join(".gitattributes"),
561 "* text=auto\n\
562 *.jpg filter=lfs diff=lfs merge=lfs -text\n",
563 )
564 .unwrap();
565 std::fs::write(
566 tmp.path().join(".git/info/attributes"),
567 "*.mov filter=lfs -text\n",
568 )
569 .unwrap();
570 std::fs::write(
571 tmp.path().join("a/.gitattributes"),
572 "*.gif filter=lfs -text\n",
573 )
574 .unwrap();
575 std::fs::write(
576 tmp.path().join("a/b/.gitattributes"),
577 "*.png filter=lfs -text\n\
578 *.gif -filter -text\n\
579 *.mov -filter=lfs -text\n",
580 )
581 .unwrap();
582
583 let listing = list_lfs_patterns(tmp.path()).unwrap();
584 let tracked: Vec<(&str, &str)> = listing
585 .tracked()
586 .map(|p| (p.pattern.as_str(), p.source.as_str()))
587 .collect();
588 let excluded: Vec<(&str, &str)> = listing
589 .excluded()
590 .map(|p| (p.pattern.as_str(), p.source.as_str()))
591 .collect();
592
593 assert_eq!(
595 tracked,
596 vec![
597 ("*.mov", ".git/info/attributes"),
598 ("*.jpg", ".gitattributes"),
599 ("*.gif", "a/.gitattributes"),
600 ("*.png", "a/b/.gitattributes"),
601 ]
602 );
603 assert_eq!(
604 excluded,
605 vec![
606 ("*.gif", "a/b/.gitattributes"),
607 ("*.mov", "a/b/.gitattributes"),
608 ]
609 );
610 }
611
612 #[test]
613 fn list_lfs_patterns_skips_macros_and_comments() {
614 let tmp = TempDir::new().unwrap();
615 std::fs::write(
616 tmp.path().join(".gitattributes"),
617 "[attr]binary -diff -merge -text\n\
618 # *.jpg filter=lfs\n\
619 *.bin filter=lfs -text\n",
620 )
621 .unwrap();
622 let listing = list_lfs_patterns(tmp.path()).unwrap();
623 let tracked: Vec<&PatternEntry> = listing.tracked().collect();
624 assert_eq!(tracked.len(), 1);
625 assert_eq!(tracked[0].pattern, "*.bin");
626 }
627
628 #[test]
629 fn list_picks_up_lockable_attribute() {
630 let tmp = TempDir::new().unwrap();
631 std::fs::write(
632 tmp.path().join(".gitattributes"),
633 "*.psd filter=lfs diff=lfs merge=lfs lockable\n\
634 *.bin filter=lfs diff=lfs merge=lfs\n",
635 )
636 .unwrap();
637 let listing = list_lfs_patterns(tmp.path()).unwrap();
638 assert_eq!(listing.patterns.len(), 2);
639 assert_eq!(listing.patterns[0].pattern, "*.psd");
640 assert!(listing.patterns[0].lockable);
641 assert_eq!(listing.patterns[1].pattern, "*.bin");
642 assert!(!listing.patterns[1].lockable);
643 }
644
645 #[test]
646 fn bang_filter_treated_as_excluded() {
647 let tmp = TempDir::new().unwrap();
648 std::fs::write(
649 tmp.path().join(".gitattributes"),
650 "*.dat filter=lfs\n\
651 a.dat !filter\n",
652 )
653 .unwrap();
654 let listing = list_lfs_patterns(tmp.path()).unwrap();
655 assert_eq!(listing.patterns.len(), 2);
656 assert!(listing.patterns[0].tracked);
657 assert_eq!(listing.patterns[1].pattern, "a.dat");
658 assert!(!listing.patterns[1].tracked);
659 }
660
661 #[test]
662 fn workdir_skips_dotgit_directory() {
663 let tmp = TempDir::new().unwrap();
666 std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
667 std::fs::write(tmp.path().join(".git/.gitattributes"), "*.bin filter=lfs\n").unwrap();
668
669 let s = AttrSet::from_workdir(tmp.path()).unwrap();
670 assert!(!s.is_lfs_tracked("a.bin"));
671 }
672}