1use crate::config::parse_path;
8use crate::config::ConfigSet;
9#[cfg(unix)]
10use crate::index::normalize_mode;
11use crate::index::Index;
12use crate::index::MODE_EXECUTABLE;
13use crate::index::MODE_GITLINK;
14use crate::index::MODE_REGULAR;
15use crate::index::MODE_SYMLINK;
16use crate::index::MODE_TREE;
17use crate::objects::parse_tree;
18use crate::objects::ObjectId;
19use crate::objects::ObjectKind;
20use crate::odb::Odb;
21use crate::repo::Repository;
22use crate::rev_parse::resolve_revision;
23use crate::wildmatch::{wildmatch, WM_CASEFOLD, WM_PATHNAME};
24use std::borrow::Cow;
25use std::collections::HashMap;
26use std::ffi::OsStr;
27use std::fs;
28use std::path::{Component, Path, PathBuf};
29use std::sync::{Arc, Mutex, OnceLock};
30use std::time::SystemTime;
31
32pub const MAX_ATTR_LINE_BYTES: usize = 2048;
35
36pub const MAX_ATTR_FILE_BYTES: usize = 100 * 1024 * 1024;
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum AttrValue {
42 Set,
43 Unset,
45 Clear,
47 Value(String),
48}
49
50impl AttrValue {
51 #[must_use]
53 pub fn display(&self) -> &str {
54 match self {
55 AttrValue::Set => "set",
56 AttrValue::Unset => "unset",
57 AttrValue::Clear => "unspecified",
58 AttrValue::Value(v) => v.as_str(),
59 }
60 }
61}
62
63const PAT_NODIR: u32 = 1;
65const PAT_MUSTBEDIR: u32 = 2;
66const PAT_ENDSWITH: u32 = 4;
67
68#[inline]
69fn is_glob_special_attr(c: u8) -> bool {
70 matches!(c, b'*' | b'?' | b'[' | b'\\')
71}
72
73fn simple_length_pat(s: &str) -> usize {
75 let b = s.as_bytes();
76 let mut i = 0;
77 while i < b.len() {
78 if is_glob_special_attr(b[i]) {
79 return i;
80 }
81 i += 1;
82 }
83 i
84}
85
86fn parse_attr_pattern_fields(pat: &str) -> (String, u32, usize) {
88 let mut flags = 0u32;
89 let mut len = pat.len();
90 if len > 0 && pat.as_bytes()[len - 1] == b'/' {
91 len -= 1;
92 flags |= PAT_MUSTBEDIR;
93 }
94 let p = &pat[..len];
95 let has_slash = p.as_bytes().contains(&b'/');
96 if !has_slash {
97 flags |= PAT_NODIR;
98 }
99 if let Some(rest) = p.strip_prefix('*') {
100 if !rest.is_empty() && simple_length_pat(rest) == rest.len() {
101 flags |= PAT_ENDSWITH;
102 }
103 }
104 let mut nowild = simple_length_pat(p);
105 if nowild > len {
106 nowild = len;
107 }
108 (p.to_string(), flags, nowild)
109}
110
111#[derive(Debug, Clone)]
113pub struct AttrRule {
114 pub attr_base: String,
117 pub pattern: String,
119 pub pattern_flags: u32,
121 pub nowildcardlen: usize,
123 pub skip: bool,
125 pub line: usize,
127 pub attrs: Vec<(String, AttrValue)>,
129}
130
131#[derive(Debug, Clone, Default)]
133pub struct MacroTable {
134 pub defs: HashMap<String, Vec<(String, AttrValue)>>,
136}
137
138#[derive(Debug, Clone, Default)]
140pub struct ParsedGitAttributes {
141 pub rules: Vec<AttrRule>,
142 pub macros: MacroTable,
143 pub warnings: Vec<String>,
144}
145
146#[must_use]
148pub fn is_reserved_builtin_name(name: &str) -> bool {
149 let Some(rest) = name.strip_prefix("builtin_") else {
150 return false;
151 };
152 matches!(rest, "objectmode")
153}
154
155pub fn validate_rules_for_add(
159 rules: &[AttrRule],
160 display_path: &str,
161) -> std::result::Result<(), String> {
162 for rule in rules {
163 if rule.skip {
164 continue;
165 }
166 for (name, _) in &rule.attrs {
167 if name.starts_with("builtin_") && !is_reserved_builtin_name(name) {
168 return Err(format!(
169 "{name} is not a valid attribute name: {display_path}:{}",
170 rule.line
171 ));
172 }
173 }
174 }
175 Ok(())
176}
177
178pub fn builtin_warnings_for_rules(rules: &[AttrRule], display_path: &str) -> Vec<String> {
180 let mut w = Vec::new();
181 for rule in rules {
182 if rule.skip {
183 continue;
184 }
185 for (name, _) in &rule.attrs {
186 if name == "builtin_objectmode" {
187 w.push(format!(
188 "builtin_objectmode is not a valid attribute name: {display_path}:{}",
189 rule.line
190 ));
191 } else if name.starts_with("builtin_") && !is_reserved_builtin_name(name) {
192 w.push(format!(
193 "{name} is not a valid attribute name: {display_path}:{}",
194 rule.line
195 ));
196 }
197 }
198 }
199 w
200}
201
202fn default_global_attributes_path() -> Option<PathBuf> {
203 let home = std::env::var("HOME").ok()?;
204 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
205 if !xdg.is_empty() {
206 return Some(PathBuf::from(xdg).join("git/attributes"));
207 }
208 }
209 Some(PathBuf::from(home).join(".config/git/attributes"))
210}
211
212fn global_attributes_path(
213 repo: &Repository,
214) -> std::result::Result<Option<PathBuf>, crate::error::Error> {
215 let config = ConfigSet::load(Some(&repo.git_dir), true)?;
216 if let Some(path) = config.get("core.attributesfile") {
217 return Ok(Some(PathBuf::from(parse_path(&path))));
218 }
219 Ok(default_global_attributes_path())
220}
221
222fn read_gitattributes_maybe_symlink(
224 path: &Path,
225 display: &str,
226 warnings: &mut Vec<String>,
227) -> Option<String> {
228 let meta = fs::symlink_metadata(path).ok()?;
229 if meta.file_type().is_symlink() {
230 warnings.push(format!(
231 "unable to access '{display}': Too many levels of symbolic links"
232 ));
233 return None;
234 }
235 fs::read_to_string(path).ok()
236}
237
238pub fn parse_gitattributes_file_content(content: &str, display_path: &str) -> ParsedGitAttributes {
241 parse_gitattributes_content_impl(content, display_path, false, "")
242}
243
244pub fn parse_gitattributes_file_content_with_base(
247 content: &str,
248 display_path: &str,
249 attr_base: &str,
250) -> ParsedGitAttributes {
251 parse_gitattributes_content_impl(content, display_path, false, attr_base)
252}
253
254fn preprocess_gitattributes_blob_text(content: &str) -> Cow<'_, str> {
255 if !content.contains("\\n") {
256 return Cow::Borrowed(content);
257 }
258 Cow::Owned(content.replace("\\n", "\n"))
259}
260
261fn parse_gitattributes_content_impl(
262 content: &str,
263 display_path: &str,
264 from_blob: bool,
265 attr_base: &str,
266) -> ParsedGitAttributes {
267 let preprocessed = if from_blob {
268 preprocess_gitattributes_blob_text(content)
269 } else {
270 Cow::Borrowed(content)
271 };
272 let content = preprocessed.as_ref();
273
274 let mut out = ParsedGitAttributes::default();
275 for (idx, raw_line) in content.lines().enumerate() {
276 let line_no = idx + 1;
277 let line_bytes = raw_line.as_bytes();
278 if line_bytes.len() >= MAX_ATTR_LINE_BYTES {
279 out.warnings.push(format!(
280 "warning: ignoring overly long attributes line {line_no}"
281 ));
282 continue;
283 }
284 parse_one_line(
285 raw_line,
286 line_no,
287 display_path,
288 from_blob,
289 attr_base,
290 &mut out,
291 );
292 }
293 out.warnings
294 .extend(builtin_warnings_for_rules(&out.rules, display_path));
295 out
296}
297
298fn skip_ascii_blank(s: &str) -> &str {
300 s.trim_start_matches([' ', '\t', '\r', '\n'])
301}
302
303fn split_at_first_blank(s: &str) -> (&str, &str) {
305 let bytes = s.as_bytes();
306 let n = bytes
307 .iter()
308 .position(|&b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
309 .unwrap_or(bytes.len());
310 s.split_at(n)
311}
312
313fn unquote_c_style(quoted: &str) -> Result<(String, &str), ()> {
315 let b = quoted.as_bytes();
316 if b.is_empty() || b[0] != b'"' {
317 return Err(());
318 }
319 let mut q = &b[1..];
320 let mut out = Vec::new();
321 loop {
322 let len = q
323 .iter()
324 .position(|&c| c == b'"' || c == b'\\')
325 .unwrap_or(q.len());
326 out.extend_from_slice(&q[..len]);
327 q = &q[len..];
328 if q.is_empty() {
329 return Err(());
330 }
331 match q[0] {
332 b'"' => {
333 let rest = std::str::from_utf8(&q[1..]).map_err(|_| ())?;
334 return Ok((String::from_utf8(out).map_err(|_| ())?, rest));
335 }
336 b'\\' => {
337 q = &q[1..];
338 if q.is_empty() {
339 return Err(());
340 }
341 let ch = q[0];
342 q = &q[1..];
343 match ch {
344 b'a' => out.push(0x07),
345 b'b' => out.push(0x08),
346 b'f' => out.push(0x0c),
347 b'n' => out.push(b'\n'),
348 b'r' => out.push(b'\r'),
349 b't' => out.push(b'\t'),
350 b'v' => out.push(0x0b),
351 b'\\' => out.push(b'\\'),
352 b'"' => out.push(b'"'),
353 b'0'..=b'3' => {
354 let mut ac = u32::from(ch - b'0') << 6;
355 if q.len() < 2 {
356 return Err(());
357 }
358 let ch2 = q[0];
359 let ch3 = q[1];
360 if !(b'0'..=b'7').contains(&ch2) || !(b'0'..=b'7').contains(&ch3) {
361 return Err(());
362 }
363 ac |= u32::from(ch2 - b'0') << 3;
364 ac |= u32::from(ch3 - b'0');
365 q = &q[2..];
366 out.push(ac as u8);
367 }
368 _ => return Err(()),
369 }
370 }
371 _ => return Err(()),
372 }
373 }
374}
375
376fn parse_one_attr_token_git(s: &str) -> (&str, Option<&str>, &str) {
378 let bytes = s.as_bytes();
379 let token_end = bytes
380 .iter()
381 .position(|&b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
382 .unwrap_or(bytes.len());
383 let eq_pos = s.find('=');
384 let eq_in_token = eq_pos.filter(|&eq| eq < token_end);
385 let (name, val) = if let Some(eq) = eq_in_token {
386 (&s[..eq], Some(&s[eq + 1..token_end]))
387 } else {
388 (&s[..token_end], None)
389 };
390 let rest = skip_ascii_blank(&s[token_end..]);
391 (name, val, rest)
392}
393
394fn accumulate_attr_states(
395 mut states: &str,
396 attrs: &mut Vec<(String, AttrValue)>,
397 macros: &MacroTable,
398 in_macro_def: bool,
399) {
400 loop {
401 states = skip_ascii_blank(states);
402 if states.is_empty() {
403 break;
404 }
405 let (name, val, rest) = parse_one_attr_token_git(states);
406 states = rest;
407 let tok = match val {
408 Some(v) => format!("{name}={v}"),
409 None => name.to_string(),
410 };
411 push_attr_token(&tok, attrs, macros, in_macro_def);
412 }
413}
414
415const ATTR_MACRO_PREFIX: &str = "[attr]";
416
417fn parse_one_line(
418 raw_line: &str,
419 line_no: usize,
420 display_path: &str,
421 from_blob: bool,
422 attr_base: &str,
423 out: &mut ParsedGitAttributes,
424) {
425 let _ = display_path;
426 let _ = from_blob;
427 let cp = skip_ascii_blank(raw_line);
428 if cp.is_empty() || cp.starts_with('#') {
429 return;
430 }
431
432 let (pattern_token, states) = if cp.as_bytes().first() == Some(&b'"') {
433 match unquote_c_style(cp) {
434 Ok((pat, rest)) => (pat, rest),
435 Err(()) => {
436 let (a, b) = split_at_first_blank(cp);
437 (a.to_string(), b)
438 }
439 }
440 } else {
441 let (a, b) = split_at_first_blank(cp);
442 (a.to_string(), b)
443 };
444
445 if pattern_token.len() > ATTR_MACRO_PREFIX.len() && pattern_token.starts_with(ATTR_MACRO_PREFIX)
446 {
447 let rest = skip_ascii_blank(&pattern_token[ATTR_MACRO_PREFIX.len()..]);
448 let (macro_name, leftover) = split_at_first_blank(rest);
449 if !leftover.is_empty() || macro_name.is_empty() {
450 return;
451 }
452 let mut attrs = Vec::new();
453 accumulate_attr_states(states, &mut attrs, &out.macros, true);
454 out.macros.defs.insert(macro_name.to_string(), attrs);
455 return;
456 }
457
458 if pattern_token.starts_with('!') && !pattern_token.starts_with("\\!") {
459 out.warnings
460 .push("Negative patterns are ignored".to_string());
461 return;
462 }
463 let pattern_raw = pattern_token.replace("\\!", "!");
464 let (pattern, pattern_flags, nowildcardlen) = parse_attr_pattern_fields(&pattern_raw);
465 let mut attrs = Vec::new();
466 accumulate_attr_states(states, &mut attrs, &out.macros, false);
467 if attrs.is_empty() {
468 return;
469 }
470 out.rules.push(AttrRule {
471 attr_base: attr_base.to_string(),
472 pattern,
473 pattern_flags,
474 nowildcardlen,
475 skip: false,
476 line: line_no,
477 attrs,
478 });
479}
480
481fn push_attr_token(
482 tok: &str,
483 attrs: &mut Vec<(String, AttrValue)>,
484 _macros: &MacroTable,
485 in_macro_def: bool,
486) {
487 if tok == "binary" {
488 attrs.push(("text".into(), AttrValue::Unset));
489 attrs.push(("diff".into(), AttrValue::Unset));
490 attrs.push(("merge".into(), AttrValue::Unset));
491 attrs.push(("binary".into(), AttrValue::Set));
492 return;
493 }
494 if in_macro_def {
495 if let Some(rest) = tok.strip_prefix('!') {
496 attrs.push((rest.to_string(), AttrValue::Clear));
497 return;
498 }
499 }
500 if let Some(rest) = tok.strip_prefix('-') {
501 attrs.push((rest.to_string(), AttrValue::Unset));
502 return;
503 }
504 if let Some((k, v)) = tok.split_once('=') {
505 let v = v.trim_end_matches(|c: char| {
506 matches!(c, ' ' | '\t' | '\r' | '\n') || c == '\u{000b}' || c == '\u{000c}'
507 });
508 attrs.push((k.to_string(), AttrValue::Value(v.to_string())));
509 return;
510 }
511 attrs.push((tok.to_string(), AttrValue::Set));
512}
513
514fn fspathncmp(a: &[u8], b: &[u8], count: usize, icase: bool) -> bool {
515 if a.len() < count || b.len() < count {
516 return false;
517 }
518 if icase {
519 a[..count]
520 .iter()
521 .zip(&b[..count])
522 .all(|(x, y)| x.eq_ignore_ascii_case(y))
523 } else {
524 a[..count] == b[..count]
525 }
526}
527
528fn match_basename_git(
530 basename: &[u8],
531 pattern: &[u8],
532 prefix: usize,
533 patternlen: usize,
534 pat_flags: u32,
535 icase: bool,
536) -> bool {
537 let basenamelen = basename.len();
538 let wm_flags = if icase { WM_CASEFOLD } else { 0 };
539 if prefix == patternlen {
540 return patternlen == basenamelen && fspathncmp(pattern, basename, basenamelen, icase);
541 }
542 if (pat_flags & PAT_ENDSWITH) != 0 {
543 if patternlen <= 1 {
544 return false;
545 }
546 let lit_len = patternlen - 1;
547 if lit_len > basenamelen {
548 return false;
549 }
550 return fspathncmp(
551 &pattern[1..patternlen],
552 &basename[basenamelen - lit_len..],
553 lit_len,
554 icase,
555 );
556 }
557 wildmatch(&pattern[..patternlen], basename, wm_flags)
558}
559
560#[allow(clippy::too_many_arguments)]
562fn match_pathname_git(
563 pathname: &[u8],
564 pathlen: usize,
565 base: &[u8],
566 baselen: usize,
567 mut pattern: &[u8],
568 mut prefix: usize,
569 mut patternlen: usize,
570 icase: bool,
571) -> bool {
572 let pathname = &pathname[..pathlen.min(pathname.len())];
573
574 if !pattern.is_empty() && pattern[0] == b'/' {
575 pattern = &pattern[1..];
576 patternlen -= 1;
577 prefix = prefix.saturating_sub(1);
578 }
579
580 if pathlen < baselen + 1 {
581 return false;
582 }
583 if baselen > 0 && pathname[baselen] != b'/' {
584 return false;
585 }
586 if !fspathncmp(pathname, base, baselen, icase) {
587 return false;
588 }
589
590 let namelen = if baselen == 0 {
591 pathlen
592 } else {
593 pathlen - baselen - 1
594 };
595 let name = &pathname[pathlen - namelen..];
596
597 if prefix > 0 {
598 if prefix > namelen {
599 return false;
600 }
601 if !fspathncmp(pattern, name, prefix, icase) {
602 return false;
603 }
604 if patternlen == prefix && namelen == prefix {
605 return true;
606 }
607 let advance = prefix - 1;
608 pattern = &pattern[advance..];
609 patternlen -= advance;
610 let name = &name[advance..];
611 let wm_flags = WM_PATHNAME | if icase { WM_CASEFOLD } else { 0 };
612 return wildmatch(&pattern[..patternlen], name, wm_flags);
613 }
614
615 let wm_flags = WM_PATHNAME | if icase { WM_CASEFOLD } else { 0 };
616 wildmatch(&pattern[..patternlen], name, wm_flags)
617}
618
619fn path_dir_prefix(rel_path: &str) -> &str {
621 match rel_path.rfind('/') {
622 Some(i) => &rel_path[..i],
623 None => "",
624 }
625}
626
627#[must_use]
631pub fn attr_rule_applies_to_path(attr_base: &str, rel_path: &str, icase: bool) -> bool {
632 if attr_base.is_empty() {
633 return true;
634 }
635 let dir = path_dir_prefix(rel_path);
636 if dir.is_empty() {
637 return false;
638 }
639 let prefix_eq = |d: &str, b: &str| {
640 if icase {
641 d.eq_ignore_ascii_case(b)
642 } else {
643 d == b
644 }
645 };
646 if prefix_eq(dir, attr_base) {
647 return true;
648 }
649 let bl = attr_base.len();
650 if dir.len() > bl && dir.as_bytes()[bl] == b'/' && prefix_eq(&dir[..bl], attr_base) {
651 return true;
652 }
653 false
654}
655
656#[must_use]
658pub fn attr_rule_matches(rule: &AttrRule, rel_path: &str, icase: bool) -> bool {
659 if !attr_rule_applies_to_path(&rule.attr_base, rel_path, icase) {
660 return false;
661 }
662 let pathname = rel_path.as_bytes();
663 let pathlen = pathname.len();
664 let isdir = pathlen > 0 && pathname[pathlen - 1] == b'/';
665
666 if (rule.pattern_flags & PAT_MUSTBEDIR) != 0 && !isdir {
667 return false;
668 }
669
670 let eff_pathlen = if isdir { pathlen - 1 } else { pathlen };
671 let pathname_trim = &pathname[..eff_pathlen];
672
673 let basename_offset = pathname_trim
674 .iter()
675 .rposition(|&b| b == b'/')
676 .map(|i| i + 1)
677 .unwrap_or(0);
678
679 let pat = rule.pattern.as_bytes();
680 let prefix = rule.nowildcardlen.min(pat.len());
681 let patternlen = pat.len();
682
683 if (rule.pattern_flags & PAT_NODIR) != 0 {
684 let bn = &pathname_trim[basename_offset..];
685 return match_basename_git(bn, pat, prefix, patternlen, rule.pattern_flags, icase);
686 }
687
688 let base = rule.attr_base.as_bytes();
689 match_pathname_git(
690 pathname_trim,
691 eff_pathlen,
692 base,
693 base.len(),
694 pat,
695 prefix,
696 patternlen,
697 icase,
698 )
699}
700
701fn expand_rule_attrs_flat(rule: &AttrRule, macros: &MacroTable) -> Vec<(String, AttrValue)> {
706 let mut flat: Vec<(String, AttrValue)> = Vec::new();
707 for (name, val) in &rule.attrs {
708 if name == "binary" {
709 flat.push(("text".into(), AttrValue::Unset));
710 flat.push(("diff".into(), AttrValue::Unset));
711 flat.push(("merge".into(), AttrValue::Unset));
712 flat.push(("binary".into(), AttrValue::Set));
713 continue;
714 }
715 if let Some(exp) = macros.defs.get(name) {
716 flat.push((name.clone(), val.clone()));
717 for (n, v) in exp {
718 flat.push((n.clone(), v.clone()));
719 }
720 } else {
721 flat.push((name.clone(), val.clone()));
722 }
723 }
724 flat
725}
726
727pub fn collect_attrs_for_path(
729 rules: &[AttrRule],
730 macros: &MacroTable,
731 rel_path: &str,
732 icase: bool,
733) -> HashMap<String, AttrValue> {
734 let mut map: HashMap<String, AttrValue> = HashMap::new();
735 for rule in rules {
736 if rule.skip {
737 continue;
738 }
739 if !attr_rule_matches(rule, rel_path, icase) {
740 continue;
741 }
742 let ops = expand_rule_attrs_flat(rule, macros);
743 for (n, v) in ops {
744 match v {
745 AttrValue::Clear => {
746 map.remove(&n);
747 }
748 _ => {
749 map.insert(n, v);
750 }
751 }
752 }
753 }
754 map
755}
756
757#[must_use]
759pub fn quote_path_for_check_attr(path: &str) -> String {
760 let needs = path
761 .chars()
762 .any(|c| c.is_control() || c == '"' || c == '\\');
763 if !needs {
764 return path.to_string();
765 }
766 let mut s = String::new();
767 s.push('"');
768 for c in path.chars() {
769 match c {
770 '"' => s.push_str("\\\""),
771 '\\' => s.push_str("\\\\"),
772 _ if c.is_control() => s.push_str(&format!("\\{:o}", c as u32)),
773 _ => s.push(c),
774 }
775 }
776 s.push('"');
777 s
778}
779
780#[must_use]
782pub fn normalize_rel_path(path: &str) -> String {
783 let p = Path::new(path);
784 let mut stack: Vec<String> = Vec::new();
785 for c in p.components() {
786 match c {
787 Component::Normal(s) => stack.push(s.to_string_lossy().into_owned()),
788 Component::ParentDir => {
789 let _ = stack.pop();
790 }
791 Component::CurDir => {}
792 _ => {}
793 }
794 }
795 stack.join("/")
796}
797
798fn lexical_normalize_path(path: PathBuf) -> PathBuf {
799 let mut out = PathBuf::new();
800 for c in path.components() {
801 match c {
802 Component::Prefix(prefix) => out.push(prefix.as_os_str()),
803 Component::RootDir => out.push(c),
804 Component::CurDir => {}
805 Component::ParentDir => {
806 let _ = out.pop();
807 }
808 Component::Normal(_) => out.push(c),
809 }
810 }
811 out
812}
813
814pub fn path_relative_to_worktree(
819 repo: &Repository,
820 path_str: &str,
821) -> std::result::Result<String, String> {
822 let wt = repo
823 .work_tree
824 .as_ref()
825 .ok_or_else(|| "bare repository — no work tree".to_string())?;
826 let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
827 let p = Path::new(path_str);
828 let combined = if p.is_absolute() {
829 p.to_path_buf()
830 } else {
831 cwd.join(p)
832 };
833
834 let wt_canon = wt.canonicalize().map_err(|e| e.to_string())?;
835
836 if let Ok(abs) = combined.canonicalize() {
837 let rel = abs
838 .strip_prefix(&wt_canon)
839 .map_err(|_| format!("path outside repository: {}", path_str))?;
840 return Ok(normalize_rel_path(
841 rel.to_str().ok_or_else(|| "invalid path".to_string())?,
842 ));
843 }
844
845 let abs_lex = lexical_normalize_path(combined);
846 let rel = abs_lex
847 .strip_prefix(&wt_canon)
848 .map_err(|_| format!("path outside repository: {}", path_str))?;
849 Ok(normalize_rel_path(
850 rel.to_str().ok_or_else(|| "invalid path".to_string())?,
851 ))
852}
853
854fn collect_nested_gitattributes_dirs(work_tree: &Path) -> Vec<PathBuf> {
855 let mut dirs: Vec<PathBuf> = Vec::new();
856 walk_dirs(work_tree, work_tree, &mut dirs);
857 dirs.sort_by(|a, b| {
858 let da = a.components().count();
859 let db = b.components().count();
860 da.cmp(&db).then_with(|| a.cmp(b))
861 });
862 dirs
863}
864
865fn walk_dirs(root: &Path, cur: &Path, dirs: &mut Vec<PathBuf>) {
866 let Ok(rd) = fs::read_dir(cur) else {
867 return;
868 };
869 for e in rd.flatten() {
870 let p = e.path();
871 let ft = e.file_type().ok();
872 if ft.is_some_and(|t| t.is_dir()) {
873 if p.file_name() == Some(OsStr::new(".git")) {
874 continue;
875 }
876 let rel = p.strip_prefix(root).unwrap_or(&p);
877 dirs.push(rel.to_path_buf());
878 walk_dirs(root, &p, dirs);
879 }
880 }
881}
882
883type AttrFileStamp = (PathBuf, Option<(SystemTime, u64)>);
910type AttrDirStamp = (PathBuf, Option<SystemTime>);
911
912struct AttrStackCacheEntry {
913 file_stamps: Vec<AttrFileStamp>,
914 dir_stamps: Vec<AttrDirStamp>,
915 parsed: Arc<ParsedGitAttributes>,
916}
917
918fn attr_stack_cache() -> &'static Mutex<HashMap<(PathBuf, PathBuf), AttrStackCacheEntry>> {
919 static CACHE: OnceLock<Mutex<HashMap<(PathBuf, PathBuf), AttrStackCacheEntry>>> =
920 OnceLock::new();
921 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
922}
923
924fn attr_bare_cache() -> &'static Mutex<HashMap<PathBuf, AttrStackCacheEntry>> {
925 static CACHE: OnceLock<Mutex<HashMap<PathBuf, AttrStackCacheEntry>>> = OnceLock::new();
926 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
927}
928
929fn attr_tree_cache() -> &'static Mutex<HashMap<ObjectId, Arc<ParsedGitAttributes>>> {
930 static CACHE: OnceLock<Mutex<HashMap<ObjectId, Arc<ParsedGitAttributes>>>> = OnceLock::new();
931 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
932}
933
934fn attr_file_stamp(path: &Path) -> Option<(SystemTime, u64)> {
938 fs::symlink_metadata(path)
939 .ok()
940 .and_then(|m| Some((m.modified().ok()?, m.len())))
941}
942
943fn attr_dir_stamp(path: &Path) -> Option<SystemTime> {
944 fs::symlink_metadata(path)
945 .ok()
946 .and_then(|m| m.modified().ok())
947}
948
949fn attr_stamps_valid(entry: &AttrStackCacheEntry) -> bool {
950 entry
951 .file_stamps
952 .iter()
953 .all(|(path, stamp)| attr_file_stamp(path) == *stamp)
954 && entry
955 .dir_stamps
956 .iter()
957 .all(|(path, stamp)| attr_dir_stamp(path) == *stamp)
958}
959
960fn collect_stack_stamps(
975 repo: &Repository,
976 work_tree: &Path,
977) -> std::result::Result<(Vec<AttrFileStamp>, Vec<AttrDirStamp>), crate::error::Error> {
978 let mut file_stamps = Vec::new();
979 if let Some(g) = global_attributes_path(repo)? {
980 let stamp = attr_file_stamp(&g);
981 file_stamps.push((g, stamp));
982 }
983 let root_ga = work_tree.join(".gitattributes");
984 let stamp = attr_file_stamp(&root_ga);
985 file_stamps.push((root_ga, stamp));
986 let info = repo.git_dir.join("info/attributes");
987 let stamp = attr_file_stamp(&info);
988 file_stamps.push((info, stamp));
989 let dir_stamps = vec![(work_tree.to_path_buf(), attr_dir_stamp(work_tree))];
990 Ok((file_stamps, dir_stamps))
991}
992
993pub fn load_gitattributes_stack(
998 repo: &Repository,
999 work_tree: &Path,
1000) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1001 let key = (repo.git_dir.clone(), work_tree.to_path_buf());
1002 {
1003 let cache = attr_stack_cache()
1004 .lock()
1005 .unwrap_or_else(std::sync::PoisonError::into_inner);
1006 if let Some(entry) = cache.get(&key) {
1007 if attr_stamps_valid(entry) {
1008 return Ok((*entry.parsed).clone());
1009 }
1010 }
1011 }
1012 let (file_stamps, dir_stamps) = collect_stack_stamps(repo, work_tree)?;
1013 let parsed = load_gitattributes_stack_uncached(repo, work_tree)?;
1014 let mut cache = attr_stack_cache()
1015 .lock()
1016 .unwrap_or_else(std::sync::PoisonError::into_inner);
1017 cache.insert(
1018 key,
1019 AttrStackCacheEntry {
1020 file_stamps,
1021 dir_stamps,
1022 parsed: Arc::new(parsed.clone()),
1023 },
1024 );
1025 Ok(parsed)
1026}
1027
1028fn load_gitattributes_stack_uncached(
1029 repo: &Repository,
1030 work_tree: &Path,
1031) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1032 let mut merged = ParsedGitAttributes::default();
1033
1034 if let Some(g) = global_attributes_path(repo)? {
1035 if g.exists() {
1036 if let Ok(content) = fs::read_to_string(&g) {
1037 if content.len() <= MAX_ATTR_FILE_BYTES {
1038 let mut p =
1039 parse_gitattributes_file_content(&content, g.to_string_lossy().as_ref());
1040 merged.rules.append(&mut p.rules);
1041 merged.macros.defs.extend(p.macros.defs.drain());
1042 merged.warnings.append(&mut p.warnings);
1043 } else {
1044 merged.warnings.push(format!(
1045 "warning: ignoring overly large gitattributes file '{}'",
1046 g.display()
1047 ));
1048 }
1049 }
1050 }
1051 }
1052
1053 let root_ga = work_tree.join(".gitattributes");
1054 if let Some(content) =
1055 read_gitattributes_maybe_symlink(&root_ga, ".gitattributes", &mut merged.warnings)
1056 {
1057 if content.len() <= MAX_ATTR_FILE_BYTES {
1058 let mut p = parse_gitattributes_file_content(&content, ".gitattributes");
1059 merged.rules.append(&mut p.rules);
1060 merged.macros.defs.extend(p.macros.defs.drain());
1061 merged.warnings.append(&mut p.warnings);
1062 } else {
1063 merged.warnings.push(
1064 "warning: ignoring overly large gitattributes file '.gitattributes'".to_string(),
1065 );
1066 }
1067 }
1068
1069 for rel in collect_nested_gitattributes_dirs(work_tree) {
1070 let ga = work_tree.join(&rel).join(".gitattributes");
1071 if let Some(content) = read_gitattributes_maybe_symlink(
1072 &ga,
1073 &format!("{}/.gitattributes", rel.display()),
1074 &mut merged.warnings,
1075 ) {
1076 if content.len() > MAX_ATTR_FILE_BYTES {
1077 merged.warnings.push(format!(
1078 "warning: ignoring overly large gitattributes file '{}'",
1079 ga.display()
1080 ));
1081 continue;
1082 }
1083 let prefix = rel.to_string_lossy().replace('\\', "/");
1084 let mut p = parse_gitattributes_file_content_with_base(
1085 &content,
1086 &ga.to_string_lossy(),
1087 &prefix,
1088 );
1089 merged.rules.append(&mut p.rules);
1090 merged.macros.defs.extend(p.macros.defs.drain());
1091 merged.warnings.append(&mut p.warnings);
1092 }
1093 }
1094
1095 let info = repo.git_dir.join("info/attributes");
1096 if info.exists() {
1097 if let Ok(content) = fs::read_to_string(&info) {
1098 if content.len() <= MAX_ATTR_FILE_BYTES {
1099 let mut p = parse_gitattributes_file_content(&content, "info/attributes");
1100 merged.rules.append(&mut p.rules);
1101 merged.macros.defs.extend(p.macros.defs.drain());
1102 merged.warnings.append(&mut p.warnings);
1103 }
1104 }
1105 }
1106
1107 Ok(merged)
1108}
1109
1110pub fn load_gitattributes_bare(
1114 repo: &Repository,
1115) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1116 let key = repo.git_dir.clone();
1117 {
1118 let cache = attr_bare_cache()
1119 .lock()
1120 .unwrap_or_else(std::sync::PoisonError::into_inner);
1121 if let Some(entry) = cache.get(&key) {
1122 if attr_stamps_valid(entry) {
1123 return Ok((*entry.parsed).clone());
1124 }
1125 }
1126 }
1127 let mut file_stamps = Vec::new();
1128 if let Some(g) = global_attributes_path(repo)? {
1129 let stamp = attr_file_stamp(&g);
1130 file_stamps.push((g, stamp));
1131 }
1132 let info = repo.git_dir.join("info/attributes");
1133 let stamp = attr_file_stamp(&info);
1134 file_stamps.push((info, stamp));
1135 let parsed = load_gitattributes_bare_uncached(repo)?;
1136 let mut cache = attr_bare_cache()
1137 .lock()
1138 .unwrap_or_else(std::sync::PoisonError::into_inner);
1139 cache.insert(
1140 key,
1141 AttrStackCacheEntry {
1142 file_stamps,
1143 dir_stamps: Vec::new(),
1144 parsed: Arc::new(parsed.clone()),
1145 },
1146 );
1147 Ok(parsed)
1148}
1149
1150fn load_gitattributes_bare_uncached(
1151 repo: &Repository,
1152) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1153 let mut merged = ParsedGitAttributes::default();
1154 if let Some(g) = global_attributes_path(repo)? {
1155 if g.exists() {
1156 if let Ok(content) = fs::read_to_string(&g) {
1157 if content.len() <= MAX_ATTR_FILE_BYTES {
1158 let mut p =
1159 parse_gitattributes_file_content(&content, g.to_string_lossy().as_ref());
1160 merged.rules.append(&mut p.rules);
1161 merged.macros.defs.extend(p.macros.defs.drain());
1162 merged.warnings.append(&mut p.warnings);
1163 }
1164 }
1165 }
1166 }
1167 let info = repo.git_dir.join("info/attributes");
1168 if info.exists() {
1169 if let Ok(content) = fs::read_to_string(&info) {
1170 if content.len() <= MAX_ATTR_FILE_BYTES {
1171 let mut p = parse_gitattributes_file_content(&content, "info/attributes");
1172 merged.rules.append(&mut p.rules);
1173 merged.macros.defs.extend(p.macros.defs.drain());
1174 merged.warnings.append(&mut p.warnings);
1175 }
1176 }
1177 }
1178 if let Ok(index) = Index::load(&repo.git_dir.join("index")) {
1183 if let Ok(mut from_index) = load_gitattributes_from_index(&index, &repo.odb, &repo.git_dir)
1184 {
1185 from_index.rules.append(&mut merged.rules);
1188 merged.rules = from_index.rules;
1189 for (k, v) in from_index.macros.defs.drain() {
1190 merged.macros.defs.entry(k).or_insert(v);
1191 }
1192 merged.warnings.append(&mut from_index.warnings);
1193 }
1194 }
1195 Ok(merged)
1196}
1197
1198pub fn load_gitattributes_from_tree(
1200 odb: &Odb,
1201 tree_oid: &ObjectId,
1202) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1203 {
1205 let cache = attr_tree_cache()
1206 .lock()
1207 .unwrap_or_else(std::sync::PoisonError::into_inner);
1208 if let Some(parsed) = cache.get(tree_oid) {
1209 return Ok((**parsed).clone());
1210 }
1211 }
1212 let mut merged = ParsedGitAttributes::default();
1213 walk_tree_attrs(odb, tree_oid, "", &mut merged)?;
1214 let mut cache = attr_tree_cache()
1215 .lock()
1216 .unwrap_or_else(std::sync::PoisonError::into_inner);
1217 cache.insert(*tree_oid, Arc::new(merged.clone()));
1218 Ok(merged)
1219}
1220
1221fn walk_tree_attrs(
1222 odb: &Odb,
1223 tree_oid: &ObjectId,
1224 prefix: &str,
1225 merged: &mut ParsedGitAttributes,
1226) -> std::result::Result<(), crate::error::Error> {
1227 let obj = odb.read(tree_oid)?;
1228 if obj.kind != ObjectKind::Tree {
1229 return Ok(());
1230 }
1231 let entries = parse_tree(&obj.data)?;
1232 for e in entries {
1233 let name = String::from_utf8_lossy(&e.name).to_string();
1234 let path = if prefix.is_empty() {
1235 name.clone()
1236 } else {
1237 format!("{prefix}/{name}")
1238 };
1239 match e.mode {
1240 0o040000 => {
1241 walk_tree_attrs(odb, &e.oid, &path, merged)?;
1242 }
1243 0o100644 | 0o100755 | 0o120000 if name == ".gitattributes" => {
1244 let oid = e.oid;
1245 {
1246 let blob = odb.read(&oid)?;
1247 if blob.kind != ObjectKind::Blob {
1248 continue;
1249 }
1250 if blob.data.len() > MAX_ATTR_FILE_BYTES {
1251 merged.warnings.push(
1252 "warning: ignoring overly large gitattributes blob '.gitattributes'"
1253 .to_string(),
1254 );
1255 continue;
1256 }
1257 let content = String::from_utf8_lossy(&blob.data).into_owned();
1258 let display = format!("{path} (tree)");
1259 let attr_base = Path::new(&path)
1260 .parent()
1261 .map(|p| p.to_string_lossy().replace('\\', "/"))
1262 .unwrap_or_default();
1263 let mut p =
1264 parse_gitattributes_content_impl(&content, &display, true, &attr_base);
1265 merged.rules.append(&mut p.rules);
1266 merged.macros.defs.extend(p.macros.defs.drain());
1267 merged.warnings.append(&mut p.warnings);
1268 }
1269 }
1270 _ => {}
1271 }
1272 }
1273 Ok(())
1274}
1275
1276pub fn load_gitattributes_for_diff(
1286 repo: &Repository,
1287) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1288 let (treeish, ignore_bad_tree) = resolve_attr_treeish(repo, None)?;
1289 if let Some(spec) = treeish.filter(|s| !s.is_empty()) {
1290 match resolve_tree_oid(repo, &spec) {
1291 Ok(oid) => return load_gitattributes_from_tree(&repo.odb, &oid),
1292 Err(_) if ignore_bad_tree => {}
1293 Err(_) => {
1294 return Err(crate::error::Error::InvalidRef(format!(
1295 "bad --attr-source or GIT_ATTR_SOURCE: {spec}"
1296 )));
1297 }
1298 }
1299 }
1300 if let Some(wt) = repo.work_tree.as_deref() {
1301 return load_gitattributes_stack(repo, wt);
1302 }
1303 load_gitattributes_bare(repo)
1304}
1305
1306pub fn resolve_attr_treeish(
1312 repo: &Repository,
1313 source_arg: Option<&str>,
1314) -> std::result::Result<(Option<String>, bool), crate::error::Error> {
1315 let env_src = std::env::var("GIT_ATTR_SOURCE")
1316 .ok()
1317 .filter(|s| !s.is_empty());
1318 let config = ConfigSet::load(Some(&repo.git_dir), true)?;
1319 let cfg_tree = config.get("attr.tree");
1320 if let Some(s) = source_arg.map(|s| s.to_string()) {
1321 return Ok((Some(s), false));
1322 }
1323 if let Some(s) = env_src {
1324 return Ok((Some(s), false));
1325 }
1326 if let Some(s) = cfg_tree {
1327 return Ok((Some(s), true));
1328 }
1329 Ok((None, false))
1330}
1331
1332pub fn resolve_tree_oid(repo: &Repository, spec: &str) -> std::result::Result<ObjectId, String> {
1334 let oid = resolve_revision(repo, spec).map_err(|e| e.to_string())?;
1335 let obj = repo.read_replaced(&oid).map_err(|e| e.to_string())?;
1336 match obj.kind {
1337 ObjectKind::Commit => {
1338 let c = crate::objects::parse_commit(&obj.data).map_err(|e| e.to_string())?;
1339 Ok(c.tree)
1340 }
1341 ObjectKind::Tree => Ok(oid),
1342 _ => Err("revision is not a commit or tree".to_string()),
1343 }
1344}
1345
1346pub fn load_gitattributes_from_index(
1348 index: &Index,
1349 odb: &Odb,
1350 work_tree: &Path,
1351) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1352 let mut merged = ParsedGitAttributes::default();
1353 let mut paths: Vec<Vec<u8>> = index
1354 .entries
1355 .iter()
1356 .filter(|e| e.stage() == 0 && e.path.ends_with(b".gitattributes"))
1357 .map(|e| e.path.clone())
1358 .collect();
1359 paths.sort();
1360 for path_bytes in paths {
1361 let Ok(rel) = std::str::from_utf8(&path_bytes) else {
1362 continue;
1363 };
1364 let Some(entry) = index.get(&path_bytes, 0) else {
1365 continue;
1366 };
1367 let obj = odb.read(&entry.oid)?;
1368 if obj.data.len() > MAX_ATTR_FILE_BYTES {
1369 merged.warnings.push(format!(
1370 "warning: ignoring overly large gitattributes blob '{}'",
1371 rel
1372 ));
1373 continue;
1374 }
1375 let content = String::from_utf8_lossy(&obj.data);
1376 let attr_base = Path::new(rel)
1377 .parent()
1378 .map(|p| p.to_string_lossy().replace('\\', "/"))
1379 .unwrap_or_default();
1380 let mut p = parse_gitattributes_content_impl(&content, rel, true, &attr_base);
1381 merged.rules.append(&mut p.rules);
1382 merged.macros.defs.extend(p.macros.defs.drain());
1383 merged.warnings.append(&mut p.warnings);
1384 }
1385 let _ = work_tree;
1386 Ok(merged)
1387}
1388
1389#[must_use]
1394pub fn builtin_objectmode_worktree(repo: &Repository, rel_path: &str) -> Option<String> {
1395 let wt = repo.work_tree.as_ref()?;
1396 let p = wt.join(rel_path);
1397 let meta = fs::symlink_metadata(&p).ok()?;
1398 let ft = meta.file_type();
1399 if ft.is_symlink() {
1400 return Some("120000".to_string());
1401 }
1402 if ft.is_dir() {
1403 let git = p.join(".git");
1404 if let Ok(git_meta) = fs::symlink_metadata(&git) {
1405 if !git_meta.file_type().is_dir() {
1406 if let Ok(content) = fs::read_to_string(&git) {
1407 if content.starts_with("gitdir:") {
1408 return Some("160000".to_string());
1409 }
1410 }
1411 }
1412 }
1413 return Some("040000".to_string());
1414 }
1415 #[cfg(unix)]
1416 {
1417 use std::os::unix::fs::MetadataExt;
1418 let m = normalize_mode(meta.mode());
1419 Some(format!("{:06o}", m))
1420 }
1421 #[cfg(not(unix))]
1422 {
1423 let _ = repo;
1424 None
1425 }
1426}
1427
1428#[must_use]
1430pub fn builtin_objectmode_index(index: &Index, rel_path: &str) -> Option<String> {
1431 let key = rel_path.as_bytes();
1432 let e = index.get(key, 0)?;
1433 let m = e.mode;
1434 if m == MODE_SYMLINK {
1435 return Some("120000".to_string());
1436 }
1437 if m == MODE_GITLINK {
1438 return Some("160000".to_string());
1439 }
1440 if m == MODE_TREE {
1441 return Some("040000".to_string());
1442 }
1443 if m == MODE_EXECUTABLE {
1444 return Some("100755".to_string());
1445 }
1446 if m == MODE_REGULAR {
1447 return Some("100644".to_string());
1448 }
1449 Some(format!("{:06o}", m))
1450}
1451
1452#[cfg(test)]
1453mod tests {
1454 use super::*;
1455
1456 #[test]
1457 fn d_yes_rule_clears_test_after_d_star() {
1458 let mut merged = ParsedGitAttributes::default();
1459 let root = parse_gitattributes_file_content("[attr]notest !test\n", ".gitattributes");
1460 merged.macros.defs.extend(root.macros.defs);
1461 let mut ab = parse_gitattributes_file_content_with_base(
1462 "h test=a/b/h\nd/* test=a/b/d/*\nd/yes notest\n",
1463 "a/b/.gitattributes",
1464 "a/b",
1465 );
1466 assert_eq!(ab.rules.len(), 3);
1467 merged.rules.append(&mut ab.rules);
1468 merged.macros.defs.extend(ab.macros.defs);
1469 let d_yes = merged
1470 .rules
1471 .iter()
1472 .find(|r| r.pattern == "d/yes")
1473 .expect("d/yes rule");
1474 assert!(attr_rule_matches(d_yes, "a/b/d/yes", false));
1475 let m = collect_attrs_for_path(&merged.rules, &merged.macros, "a/b/d/yes", false);
1476 assert!(
1477 m.get("test").is_none(),
1478 "expected test cleared by notest macro, got {:?}",
1479 m.get("test")
1480 );
1481 }
1482}
1483
1484#[cfg(test)]
1485mod attr_cache_tests {
1486 use super::*;
1487 use filetime::FileTime;
1488
1489 fn test_repo(td: &Path) -> Repository {
1490 crate::repo::init_repository(td, false, "main", None, "files").expect("init repo")
1491 }
1492
1493 fn rules_for(repo: &Repository, wt: &Path) -> Vec<String> {
1494 let parsed = load_gitattributes_stack(repo, wt).expect("load stack");
1495 parsed.rules.iter().map(|r| r.pattern.clone()).collect()
1496 }
1497
1498 fn mtime_of(path: &Path) -> FileTime {
1499 FileTime::from_last_modification_time(&fs::symlink_metadata(path).expect("stat"))
1500 }
1501
1502 fn restore_mtime(path: &Path, stamp: FileTime) {
1503 filetime::set_file_mtime(path, stamp).expect("restore mtime");
1504 }
1505
1506 #[test]
1507 fn stack_cache_serves_same_stamp_and_invalidates_on_change() {
1508 let td = tempfile::tempdir().expect("tempdir");
1509 let wt = td.path();
1510 let repo = test_repo(wt);
1511 let ga = wt.join(".gitattributes");
1512 fs::write(&ga, "*.aaa text\n").expect("write v1");
1513 let wt_t0 = mtime_of(wt);
1514 restore_mtime(wt, wt_t0);
1515 let t0 = mtime_of(&ga);
1516 assert_eq!(rules_for(&repo, wt), vec!["*.aaa".to_string()]);
1517
1518 fs::write(&ga, "*.bbb text\n").expect("write v2");
1522 restore_mtime(&ga, t0);
1523 restore_mtime(wt, wt_t0);
1524 assert_eq!(rules_for(&repo, wt), vec!["*.aaa".to_string()]);
1525
1526 fs::write(&ga, "*.ccc-longer text\n").expect("write v3");
1528 restore_mtime(&ga, t0);
1529 restore_mtime(wt, wt_t0);
1530 assert_eq!(rules_for(&repo, wt), vec!["*.ccc-longer".to_string()]);
1531 }
1532
1533 #[test]
1534 fn new_nested_gitattributes_is_detected() {
1535 let td = tempfile::tempdir().expect("tempdir");
1536 let wt = td.path();
1537 let repo = test_repo(wt);
1538 fs::write(wt.join(".gitattributes"), "root-rule text\n").expect("write root");
1539 restore_mtime(wt, FileTime::from_unix_time(1_000_000_000, 0));
1542 assert_eq!(rules_for(&repo, wt), vec!["root-rule".to_string()]);
1543
1544 fs::create_dir(wt.join("sub")).expect("mkdir");
1547 fs::write(wt.join("sub/.gitattributes"), "nested-rule text\n").expect("write nested");
1548 assert_eq!(
1549 rules_for(&repo, wt),
1550 vec!["root-rule".to_string(), "nested-rule".to_string()]
1551 );
1552 }
1553
1554 #[test]
1555 fn modified_nested_gitattributes_follows_c_git_process_semantics() {
1556 let td = tempfile::tempdir().expect("tempdir");
1557 let wt = td.path();
1558 let repo = test_repo(wt);
1559 fs::create_dir(wt.join("sub")).expect("mkdir");
1560 let nested = wt.join("sub/.gitattributes");
1561 fs::write(&nested, "one text\n").expect("write v1");
1562 restore_mtime(wt, FileTime::from_unix_time(1_000_000_000, 0));
1565 assert_eq!(rules_for(&repo, wt), vec!["one".to_string()]);
1566
1567 fs::write(&nested, "two-longer text\n").expect("write v2");
1571 assert_eq!(rules_for(&repo, wt), vec!["one".to_string()]);
1572
1573 fs::create_dir(wt.join("poke")).expect("mkdir poke");
1576 assert_eq!(rules_for(&repo, wt), vec!["two-longer".to_string()]);
1577 }
1578
1579 #[test]
1580 fn info_attributes_is_stamped() {
1581 let td = tempfile::tempdir().expect("tempdir");
1582 let wt = td.path();
1583 let repo = test_repo(wt);
1584 assert!(rules_for(&repo, wt).is_empty());
1585
1586 fs::write(repo.git_dir.join("info/attributes"), "from-info text\n").expect("write info");
1588 assert_eq!(rules_for(&repo, wt), vec!["from-info".to_string()]);
1589 }
1590}