1use crate::config::parse_path;
8use crate::config::ConfigSet;
9use crate::git_path::CanonicalizeExt;
10#[cfg(unix)]
11use crate::index::normalize_mode;
12use crate::index::Index;
13use crate::index::MODE_EXECUTABLE;
14use crate::index::MODE_GITLINK;
15use crate::index::MODE_REGULAR;
16use crate::index::MODE_SYMLINK;
17use crate::index::MODE_TREE;
18use crate::objects::parse_tree;
19use crate::objects::ObjectId;
20use crate::objects::ObjectKind;
21use crate::odb::Odb;
22use crate::repo::Repository;
23use crate::rev_parse::resolve_revision;
24use crate::wildmatch::{wildmatch, WM_CASEFOLD, WM_PATHNAME};
25use std::collections::HashMap;
26use std::ffi::OsStr;
27use std::fs;
28use std::path::{Component, Path, PathBuf};
29use std::sync::Arc;
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,
44 Unset,
46 Clear,
48 Value(String),
50}
51
52impl AttrValue {
53 #[must_use]
55 pub fn display(&self) -> &str {
56 match self {
57 AttrValue::Set => "set",
58 AttrValue::Unset => "unset",
59 AttrValue::Clear => "unspecified",
60 AttrValue::Value(v) => v.as_str(),
61 }
62 }
63}
64
65const PAT_NODIR: u32 = 1;
67const PAT_MUSTBEDIR: u32 = 2;
68const PAT_ENDSWITH: u32 = 4;
69
70#[inline]
71fn is_glob_special_attr(c: u8) -> bool {
72 matches!(c, b'*' | b'?' | b'[' | b'\\')
73}
74
75fn simple_length_pat(s: &str) -> usize {
77 let b = s.as_bytes();
78 let mut i = 0;
79 while i < b.len() {
80 if is_glob_special_attr(b[i]) {
81 return i;
82 }
83 i += 1;
84 }
85 i
86}
87
88fn parse_attr_pattern_fields(pat: &str) -> (String, u32, usize) {
90 let mut flags = 0u32;
91 let mut len = pat.len();
92 if len > 0 && pat.as_bytes()[len - 1] == b'/' {
93 len -= 1;
94 flags |= PAT_MUSTBEDIR;
95 }
96 let p = &pat[..len];
97 let has_slash = p.as_bytes().contains(&b'/');
98 if !has_slash {
99 flags |= PAT_NODIR;
100 }
101 if let Some(rest) = p.strip_prefix('*') {
102 if !rest.is_empty() && simple_length_pat(rest) == rest.len() {
103 flags |= PAT_ENDSWITH;
104 }
105 }
106 let mut nowild = simple_length_pat(p);
107 if nowild > len {
108 nowild = len;
109 }
110 (p.to_string(), flags, nowild)
111}
112
113#[derive(Debug, Clone)]
115pub struct AttrRule {
116 pub attr_base: String,
119 pub pattern: String,
121 pub pattern_flags: u32,
123 pub nowildcardlen: usize,
125 pub skip: bool,
127 pub line: usize,
129 pub attrs: Vec<(String, AttrValue)>,
131}
132
133#[derive(Debug, Clone, Default)]
135pub struct MacroTable {
136 pub defs: HashMap<String, Vec<(String, AttrValue)>>,
138}
139
140#[derive(Debug, Clone, Default)]
142pub struct ParsedGitAttributes {
143 pub rules: Vec<AttrRule>,
145 pub macros: MacroTable,
147 pub warnings: Vec<String>,
149}
150
151#[must_use]
153pub fn is_reserved_builtin_name(name: &str) -> bool {
154 let Some(rest) = name.strip_prefix("builtin_") else {
155 return false;
156 };
157 matches!(rest, "objectmode")
158}
159
160pub fn validate_rules_for_add(
164 rules: &[AttrRule],
165 display_path: &str,
166) -> std::result::Result<(), String> {
167 for rule in rules {
168 if rule.skip {
169 continue;
170 }
171 for (name, _) in &rule.attrs {
172 if name.starts_with("builtin_") && !is_reserved_builtin_name(name) {
173 return Err(format!(
174 "{name} is not a valid attribute name: {display_path}:{}",
175 rule.line
176 ));
177 }
178 }
179 }
180 Ok(())
181}
182
183pub fn builtin_warnings_for_rules(rules: &[AttrRule], display_path: &str) -> Vec<String> {
185 let mut w = Vec::new();
186 for rule in rules {
187 if rule.skip {
188 continue;
189 }
190 for (name, _) in &rule.attrs {
191 if name == "builtin_objectmode" {
192 w.push(format!(
193 "builtin_objectmode is not a valid attribute name: {display_path}:{}",
194 rule.line
195 ));
196 } else if name.starts_with("builtin_") && !is_reserved_builtin_name(name) {
197 w.push(format!(
198 "{name} is not a valid attribute name: {display_path}:{}",
199 rule.line
200 ));
201 }
202 }
203 }
204 w
205}
206
207pub(crate) fn global_attributes_path(
208 repo: &Repository,
209) -> std::result::Result<Option<PathBuf>, crate::error::Error> {
210 let config = repo.config()?;
211 let env = repo.environment();
212 if let Some(path) = config.get("core.attributesfile") {
213 return Ok(Some(PathBuf::from(parse_path(env, &path))));
214 }
215 Ok(env.default_xdg_config_git_file("attributes"))
216}
217
218fn read_gitattributes_maybe_symlink(
220 path: &Path,
221 display: &str,
222 warnings: &mut Vec<String>,
223) -> Option<String> {
224 let meta = fs::symlink_metadata(path).ok()?;
225 if meta.file_type().is_symlink() {
226 warnings.push(format!(
227 "unable to access '{display}': Too many levels of symbolic links"
228 ));
229 return None;
230 }
231 fs::read_to_string(path).ok()
232}
233
234pub fn parse_gitattributes_file_content(content: &str, display_path: &str) -> ParsedGitAttributes {
237 parse_gitattributes_content_impl(content, display_path, false, "")
238}
239
240pub fn parse_gitattributes_file_content_with_base(
243 content: &str,
244 display_path: &str,
245 attr_base: &str,
246) -> ParsedGitAttributes {
247 parse_gitattributes_content_impl(content, display_path, false, attr_base)
248}
249
250fn parse_gitattributes_content_impl(
251 content: &str,
252 display_path: &str,
253 from_blob: bool,
254 attr_base: &str,
255) -> ParsedGitAttributes {
256 let _ = from_blob;
257
258 let mut out = ParsedGitAttributes::default();
259 for (idx, raw_line) in content.lines().enumerate() {
260 let line_no = idx + 1;
261 let line_bytes = raw_line.as_bytes();
262 if line_bytes.len() >= MAX_ATTR_LINE_BYTES {
263 out.warnings.push(crate::diagnostics::warning_line(&format!(
264 "ignoring overly long attributes line {line_no}"
265 )));
266 continue;
267 }
268 parse_one_line(
269 raw_line,
270 line_no,
271 display_path,
272 from_blob,
273 attr_base,
274 &mut out,
275 );
276 }
277 out.warnings
278 .extend(builtin_warnings_for_rules(&out.rules, display_path));
279 out
280}
281
282fn skip_ascii_blank(s: &str) -> &str {
284 s.trim_start_matches([' ', '\t', '\r', '\n'])
285}
286
287fn split_at_first_blank(s: &str) -> (&str, &str) {
289 let bytes = s.as_bytes();
290 let n = bytes
291 .iter()
292 .position(|&b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
293 .unwrap_or(bytes.len());
294 s.split_at(n)
295}
296
297fn unquote_c_style(quoted: &str) -> Result<(String, &str), ()> {
299 let (bytes, rest) = crate::quote_path_root::unquote_c_style_prefix(quoted).ok_or(())?;
300 Ok((String::from_utf8(bytes).map_err(|_| ())?, rest))
301}
302
303fn parse_one_attr_token_git(s: &str) -> (&str, Option<&str>, &str) {
305 let bytes = s.as_bytes();
306 let token_end = bytes
307 .iter()
308 .position(|&b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
309 .unwrap_or(bytes.len());
310 let eq_pos = s.find('=');
311 let eq_in_token = eq_pos.filter(|&eq| eq < token_end);
312 let (name, val) = if let Some(eq) = eq_in_token {
313 (&s[..eq], Some(&s[eq + 1..token_end]))
314 } else {
315 (&s[..token_end], None)
316 };
317 let rest = skip_ascii_blank(&s[token_end..]);
318 (name, val, rest)
319}
320
321fn accumulate_attr_states(
322 mut states: &str,
323 attrs: &mut Vec<(String, AttrValue)>,
324 macros: &MacroTable,
325 in_macro_def: bool,
326) {
327 loop {
328 states = skip_ascii_blank(states);
329 if states.is_empty() {
330 break;
331 }
332 let (name, val, rest) = parse_one_attr_token_git(states);
333 states = rest;
334 let tok = match val {
335 Some(v) => format!("{name}={v}"),
336 None => name.to_string(),
337 };
338 push_attr_token(&tok, attrs, macros, in_macro_def);
339 }
340}
341
342const ATTR_MACRO_PREFIX: &str = "[attr]";
343
344fn parse_one_line(
345 raw_line: &str,
346 line_no: usize,
347 display_path: &str,
348 from_blob: bool,
349 attr_base: &str,
350 out: &mut ParsedGitAttributes,
351) {
352 let _ = from_blob;
353 let cp = skip_ascii_blank(raw_line);
354 if cp.is_empty() || cp.starts_with('#') {
355 return;
356 }
357
358 let (pattern_token, states) = if cp.as_bytes().first() == Some(&b'"') {
359 match unquote_c_style(cp) {
360 Ok((pat, rest)) => (pat, rest),
361 Err(()) => {
362 let (a, b) = split_at_first_blank(cp);
363 (a.to_string(), b)
364 }
365 }
366 } else {
367 let (a, b) = split_at_first_blank(cp);
368 (a.to_string(), b)
369 };
370
371 if pattern_token.len() > ATTR_MACRO_PREFIX.len() && pattern_token.starts_with(ATTR_MACRO_PREFIX)
372 {
373 if !attr_base.is_empty() {
374 out.warnings.push(format!(
375 "{pattern_token} not allowed: {display_path}:{line_no}"
376 ));
377 return;
378 }
379 let rest = skip_ascii_blank(&pattern_token[ATTR_MACRO_PREFIX.len()..]);
380 let (macro_name, leftover) = split_at_first_blank(rest);
381 if !leftover.is_empty() || macro_name.is_empty() {
382 return;
383 }
384 let mut attrs = Vec::new();
385 accumulate_attr_states(states, &mut attrs, &out.macros, true);
386 out.macros.defs.insert(macro_name.to_string(), attrs);
387 return;
388 }
389
390 if pattern_token.starts_with('!') && !pattern_token.starts_with("\\!") {
391 out.warnings
392 .push("Negative patterns are ignored".to_string());
393 return;
394 }
395 let pattern_raw = pattern_token.replace("\\!", "!");
396 let (pattern, pattern_flags, nowildcardlen) = parse_attr_pattern_fields(&pattern_raw);
397 let mut attrs = Vec::new();
398 accumulate_attr_states(states, &mut attrs, &out.macros, false);
399 if attrs.is_empty() {
400 return;
401 }
402 out.rules.push(AttrRule {
403 attr_base: attr_base.to_string(),
404 pattern,
405 pattern_flags,
406 nowildcardlen,
407 skip: false,
408 line: line_no,
409 attrs,
410 });
411}
412
413fn push_attr_token(
414 tok: &str,
415 attrs: &mut Vec<(String, AttrValue)>,
416 _macros: &MacroTable,
417 _in_macro_def: bool,
418) {
419 if tok == "binary" {
420 attrs.push(("text".into(), AttrValue::Unset));
421 attrs.push(("diff".into(), AttrValue::Unset));
422 attrs.push(("merge".into(), AttrValue::Unset));
423 attrs.push(("binary".into(), AttrValue::Set));
424 return;
425 }
426 if let Some(rest) = tok.strip_prefix('!') {
427 if !rest.is_empty() {
428 attrs.push((rest.to_string(), AttrValue::Clear));
429 return;
430 }
431 }
432 if let Some(rest) = tok.strip_prefix('-') {
433 attrs.push((rest.to_string(), AttrValue::Unset));
434 return;
435 }
436 if let Some((k, v)) = tok.split_once('=') {
437 let v = v.trim_end_matches(|c: char| {
438 matches!(c, ' ' | '\t' | '\r' | '\n') || c == '\u{000b}' || c == '\u{000c}'
439 });
440 attrs.push((k.to_string(), AttrValue::Value(v.to_string())));
441 return;
442 }
443 attrs.push((tok.to_string(), AttrValue::Set));
444}
445
446fn fspathncmp(a: &[u8], b: &[u8], count: usize, icase: bool) -> bool {
447 if a.len() < count || b.len() < count {
448 return false;
449 }
450 if icase {
451 a[..count]
452 .iter()
453 .zip(&b[..count])
454 .all(|(x, y)| x.eq_ignore_ascii_case(y))
455 } else {
456 a[..count] == b[..count]
457 }
458}
459
460fn match_basename_git(
462 basename: &[u8],
463 pattern: &[u8],
464 prefix: usize,
465 patternlen: usize,
466 pat_flags: u32,
467 icase: bool,
468) -> bool {
469 let basenamelen = basename.len();
470 let wm_flags = if icase { WM_CASEFOLD } else { 0 };
471 if prefix == patternlen {
472 return patternlen == basenamelen && fspathncmp(pattern, basename, basenamelen, icase);
473 }
474 if (pat_flags & PAT_ENDSWITH) != 0 {
475 if patternlen <= 1 {
476 return false;
477 }
478 let lit_len = patternlen - 1;
479 if lit_len > basenamelen {
480 return false;
481 }
482 return fspathncmp(
483 &pattern[1..patternlen],
484 &basename[basenamelen - lit_len..],
485 lit_len,
486 icase,
487 );
488 }
489 wildmatch(&pattern[..patternlen], basename, wm_flags)
490}
491
492#[allow(clippy::too_many_arguments)]
494fn match_pathname_git(
495 pathname: &[u8],
496 pathlen: usize,
497 base: &[u8],
498 baselen: usize,
499 mut pattern: &[u8],
500 mut prefix: usize,
501 mut patternlen: usize,
502 icase: bool,
503) -> bool {
504 let pathname = &pathname[..pathlen.min(pathname.len())];
505
506 if !pattern.is_empty() && pattern[0] == b'/' {
507 pattern = &pattern[1..];
508 patternlen -= 1;
509 prefix = prefix.saturating_sub(1);
510 }
511
512 if pathlen < baselen + 1 {
513 return false;
514 }
515 if baselen > 0 && pathname[baselen] != b'/' {
516 return false;
517 }
518 if !fspathncmp(pathname, base, baselen, icase) {
519 return false;
520 }
521
522 let namelen = if baselen == 0 {
523 pathlen
524 } else {
525 pathlen - baselen - 1
526 };
527 let name = &pathname[pathlen - namelen..];
528
529 if prefix > 0 {
530 if prefix > namelen {
531 return false;
532 }
533 if !fspathncmp(pattern, name, prefix, icase) {
534 return false;
535 }
536 if patternlen == prefix && namelen == prefix {
537 return true;
538 }
539 let advance = prefix - 1;
540 pattern = &pattern[advance..];
541 patternlen -= advance;
542 let name = &name[advance..];
543 let wm_flags = WM_PATHNAME | if icase { WM_CASEFOLD } else { 0 };
544 return wildmatch(&pattern[..patternlen], name, wm_flags);
545 }
546
547 let wm_flags = WM_PATHNAME | if icase { WM_CASEFOLD } else { 0 };
548 wildmatch(&pattern[..patternlen], name, wm_flags)
549}
550
551fn path_dir_prefix(rel_path: &str) -> &str {
553 match rel_path.rfind('/') {
554 Some(i) => &rel_path[..i],
555 None => "",
556 }
557}
558
559#[must_use]
563pub fn attr_rule_applies_to_path(attr_base: &str, rel_path: &str, icase: bool) -> bool {
564 if attr_base.is_empty() {
565 return true;
566 }
567 let dir = path_dir_prefix(rel_path);
568 if dir.is_empty() {
569 return false;
570 }
571 let prefix_eq = |d: &str, b: &str| {
572 if icase {
573 d.eq_ignore_ascii_case(b)
574 } else {
575 d == b
576 }
577 };
578 if prefix_eq(dir, attr_base) {
579 return true;
580 }
581 let bl = attr_base.len();
582 if dir.len() > bl && dir.as_bytes()[bl] == b'/' && prefix_eq(&dir[..bl], attr_base) {
583 return true;
584 }
585 false
586}
587
588#[must_use]
590pub fn attr_rule_matches(rule: &AttrRule, rel_path: &str, icase: bool) -> bool {
591 if !attr_rule_applies_to_path(&rule.attr_base, rel_path, icase) {
592 return false;
593 }
594 let pathname = rel_path.as_bytes();
595 let pathlen = pathname.len();
596 let isdir = pathlen > 0 && pathname[pathlen - 1] == b'/';
597
598 if (rule.pattern_flags & PAT_MUSTBEDIR) != 0 && !isdir {
599 return false;
600 }
601
602 let eff_pathlen = if isdir { pathlen - 1 } else { pathlen };
603 let pathname_trim = &pathname[..eff_pathlen];
604
605 let basename_offset = pathname_trim
606 .iter()
607 .rposition(|&b| b == b'/')
608 .map(|i| i + 1)
609 .unwrap_or(0);
610
611 let pat = rule.pattern.as_bytes();
612 let prefix = rule.nowildcardlen.min(pat.len());
613 let patternlen = pat.len();
614
615 if (rule.pattern_flags & PAT_NODIR) != 0 {
616 let bn = &pathname_trim[basename_offset..];
617 return match_basename_git(bn, pat, prefix, patternlen, rule.pattern_flags, icase);
618 }
619
620 let base = rule.attr_base.as_bytes();
621 match_pathname_git(
622 pathname_trim,
623 eff_pathlen,
624 base,
625 base.len(),
626 pat,
627 prefix,
628 patternlen,
629 icase,
630 )
631}
632
633const MAX_MACRO_EXPAND_DEPTH: u32 = 3001;
635
636struct ExpandStep {
638 name: String,
639 val: AttrValue,
640 depth: u32,
641}
642
643fn push_binary_expansion(out: &mut Vec<(String, AttrValue)>) {
644 out.push(("text".into(), AttrValue::Unset));
645 out.push(("diff".into(), AttrValue::Unset));
646 out.push(("merge".into(), AttrValue::Unset));
647 out.push(("binary".into(), AttrValue::Set));
648}
649
650fn push_expanded_assignment(
652 name: String,
653 val: AttrValue,
654 macros: &MacroTable,
655 out: &mut Vec<(String, AttrValue)>,
656) {
657 let mut pending = vec![ExpandStep {
658 name,
659 val,
660 depth: 0,
661 }];
662 while let Some(ExpandStep { name, val, depth }) = pending.pop() {
663 if depth > MAX_MACRO_EXPAND_DEPTH {
664 continue;
665 }
666 if name == "binary" {
667 push_binary_expansion(out);
668 continue;
669 }
670 if matches!(val, AttrValue::Set) {
671 if let Some(body) = macros.defs.get(&name) {
672 out.push((name, AttrValue::Set));
673 for (n, v) in body.iter().rev() {
674 pending.push(ExpandStep {
675 name: n.clone(),
676 val: v.clone(),
677 depth: depth + 1,
678 });
679 }
680 continue;
681 }
682 }
683 out.push((name, val));
684 }
685}
686
687fn expand_rule_attrs_flat(rule: &AttrRule, macros: &MacroTable) -> Vec<(String, AttrValue)> {
692 let mut flat: Vec<(String, AttrValue)> = Vec::new();
693 for (name, val) in &rule.attrs {
694 push_expanded_assignment(name.clone(), val.clone(), macros, &mut flat);
695 }
696 flat
697}
698
699pub fn collect_attrs_for_path(
701 rules: &[AttrRule],
702 macros: &MacroTable,
703 rel_path: &str,
704 icase: bool,
705) -> HashMap<String, AttrValue> {
706 let mut map: HashMap<String, AttrValue> = HashMap::new();
707 for rule in rules {
708 if rule.skip {
709 continue;
710 }
711 if !attr_rule_matches(rule, rel_path, icase) {
712 continue;
713 }
714 let ops = expand_rule_attrs_flat(rule, macros);
715 for (n, v) in ops {
716 match v {
717 AttrValue::Clear => {
718 map.remove(&n);
719 }
720 _ => {
721 map.insert(n, v);
722 }
723 }
724 }
725 }
726 map
727}
728
729#[must_use]
731pub fn quote_path_for_check_attr(path: &str) -> String {
732 let needs = path
733 .chars()
734 .any(|c| c.is_control() || c == '"' || c == '\\');
735 if !needs {
736 return path.to_string();
737 }
738 let mut s = String::new();
739 s.push('"');
740 for c in path.chars() {
741 match c {
742 '"' => s.push_str("\\\""),
743 '\\' => s.push_str("\\\\"),
744 _ if c.is_control() => s.push_str(&format!("\\{:o}", c as u32)),
745 _ => s.push(c),
746 }
747 }
748 s.push('"');
749 s
750}
751
752#[must_use]
754pub fn normalize_rel_path(path: &str) -> String {
755 let p = Path::new(path);
756 let mut stack: Vec<String> = Vec::new();
757 for c in p.components() {
758 match c {
759 Component::Normal(s) => stack.push(s.to_string_lossy().into_owned()),
760 Component::ParentDir => {
761 let _ = stack.pop();
762 }
763 Component::CurDir => {}
764 _ => {}
765 }
766 }
767 stack.join("/")
768}
769
770fn lexical_normalize_path(path: PathBuf) -> PathBuf {
771 let mut out = PathBuf::new();
772 for c in path.components() {
773 match c {
774 Component::Prefix(prefix) => out.push(prefix.as_os_str()),
775 Component::RootDir => out.push(c),
776 Component::CurDir => {}
777 Component::ParentDir => {
778 let _ = out.pop();
779 }
780 Component::Normal(_) => out.push(c),
781 }
782 }
783 out
784}
785
786pub fn path_relative_to_worktree(
791 repo: &Repository,
792 path_str: &str,
793) -> std::result::Result<String, String> {
794 let wt = repo
795 .work_tree()
796 .ok_or_else(|| "bare repository — no work tree".to_string())?;
797 let cwd = repo.environment().cwd.clone();
798 let p = Path::new(path_str);
799 let combined = if p.is_absolute() {
800 p.to_path_buf()
801 } else {
802 cwd.join(p)
803 };
804
805 if let Ok(abs) = combined.canonicalize_simplified() {
806 let rel = crate::git_path::strip_worktree_prefix(&abs, wt)
807 .ok_or_else(|| format!("path outside repository: {}", path_str))?;
808 return Ok(normalize_rel_path(&rel));
809 }
810
811 let abs_lex = lexical_normalize_path(combined);
812 let rel = crate::git_path::strip_worktree_prefix(&abs_lex, wt)
813 .ok_or_else(|| format!("path outside repository: {}", path_str))?;
814 Ok(normalize_rel_path(&rel))
815}
816
817fn collect_nested_gitattributes_dirs(work_tree: &Path) -> Vec<PathBuf> {
818 let mut dirs: Vec<PathBuf> = Vec::new();
819 walk_dirs(work_tree, work_tree, &mut dirs);
820 dirs.sort_by(|a, b| {
821 let da = a.components().count();
822 let db = b.components().count();
823 da.cmp(&db).then_with(|| a.cmp(b))
824 });
825 dirs
826}
827
828fn walk_dirs(root: &Path, cur: &Path, dirs: &mut Vec<PathBuf>) {
829 let Ok(rd) = fs::read_dir(cur) else {
830 return;
831 };
832 for e in rd.flatten() {
833 let p = e.path();
834 let ft = e.file_type().ok();
835 if ft.is_some_and(|t| t.is_dir()) {
836 if p.file_name() == Some(OsStr::new(".git")) {
837 continue;
838 }
839 let rel = p.strip_prefix(root).unwrap_or(&p);
840 dirs.push(rel.to_path_buf());
841 walk_dirs(root, &p, dirs);
842 }
843 }
844}
845
846type AttrFileStamp = (PathBuf, Option<(SystemTime, u64)>);
873type AttrDirStamp = (PathBuf, Option<SystemTime>);
874
875pub(crate) struct AttrStackCacheEntry {
876 pub(crate) file_stamps: Vec<AttrFileStamp>,
877 pub(crate) dir_stamps: Vec<AttrDirStamp>,
878 pub(crate) parsed: Arc<ParsedGitAttributes>,
879}
880
881pub(crate) fn attr_file_stamp(path: &Path) -> Option<(SystemTime, u64)> {
885 fs::symlink_metadata(path)
886 .ok()
887 .and_then(|m| Some((m.modified().ok()?, m.len())))
888}
889
890pub(crate) fn attr_dir_stamp(path: &Path) -> Option<SystemTime> {
891 fs::symlink_metadata(path)
892 .ok()
893 .and_then(|m| m.modified().ok())
894}
895
896pub(crate) fn attr_stamps_valid(entry: &AttrStackCacheEntry) -> bool {
897 entry
898 .file_stamps
899 .iter()
900 .all(|(path, stamp)| attr_file_stamp(path) == *stamp)
901 && entry
902 .dir_stamps
903 .iter()
904 .all(|(path, stamp)| attr_dir_stamp(path) == *stamp)
905}
906
907pub(crate) fn collect_stack_stamps(
922 repo: &Repository,
923 work_tree: &Path,
924) -> std::result::Result<(Vec<AttrFileStamp>, Vec<AttrDirStamp>), crate::error::Error> {
925 let mut file_stamps = Vec::new();
926 if let Some(g) = global_attributes_path(repo)? {
927 let stamp = attr_file_stamp(&g);
928 file_stamps.push((g, stamp));
929 }
930 let root_ga = work_tree.join(".gitattributes");
931 let stamp = attr_file_stamp(&root_ga);
932 file_stamps.push((root_ga, stamp));
933 let info = repo.git_dir().join("info/attributes");
934 let stamp = attr_file_stamp(&info);
935 file_stamps.push((info, stamp));
936 let dir_stamps = vec![(work_tree.to_path_buf(), attr_dir_stamp(work_tree))];
937 Ok((file_stamps, dir_stamps))
938}
939
940pub fn load_gitattributes_stack(
945 repo: &Repository,
946 work_tree: &Path,
947) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
948 repo.caches().load_gitattributes_stack(repo, work_tree)
949}
950
951pub(crate) fn load_gitattributes_stack_uncached(
952 repo: &Repository,
953 work_tree: &Path,
954) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
955 let mut merged = ParsedGitAttributes::default();
956
957 if let Some(g) = global_attributes_path(repo)? {
958 if g.exists() {
959 if let Ok(content) = fs::read_to_string(&g) {
960 if content.len() <= MAX_ATTR_FILE_BYTES {
961 let mut p =
962 parse_gitattributes_file_content(&content, g.to_string_lossy().as_ref());
963 merged.rules.append(&mut p.rules);
964 merged.macros.defs.extend(p.macros.defs.drain());
965 merged.warnings.append(&mut p.warnings);
966 } else {
967 merged
968 .warnings
969 .push(crate::diagnostics::warning_line(&format!(
970 "ignoring overly large gitattributes file '{}'",
971 g.display()
972 )));
973 }
974 }
975 }
976 }
977
978 let root_ga = work_tree.join(".gitattributes");
979 if let Some(content) =
980 read_gitattributes_maybe_symlink(&root_ga, ".gitattributes", &mut merged.warnings)
981 {
982 if content.len() <= MAX_ATTR_FILE_BYTES {
983 let mut p = parse_gitattributes_file_content(&content, ".gitattributes");
984 merged.rules.append(&mut p.rules);
985 merged.macros.defs.extend(p.macros.defs.drain());
986 merged.warnings.append(&mut p.warnings);
987 } else {
988 merged.warnings.push(crate::diagnostics::warning_line(
989 "ignoring overly large gitattributes file '.gitattributes'",
990 ));
991 }
992 }
993
994 for rel in collect_nested_gitattributes_dirs(work_tree) {
995 let ga = work_tree.join(&rel).join(".gitattributes");
996 if let Some(content) = read_gitattributes_maybe_symlink(
997 &ga,
998 &format!("{}/.gitattributes", rel.display()),
999 &mut merged.warnings,
1000 ) {
1001 if content.len() > MAX_ATTR_FILE_BYTES {
1002 merged
1003 .warnings
1004 .push(crate::diagnostics::warning_line(&format!(
1005 "ignoring overly large gitattributes file '{}'",
1006 ga.display()
1007 )));
1008 continue;
1009 }
1010 let prefix = rel.to_string_lossy().replace('\\', "/");
1011 let mut p = parse_gitattributes_file_content_with_base(
1012 &content,
1013 &ga.to_string_lossy(),
1014 &prefix,
1015 );
1016 merged.rules.append(&mut p.rules);
1017 merged.warnings.append(&mut p.warnings);
1018 }
1019 }
1020
1021 let info = repo.git_dir().join("info/attributes");
1022 if info.exists() {
1023 if let Ok(content) = fs::read_to_string(&info) {
1024 if content.len() <= MAX_ATTR_FILE_BYTES {
1025 let mut p = parse_gitattributes_file_content(&content, "info/attributes");
1026 merged.rules.append(&mut p.rules);
1027 merged.macros.defs.extend(p.macros.defs.drain());
1028 merged.warnings.append(&mut p.warnings);
1029 }
1030 }
1031 }
1032
1033 Ok(merged)
1034}
1035
1036pub fn load_gitattributes_bare(
1040 repo: &Repository,
1041) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1042 repo.caches().load_gitattributes_bare(repo)
1043}
1044
1045pub(crate) fn load_gitattributes_bare_uncached(
1046 repo: &Repository,
1047) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1048 let mut merged = ParsedGitAttributes::default();
1049 if let Some(g) = global_attributes_path(repo)? {
1050 if g.exists() {
1051 if let Ok(content) = fs::read_to_string(&g) {
1052 if content.len() <= MAX_ATTR_FILE_BYTES {
1053 let mut p =
1054 parse_gitattributes_file_content(&content, g.to_string_lossy().as_ref());
1055 merged.rules.append(&mut p.rules);
1056 merged.macros.defs.extend(p.macros.defs.drain());
1057 merged.warnings.append(&mut p.warnings);
1058 }
1059 }
1060 }
1061 }
1062 let info = repo.git_dir().join("info/attributes");
1063 if info.exists() {
1064 if let Ok(content) = fs::read_to_string(&info) {
1065 if content.len() <= MAX_ATTR_FILE_BYTES {
1066 let mut p = parse_gitattributes_file_content(&content, "info/attributes");
1067 merged.rules.append(&mut p.rules);
1068 merged.macros.defs.extend(p.macros.defs.drain());
1069 merged.warnings.append(&mut p.warnings);
1070 }
1071 }
1072 }
1073 if let Ok(index) = Index::load(&repo.git_dir().join("index")) {
1078 if let Ok(mut from_index) =
1079 load_gitattributes_from_index(&index, repo.odb(), repo.git_dir())
1080 {
1081 from_index.rules.append(&mut merged.rules);
1084 merged.rules = from_index.rules;
1085 for (k, v) in from_index.macros.defs.drain() {
1086 merged.macros.defs.entry(k).or_insert(v);
1087 }
1088 merged.warnings.append(&mut from_index.warnings);
1089 }
1090 }
1091 Ok(merged)
1092}
1093
1094fn path_is_gitattributes(rel: &str) -> bool {
1097 Path::new(rel)
1098 .file_name()
1099 .is_some_and(|n| n == ".gitattributes")
1100}
1101
1102fn gitattributes_file_depth(rel: &str) -> usize {
1104 match rel.rfind('/') {
1105 None => 0,
1106 Some(i) => rel[..i].matches('/').count() + 1,
1107 }
1108}
1109
1110fn cmp_gitattributes_paths(a: &str, b: &str) -> std::cmp::Ordering {
1111 gitattributes_file_depth(a)
1112 .cmp(&gitattributes_file_depth(b))
1113 .then_with(|| a.cmp(b))
1114}
1115
1116struct CollectedGitAttributes {
1117 depth: usize,
1118 sort_key: String,
1119 root_macros: bool,
1120 parsed: ParsedGitAttributes,
1121}
1122
1123fn merge_collected_gitattributes(
1124 collected: &mut Vec<CollectedGitAttributes>,
1125 merged: &mut ParsedGitAttributes,
1126) {
1127 collected.sort_by(|a, b| {
1128 a.depth
1129 .cmp(&b.depth)
1130 .then_with(|| a.sort_key.cmp(&b.sort_key))
1131 });
1132 for mut item in collected.drain(..) {
1133 merged.rules.append(&mut item.parsed.rules);
1134 if item.root_macros {
1135 merged.macros.defs.extend(item.parsed.macros.defs.drain());
1136 }
1137 merged.warnings.append(&mut item.parsed.warnings);
1138 }
1139}
1140
1141pub fn load_gitattributes_from_tree(
1143 repo: &Repository,
1144 tree_oid: &ObjectId,
1145) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1146 repo.caches()
1147 .load_gitattributes_from_tree_cached(repo.odb(), tree_oid, || {
1148 let mut merged = ParsedGitAttributes::default();
1149 let mut collected = Vec::new();
1150 walk_tree_attrs_collect(repo.odb(), tree_oid, "", &mut collected)?;
1151 merge_collected_gitattributes(&mut collected, &mut merged);
1152 Ok(merged)
1153 })
1154}
1155
1156fn walk_tree_attrs_collect(
1157 odb: &Odb,
1158 tree_oid: &ObjectId,
1159 prefix: &str,
1160 out: &mut Vec<CollectedGitAttributes>,
1161) -> std::result::Result<(), crate::error::Error> {
1162 let obj = odb.read(tree_oid)?;
1163 if obj.kind != ObjectKind::Tree {
1164 return Ok(());
1165 }
1166 let entries = parse_tree(&obj.data)?;
1167 for e in entries {
1168 let name = String::from_utf8_lossy(&e.name).to_string();
1169 let path = if prefix.is_empty() {
1170 name.clone()
1171 } else {
1172 format!("{prefix}/{name}")
1173 };
1174 match e.mode {
1175 0o040000 => {
1176 walk_tree_attrs_collect(odb, &e.oid, &path, out)?;
1177 }
1178 0o100644 | 0o100755 | 0o120000 if name == ".gitattributes" => {
1179 let oid = e.oid;
1180 let blob = odb.read(&oid)?;
1181 if blob.kind != ObjectKind::Blob {
1182 continue;
1183 }
1184 if blob.data.len() > MAX_ATTR_FILE_BYTES {
1185 out.push(CollectedGitAttributes {
1186 depth: gitattributes_file_depth(&path),
1187 sort_key: path.clone(),
1188 root_macros: false,
1189 parsed: ParsedGitAttributes {
1190 warnings: vec![crate::diagnostics::warning_line(
1191 "ignoring overly large gitattributes blob '.gitattributes'",
1192 )],
1193 ..Default::default()
1194 },
1195 });
1196 continue;
1197 }
1198 let content = String::from_utf8_lossy(&blob.data).into_owned();
1199 let display = format!("{path} (tree)");
1200 let attr_base = Path::new(&path)
1201 .parent()
1202 .map(|p| p.to_string_lossy().replace('\\', "/"))
1203 .unwrap_or_default();
1204 let parsed = parse_gitattributes_content_impl(&content, &display, true, &attr_base);
1205 out.push(CollectedGitAttributes {
1206 depth: gitattributes_file_depth(&path),
1207 sort_key: path,
1208 root_macros: attr_base.is_empty(),
1209 parsed,
1210 });
1211 }
1212 _ => {}
1213 }
1214 }
1215 Ok(())
1216}
1217
1218fn append_gitattributes_from_tree(
1219 odb: &Odb,
1220 tree_oid: &ObjectId,
1221 merged: &mut ParsedGitAttributes,
1222) -> std::result::Result<(), crate::error::Error> {
1223 let mut collected = Vec::new();
1224 walk_tree_attrs_collect(odb, tree_oid, "", &mut collected)?;
1225 merge_collected_gitattributes(&mut collected, merged);
1226 Ok(())
1227}
1228
1229pub fn load_gitattributes_for_diff(
1239 repo: &Repository,
1240) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1241 let (treeish, ignore_bad_tree) = resolve_attr_treeish(repo, None)?;
1242 if let Some(spec) = treeish.filter(|s| !s.is_empty()) {
1243 match resolve_tree_oid(repo, &spec) {
1244 Ok(oid) => return load_gitattributes_from_tree(repo, &oid),
1245 Err(_) if ignore_bad_tree => {}
1246 Err(_) => {
1247 return Err(crate::error::Error::InvalidRef(format!(
1248 "bad --attr-source or GIT_ATTR_SOURCE: {spec}"
1249 )));
1250 }
1251 }
1252 }
1253 if let Some(wt) = repo.work_tree() {
1254 return load_gitattributes_stack(repo, wt);
1255 }
1256 load_gitattributes_bare(repo)
1257}
1258
1259pub fn resolve_attr_treeish(
1265 repo: &Repository,
1266 source_arg: Option<&str>,
1267) -> std::result::Result<(Option<String>, bool), crate::error::Error> {
1268 let env_src = repo
1269 .environment()
1270 .var("GIT_ATTR_SOURCE")
1271 .filter(|s| !s.is_empty());
1272 let config = ConfigSet::load(repo.environment(), Some(repo.git_dir()), true)?;
1273 let cfg_tree = config.get("attr.tree");
1274 if let Some(s) = source_arg.map(|s| s.to_string()) {
1275 return Ok((Some(s), false));
1276 }
1277 if let Some(s) = env_src {
1278 return Ok((Some(s), false));
1279 }
1280 if let Some(s) = cfg_tree {
1281 return Ok((Some(s), true));
1282 }
1283 Ok((None, false))
1284}
1285
1286pub fn resolve_tree_oid(repo: &Repository, spec: &str) -> std::result::Result<ObjectId, String> {
1288 let oid = resolve_revision(repo, spec).map_err(|e| e.to_string())?;
1289 let obj = repo.read_replaced(&oid).map_err(|e| e.to_string())?;
1290 match obj.kind {
1291 ObjectKind::Commit => {
1292 let c = crate::objects::parse_commit(&obj.data).map_err(|e| e.to_string())?;
1293 Ok(c.tree)
1294 }
1295 ObjectKind::Tree => Ok(oid),
1296 _ => Err("revision is not a commit or tree".to_string()),
1297 }
1298}
1299
1300pub fn load_gitattributes_from_index(
1302 index: &Index,
1303 odb: &Odb,
1304 work_tree: &Path,
1305) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1306 let mut merged = ParsedGitAttributes::default();
1307 let mut paths: Vec<Vec<u8>> = index
1308 .entries
1309 .iter()
1310 .filter(|e| {
1311 e.stage() == 0
1312 && std::str::from_utf8(&e.path)
1313 .ok()
1314 .is_some_and(path_is_gitattributes)
1315 })
1316 .map(|e| e.path.clone())
1317 .collect();
1318 paths.sort_by(|a, b| {
1319 let sa = std::str::from_utf8(a).unwrap_or("");
1320 let sb = std::str::from_utf8(b).unwrap_or("");
1321 cmp_gitattributes_paths(sa, sb)
1322 });
1323 for path_bytes in paths {
1324 let Ok(rel) = std::str::from_utf8(&path_bytes) else {
1325 continue;
1326 };
1327 let Some(entry) = index.get(&path_bytes, 0) else {
1328 continue;
1329 };
1330 let obj = odb.read(&entry.oid)?;
1331 if obj.data.len() > MAX_ATTR_FILE_BYTES {
1332 merged
1333 .warnings
1334 .push(crate::diagnostics::warning_line(&format!(
1335 "ignoring overly large gitattributes blob '{rel}'"
1336 )));
1337 continue;
1338 }
1339 let content = String::from_utf8_lossy(&obj.data);
1340 let attr_base = Path::new(rel)
1341 .parent()
1342 .map(|p| p.to_string_lossy().replace('\\', "/"))
1343 .unwrap_or_default();
1344 let mut p = parse_gitattributes_content_impl(&content, rel, true, &attr_base);
1345 merged.rules.append(&mut p.rules);
1346 if attr_base.is_empty() {
1347 merged.macros.defs.extend(p.macros.defs.drain());
1348 }
1349 merged.warnings.append(&mut p.warnings);
1350 }
1351 let _ = work_tree;
1352 Ok(merged)
1353}
1354
1355fn merge_global_attributes_file(
1356 repo: &Repository,
1357 merged: &mut ParsedGitAttributes,
1358) -> std::result::Result<(), crate::error::Error> {
1359 if let Some(g) = global_attributes_path(repo)? {
1360 if g.exists() {
1361 if let Ok(content) = fs::read_to_string(&g) {
1362 if content.len() <= MAX_ATTR_FILE_BYTES {
1363 let mut p =
1364 parse_gitattributes_file_content(&content, g.to_string_lossy().as_ref());
1365 merged.rules.append(&mut p.rules);
1366 merged.macros.defs.extend(p.macros.defs.drain());
1367 merged.warnings.append(&mut p.warnings);
1368 } else {
1369 merged
1370 .warnings
1371 .push(crate::diagnostics::warning_line(&format!(
1372 "ignoring overly large gitattributes file '{}'",
1373 g.display()
1374 )));
1375 }
1376 }
1377 }
1378 }
1379 Ok(())
1380}
1381
1382fn merge_info_attributes(repo: &Repository, merged: &mut ParsedGitAttributes) {
1383 let info = repo.git_dir().join("info/attributes");
1384 if info.exists() {
1385 if let Ok(content) = fs::read_to_string(&info) {
1386 if content.len() <= MAX_ATTR_FILE_BYTES {
1387 let mut p = parse_gitattributes_file_content(&content, "info/attributes");
1388 merged.rules.append(&mut p.rules);
1389 merged.macros.defs.extend(p.macros.defs.drain());
1390 merged.warnings.append(&mut p.warnings);
1391 }
1392 }
1393 }
1394}
1395
1396#[must_use]
1398pub fn attribute_matching_ignore_case(repo: &Repository) -> bool {
1399 repo.config()
1400 .ok()
1401 .and_then(|c| c.get_bool("core.ignorecase"))
1402 .and_then(|r| r.ok())
1403 .unwrap_or(false)
1404}
1405
1406pub fn load_gitattributes_for_check_attr_cached(
1412 repo: &Repository,
1413) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1414 let mut merged = ParsedGitAttributes::default();
1415 merge_global_attributes_file(repo, &mut merged)?;
1416 let index = Index::load(&repo.git_dir().join("index"))?;
1417 let wt = repo.work_tree().unwrap_or(repo.git_dir());
1418 let mut from_index = load_gitattributes_from_index(&index, repo.odb(), wt)?;
1419 merged.rules.append(&mut from_index.rules);
1420 merged.macros.defs.extend(from_index.macros.defs.drain());
1421 merged.warnings.append(&mut from_index.warnings);
1422 merge_info_attributes(repo, &mut merged);
1423 Ok(merged)
1424}
1425
1426pub fn load_gitattributes_for_check_attr_source(
1432 repo: &Repository,
1433 tree_oid: &ObjectId,
1434) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1435 let mut merged = ParsedGitAttributes::default();
1436 merge_global_attributes_file(repo, &mut merged)?;
1437 append_gitattributes_from_tree(repo.odb(), tree_oid, &mut merged)?;
1438 merge_info_attributes(repo, &mut merged);
1439 Ok(merged)
1440}
1441
1442#[must_use]
1447pub fn builtin_objectmode_worktree(repo: &Repository, rel_path: &str) -> Option<String> {
1448 let wt = repo.work_tree()?;
1449 let p = wt.join(rel_path);
1450 let meta = fs::symlink_metadata(&p).ok()?;
1451 let ft = meta.file_type();
1452 if ft.is_symlink() {
1453 return Some("120000".to_string());
1454 }
1455 if ft.is_dir() {
1456 let git = p.join(".git");
1457 if let Ok(git_meta) = fs::symlink_metadata(&git) {
1458 if !git_meta.file_type().is_dir() {
1459 if let Ok(content) = fs::read_to_string(&git) {
1460 if content.starts_with("gitdir:") {
1461 return Some("160000".to_string());
1462 }
1463 }
1464 }
1465 }
1466 return Some("040000".to_string());
1467 }
1468 #[cfg(unix)]
1469 {
1470 use std::os::unix::fs::MetadataExt;
1471 let m = normalize_mode(meta.mode());
1472 Some(format!("{:06o}", m))
1473 }
1474 #[cfg(not(unix))]
1475 {
1476 let _ = repo;
1477 None
1478 }
1479}
1480
1481#[must_use]
1483pub fn builtin_objectmode_index(index: &Index, rel_path: &str) -> Option<String> {
1484 let key = rel_path.as_bytes();
1485 let e = index.get(key, 0)?;
1486 let m = e.mode;
1487 if m == MODE_SYMLINK {
1488 return Some("120000".to_string());
1489 }
1490 if m == MODE_GITLINK {
1491 return Some("160000".to_string());
1492 }
1493 if m == MODE_TREE {
1494 return Some("040000".to_string());
1495 }
1496 if m == MODE_EXECUTABLE {
1497 return Some("100755".to_string());
1498 }
1499 if m == MODE_REGULAR {
1500 return Some("100644".to_string());
1501 }
1502 Some(format!("{:06o}", m))
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507 use super::*;
1508
1509 #[test]
1510 fn unquote_c_style_requires_utf8_patterns() {
1511 assert_eq!(
1512 unquote_c_style(r#""caf\303\251" text"#),
1513 Ok(("café".to_owned(), " text"))
1514 );
1515 assert_eq!(unquote_c_style(r#""\377" text"#), Err(()));
1516 }
1517
1518 #[test]
1519 fn parse_unclosed_quote_falls_back_to_literal_pattern() {
1520 let parsed = parse_gitattributes_file_content("\"unclosed test=set\n", ".gitattributes");
1521 assert_eq!(parsed.rules.len(), 1);
1522 assert_eq!(parsed.rules[0].pattern, "\"unclosed");
1523 }
1524
1525 #[test]
1526 fn unquote_c_style_rejects_invalid_escape() {
1527 assert!(unquote_c_style("\"bad\\\"").is_err());
1528 }
1529
1530 #[test]
1531 fn macro_expansion_respects_depth_limit() {
1532 const N: u32 = 3003;
1533 let mut content = String::new();
1534 for i in 0..N {
1535 use std::fmt::Write;
1536 write!(content, "[attr]a{i} a{}\n", i + 1).expect("write");
1537 }
1538 use std::fmt::Write;
1539 write!(content, "[attr]a{N} tail=deep\nfile a0\n").expect("write tail");
1540 let parsed = parse_gitattributes_file_content(&content, ".gitattributes");
1541 let map = collect_attrs_for_path(&parsed.rules, &parsed.macros, "file", false);
1542 assert!(!map.contains_key("tail"));
1543 }
1544
1545 #[test]
1546 fn directory_only_pattern_matches_trailing_slash_paths() {
1547 let parsed = parse_gitattributes_file_content("dir/ export-submodule\n", ".gitattributes");
1548 let rule = &parsed.rules[0];
1549 assert!(!attr_rule_matches(rule, "dir", false));
1550 assert!(attr_rule_matches(rule, "dir/", false));
1551 }
1552
1553 #[test]
1554 fn builtin_objectmode_assignment_emits_check_attr_warning() {
1555 let parsed = parse_gitattributes_file_content("* builtin_objectmode=1\n", ".gitattributes");
1556 let warnings = builtin_warnings_for_rules(&parsed.rules, ".gitattributes");
1557 assert!(warnings.iter().any(|w| w.contains("builtin_objectmode")));
1558 }
1559
1560 #[test]
1561 fn attr_rule_endswith_star_suffix() {
1562 let parsed = parse_gitattributes_file_content("*.txt text\n", ".gitattributes");
1563 let rule = &parsed.rules[0];
1564 assert!(attr_rule_matches(rule, "readme.txt", false));
1565 assert!(!attr_rule_matches(rule, "readme.txta", false));
1566 }
1567
1568 #[test]
1569 fn normalize_rel_path_collapses_dot_dot() {
1570 assert_eq!(normalize_rel_path("a/b/../c"), "a/c");
1571 assert_eq!(normalize_rel_path("."), "");
1572 }
1573
1574 #[test]
1575 fn attr_rule_applies_to_path_honors_nested_attr_base() {
1576 assert!(!attr_rule_applies_to_path("a/b", "a/c/f", false));
1577 assert!(attr_rule_applies_to_path("a/b", "a/b/f", false));
1578 assert!(attr_rule_applies_to_path("a/b", "a/b/c/f", false));
1579 }
1580
1581 #[test]
1582 fn leading_slash_pattern_matches_from_repository_root() {
1583 let parsed = parse_gitattributes_file_content("/f test=set\n", ".gitattributes");
1584 let rule = &parsed.rules[0];
1585 assert!(attr_rule_matches(rule, "f", false));
1586 assert!(!attr_rule_matches(rule, "sub/f", false));
1587 }
1588
1589 #[test]
1590 fn validate_rules_for_add_accepts_normal_attributes() {
1591 let parsed = parse_gitattributes_file_content("* text\n", ".gitattributes");
1592 assert!(validate_rules_for_add(&parsed.rules, ".gitattributes").is_ok());
1593 }
1594
1595 #[test]
1596 fn quote_path_for_check_attr_leaves_simple_paths() {
1597 assert_eq!(quote_path_for_check_attr("a/b"), "a/b");
1598 }
1599
1600 #[test]
1601 fn d_yes_rule_clears_test_after_d_star() {
1602 let mut merged = ParsedGitAttributes::default();
1603 let root = parse_gitattributes_file_content("[attr]notest !test\n", ".gitattributes");
1604 merged.macros.defs.extend(root.macros.defs);
1605 let mut ab = parse_gitattributes_file_content_with_base(
1606 "h test=a/b/h\nd/* test=a/b/d/*\nd/yes notest\n",
1607 "a/b/.gitattributes",
1608 "a/b",
1609 );
1610 assert_eq!(ab.rules.len(), 3);
1611 merged.rules.append(&mut ab.rules);
1612 merged.macros.defs.extend(ab.macros.defs);
1613 let d_yes = merged
1614 .rules
1615 .iter()
1616 .find(|r| r.pattern == "d/yes")
1617 .expect("d/yes rule");
1618 assert!(attr_rule_matches(d_yes, "a/b/d/yes", false));
1619 let m = collect_attrs_for_path(&merged.rules, &merged.macros, "a/b/d/yes", false);
1620 assert!(
1621 !m.contains_key("test"),
1622 "expected test cleared by notest macro, got {:?}",
1623 m.get("test")
1624 );
1625 }
1626}
1627
1628#[cfg(test)]
1629mod attr_cache_tests {
1630 use super::*;
1631 use filetime::FileTime;
1632
1633 fn test_repo(td: &Path) -> Repository {
1634 crate::repo::init_repository(td, false, "main", None, crate::RefStorageFormat::Files)
1635 .expect("init repo")
1636 }
1637
1638 fn rules_for(repo: &Repository, wt: &Path) -> Vec<String> {
1639 let parsed = load_gitattributes_stack(repo, wt).expect("load stack");
1640 parsed.rules.iter().map(|r| r.pattern.clone()).collect()
1641 }
1642
1643 fn mtime_of(path: &Path) -> FileTime {
1644 FileTime::from_last_modification_time(&fs::symlink_metadata(path).expect("stat"))
1645 }
1646
1647 fn restore_mtime(path: &Path, stamp: FileTime) {
1648 filetime::set_file_mtime(path, stamp).expect("restore mtime");
1649 }
1650
1651 #[test]
1652 fn stack_cache_serves_same_stamp_and_invalidates_on_change() {
1653 let td = tempfile::tempdir().expect("tempdir");
1654 let wt = td.path();
1655 let repo = test_repo(wt);
1656 let ga = wt.join(".gitattributes");
1657 fs::write(&ga, "*.aaa text\n").expect("write v1");
1658 let wt_t0 = mtime_of(wt);
1659 restore_mtime(wt, wt_t0);
1660 let t0 = mtime_of(&ga);
1661 assert_eq!(rules_for(&repo, wt), vec!["*.aaa".to_string()]);
1662
1663 fs::write(&ga, "*.bbb text\n").expect("write v2");
1667 restore_mtime(&ga, t0);
1668 restore_mtime(wt, wt_t0);
1669 assert_eq!(rules_for(&repo, wt), vec!["*.aaa".to_string()]);
1670
1671 fs::write(&ga, "*.ccc-longer text\n").expect("write v3");
1673 restore_mtime(&ga, t0);
1674 restore_mtime(wt, wt_t0);
1675 assert_eq!(rules_for(&repo, wt), vec!["*.ccc-longer".to_string()]);
1676 }
1677
1678 #[test]
1679 fn new_nested_gitattributes_is_detected() {
1680 let td = tempfile::tempdir().expect("tempdir");
1681 let wt = td.path();
1682 let repo = test_repo(wt);
1683 fs::write(wt.join(".gitattributes"), "root-rule text\n").expect("write root");
1684 restore_mtime(wt, FileTime::from_unix_time(1_000_000_000, 0));
1687 assert_eq!(rules_for(&repo, wt), vec!["root-rule".to_string()]);
1688
1689 fs::create_dir(wt.join("sub")).expect("mkdir");
1692 fs::write(wt.join("sub/.gitattributes"), "nested-rule text\n").expect("write nested");
1693 assert_eq!(
1694 rules_for(&repo, wt),
1695 vec!["root-rule".to_string(), "nested-rule".to_string()]
1696 );
1697 }
1698
1699 #[test]
1700 fn modified_nested_gitattributes_follows_c_git_process_semantics() {
1701 let td = tempfile::tempdir().expect("tempdir");
1702 let wt = td.path();
1703 let repo = test_repo(wt);
1704 fs::create_dir(wt.join("sub")).expect("mkdir");
1705 let nested = wt.join("sub/.gitattributes");
1706 fs::write(&nested, "one text\n").expect("write v1");
1707 restore_mtime(wt, FileTime::from_unix_time(1_000_000_000, 0));
1710 assert_eq!(rules_for(&repo, wt), vec!["one".to_string()]);
1711
1712 fs::write(&nested, "two-longer text\n").expect("write v2");
1716 assert_eq!(rules_for(&repo, wt), vec!["one".to_string()]);
1717
1718 fs::create_dir(wt.join("poke")).expect("mkdir poke");
1721 assert_eq!(rules_for(&repo, wt), vec!["two-longer".to_string()]);
1722 }
1723
1724 #[test]
1725 fn oversized_worktree_gitattributes_file_is_skipped() {
1726 let td = tempfile::tempdir().expect("tempdir");
1727 let wt = td.path();
1728 let repo = test_repo(wt);
1729 let data = vec![b'x'; MAX_ATTR_FILE_BYTES + 1];
1730 fs::write(wt.join(".gitattributes"), &data).expect("large ga");
1731 let parsed = load_gitattributes_stack_uncached(&repo, wt).expect("stack");
1732 assert!(parsed.rules.is_empty());
1733 assert!(
1734 parsed
1735 .warnings
1736 .iter()
1737 .any(|w| w.contains("ignoring overly large")),
1738 "{:?}",
1739 parsed.warnings
1740 );
1741 }
1742
1743 #[test]
1744 fn load_cached_and_source_loaders_merge_info() {
1745 let td = tempfile::tempdir().expect("tempdir");
1746 let wt = td.path();
1747 let repo = test_repo(wt);
1748 fs::write(wt.join(".gitattributes"), "f test=root\n").expect("ga");
1749 fs::write(repo.git_dir().join("info/attributes"), "f test=info\n").expect("info");
1750 let cached = load_gitattributes_for_check_attr_cached(&repo).expect("cached");
1751 assert_eq!(
1752 collect_attrs_for_path(&cached.rules, &cached.macros, "f", false).get("test"),
1753 Some(&AttrValue::Value("info".into()))
1754 );
1755 }
1756
1757 #[test]
1758 fn resolve_attr_treeish_prefers_explicit_source_argument() {
1759 let td = tempfile::tempdir().expect("tempdir");
1760 let wt = td.path();
1761 let repo = test_repo(wt);
1762 let (spec, ignore) = resolve_attr_treeish(&repo, Some("HEAD^{tree}")).expect("treeish");
1763 assert_eq!(spec.as_deref(), Some("HEAD^{tree}"));
1764 assert!(!ignore);
1765 }
1766
1767 #[test]
1768 fn path_relative_to_worktree_resolves_missing_relative_path() {
1769 let td = tempfile::tempdir().expect("tempdir");
1770 let wt = td.path();
1771 let repo = test_repo(wt);
1772 let mut env = repo.environment().clone();
1773 env.cwd = wt.join("a");
1774 fs::create_dir_all(wt.join("a")).expect("mkdir");
1775 let options = crate::environment::RepositoryOptions::with_environment(env);
1776 let repo2 = Repository::open(&repo.git_dir(), &&options.clone().with_work_tree(wt))
1777 .expect("reopen");
1778 let rel = path_relative_to_worktree(&repo2, "../f").expect("rel");
1779 assert_eq!(rel, "f");
1780 }
1781
1782 #[test]
1783 fn info_attributes_is_stamped() {
1784 let td = tempfile::tempdir().expect("tempdir");
1785 let wt = td.path();
1786 let repo = test_repo(wt);
1787 assert!(rules_for(&repo, wt).is_empty());
1788
1789 fs::write(repo.git_dir().join("info/attributes"), "from-info text\n").expect("write info");
1791 assert_eq!(rules_for(&repo, wt), vec!["from-info".to_string()]);
1792 }
1793}