Skip to main content

grit_lib/
attributes.rs

1//! Gitattributes parsing and pattern matching for `check-attr` and validation.
2//!
3//! Implements Git-consistent rule ordering, macro expansion (`[attr]`), `binary`
4//! expansion, `**` globbing via [`crate::wildmatch`], and optional case folding
5//! for `core.ignorecase`.
6
7use 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
32/// Maximum length of a single `.gitattributes` line (bytes), matching Git (`ATTR_MAX_LINE_LENGTH`).
33/// Lines of this length or longer are ignored with a warning.
34pub const MAX_ATTR_LINE_BYTES: usize = 2048;
35
36/// Maximum `.gitattributes` file size (bytes) before Git ignores the file.
37pub const MAX_ATTR_FILE_BYTES: usize = 100 * 1024 * 1024;
38
39/// Parsed attribute value for display (`check-attr` output).
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum AttrValue {
42    /// `Set` variant.
43    Set,
44    /// Explicit `-attr` in a rule — `check-attr` prints `unset`.
45    Unset,
46    /// Macro body `!attr` — clears the attribute to *unspecified* (not `unset`).
47    Clear,
48    /// `Value(String)` variant.
49    Value(String),
50}
51
52impl AttrValue {
53    /// Text form as printed by `git check-attr`.
54    #[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
65/// Pattern flags after Git `parse_path_pattern` (`dir.c`).
66const 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
75/// Length of initial literal segment before the first glob special (Git `simple_length`).
76fn 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
88/// Parse pattern text like Git `parse_path_pattern` (after `!` and unquoting are handled).
89fn 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/// One line in a gitattributes file.
114#[derive(Debug, Clone)]
115pub struct AttrRule {
116    /// Directory of the `.gitattributes` file that defined this rule (repo-relative, `/`,
117    /// no trailing slash). Empty for the repository root file.
118    pub attr_base: String,
119    /// Pattern body (no leading `!`; trailing `/` stripped; same as Git after `parse_path_pattern` prep).
120    pub pattern: String,
121    /// From `parse_path_pattern`: basename-only match vs full path under `attr_base`.
122    pub pattern_flags: u32,
123    /// Length of leading literal segment before first wildcard (Git `nowildcardlen`).
124    pub nowildcardlen: usize,
125    /// If true, this rule was discarded (negative pattern) after emitting a warning.
126    pub skip: bool,
127    /// 1-based line number in the source file.
128    pub line: usize,
129    /// Attribute assignments in source order (last wins for duplicates on this line).
130    pub attrs: Vec<(String, AttrValue)>,
131}
132
133/// Macro definitions from `[attr]name ...` lines.
134#[derive(Debug, Clone, Default)]
135pub struct MacroTable {
136    /// Maps macro name → list of assignments (e.g. `!test` → unset test).
137    pub defs: HashMap<String, Vec<(String, AttrValue)>>,
138}
139
140/// Result of parsing a gitattributes file.
141#[derive(Debug, Clone, Default)]
142pub struct ParsedGitAttributes {
143    /// `rules` field.
144    pub rules: Vec<AttrRule>,
145    /// `macros` field.
146    pub macros: MacroTable,
147    /// `warnings` field.
148    pub warnings: Vec<String>,
149}
150
151/// Returns true if `name` is reserved (`builtin_*` except the real builtin names Git allows).
152#[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
160/// Validate user-defined attribute names in parsed rules (for `git add`).
161///
162/// Returns an error string matching Git when a rule uses an invalid `builtin_*` name.
163pub 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
183/// Collect warnings for invalid `builtin_*` assignments (check-attr continues).
184pub 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
218/// Read a `.gitattributes` path; if it is a symlink, record an error and skip (in-tree rules).
219fn 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
234/// Parse one gitattributes file from disk (patterns are relative to `attr_base`, the directory
235/// containing the file — use `""` for the repository root file).
236pub fn parse_gitattributes_file_content(content: &str, display_path: &str) -> ParsedGitAttributes {
237    parse_gitattributes_content_impl(content, display_path, false, "")
238}
239
240/// Parse attributes defined in a `.gitattributes` file located in `attr_base` (repo-relative,
241/// `/` separators, no trailing slash; empty string for the repository root).
242pub 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
282/// Skip leading ASCII blanks only (matches Git's `blank` in `attr.c`).
283fn skip_ascii_blank(s: &str) -> &str {
284    s.trim_start_matches([' ', '\t', '\r', '\n'])
285}
286
287/// First whitespace-delimited token and the remainder (Git `strcspn` on `blank`).
288fn 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
297/// Decode a quoted pattern, retaining the attributes parser's UTF-8 requirement.
298fn 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
303/// One attribute assignment token (`parse_attr` in Git `attr.c`).
304fn 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
460/// Git `match_basename` (`dir.c`) for attribute patterns.
461fn 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/// Git `match_pathname` (`dir.c`) for attribute patterns.
493#[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
551/// Directory prefix of `rel_path` (no trailing slash), or `""` for a top-level file.
552fn path_dir_prefix(rel_path: &str) -> &str {
553    match rel_path.rfind('/') {
554        Some(i) => &rel_path[..i],
555        None => "",
556    }
557}
558
559/// Whether a rule from `dir/.gitattributes` may apply to `rel_path` (Git `prepare_attr_stack`).
560///
561/// Rules from nested attribute files only affect paths inside that directory tree.
562#[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/// Match one parsed rule against a repo-relative path (Git `path_matches` / `attr.c`).
589#[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
633/// Git's t0003 deep-macro case uses 3000 chained `[attr]` lines plus a terminal `-text` assignment.
634const MAX_MACRO_EXPAND_DEPTH: u32 = 3001;
635
636/// One macro/`binary` expansion step (iterative queue avoids deep recursion on long chains).
637struct 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
650/// Push one assignment, expanding user macros only on bare `Set` (Git `expand_attr`).
651fn 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
687/// Expand macros and `binary` for one rule's assignments into source-order operations.
688///
689/// These must be applied in order to the same map as later rules (not folded into a local map),
690/// so `!attr` / macro clears remove attributes set by earlier rules on the same path.
691fn 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
699/// Merge assignments: later rules override earlier; within one expanded rule, last wins.
700pub 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/// Quote a path for `check-attr` output (C-style) when needed.
730#[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/// Normalize `.` / `..` segments in a repo-relative path string.
753#[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
786/// Resolve a user path to a repo-relative path (forward slashes).
787///
788/// Uses [`std::fs::canonicalize`] when the target exists; otherwise resolves `..` lexically from the
789/// current directory so paths like `../f` work for missing files (Git `prefix_path`, t0003).
790pub 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
846// ── Process-lifetime gitattributes cache ─────────────────────────────
847//
848// `load_gitattributes_stack` re-walks the entire working tree (`read_dir`
849// per directory) and re-parses every `.gitattributes` on each call, and hot
850// paths (grep/diff/add/checkout) call it per file. The parsed stack is
851// memoized for the process lifetime and revalidated with stat stamps on
852// every call:
853//
854// - the global attributes file, root `.gitattributes`, and
855//   `info/attributes` are stamped (mtime + size, or "absent"), recorded
856//   *before* the parse;
857// - the work-tree root directory is mtime-stamped, so creating or deleting
858//   a root-level entry forces a re-walk. Nested `.gitattributes` files are
859//   *not* revalidated per query (see `collect_stack_stamps`); within one
860//   process they behave like C git's process-lifetime attribute cache.
861//
862// Tree-sourced stacks (`attr.tree` / `GIT_ATTR_SOURCE`) are keyed by tree
863// OID and never revalidated: tree objects are content-addressed and
864// immutable.
865//
866// The resolved global-attributes *path* (from `core.attributesFile`) is
867// recorded at parse time and only re-statted afterwards; a mid-process
868// change to that config value is not detected. C git caches the attribute
869// stack per directory for the whole process with no revalidation at all,
870// so serving a stamped copy is strictly more conservative than upstream.
871
872type 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
881/// `symlink_metadata`-based stamp, matching `read_gitattributes_maybe_symlink`
882/// (symlinked `.gitattributes` files are skipped by the parser, but stamping
883/// the link still detects replacement by a regular file).
884pub(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
907/// Stamp the cheap top-level inputs of the stack: the global attributes
908/// file, root `.gitattributes`, `info/attributes`, and the work-tree root
909/// directory's mtime (~4 stats per validation).
910///
911/// Nested per-directory `.gitattributes` files are deliberately *not*
912/// stamped: revalidating them costs two stats per walked directory, which
913/// dominates per-file hot loops on large trees. Within one process a change
914/// to an already-loaded nested file is therefore served stale — matching
915/// C git, which caches attribute stacks for the whole process with no
916/// revalidation at all. The checkout/apply/merge materialization paths are
917/// unaffected: they read attributes through
918/// `crlf::load_gitattributes_for_checkout` (index/odb-sourced), not this
919/// work-tree stack. Creating or deleting entries in the work-tree *root*
920/// still bumps its stamped mtime and forces a fresh walk.
921pub(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
940/// Load the full stack of attribute rules for a normal repository (working tree).
941///
942/// Results are memoized for the process lifetime and revalidated against
943/// stat stamps on every call (see the cache notes above).
944pub 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
1036/// Bare repository: only `info/attributes` from disk (no in-repo `.gitattributes` file).
1037///
1038/// Memoized like [`load_gitattributes_stack`], keyed by `git_dir`.
1039pub 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    // Without a work tree, Git reads tracked `.gitattributes` from the index (Git
1074    // `read_attr_from_index`), so e.g. `git -C .git diff-tree --check` still honours a
1075    // committed `* -whitespace` attribute. Prepend index rules so work-tree-equivalent
1076    // ordering (closer paths win) is preserved relative to info/global.
1077    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            // info/global attributes are lower priority than per-tree `.gitattributes`,
1082            // so place the index rules ahead of what we have collected so far.
1083            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
1094/// Read `.gitattributes` blob from a tree object at `tree_oid`, recursively.
1095/// Index or tree path is a `.gitattributes` file (basename must be exactly `.gitattributes`).
1096fn path_is_gitattributes(rel: &str) -> bool {
1097    Path::new(rel)
1098        .file_name()
1099        .is_some_and(|n| n == ".gitattributes")
1100}
1101
1102/// Directory depth of a `.gitattributes` path (`".gitattributes"` → 0, `"a/b/.gitattributes"` → 2).
1103fn 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
1141/// `load_gitattributes_from_tree` function.
1142pub 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
1229/// Load merged `.gitattributes` rules for diff and merge (respects `GIT_ATTR_SOURCE` / `attr.tree`).
1230///
1231/// Resolution order matches Git's attribute source for diff: optional tree from
1232/// [`resolve_attr_treeish`], then work tree stack (or bare `info/attributes` only).
1233///
1234/// # Errors
1235///
1236/// Returns an error when a tree-ish source is set from the environment or command line and cannot
1237/// be resolved (Git: *"bad --attr-source or GIT_ATTR_SOURCE"*).
1238pub 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
1259/// Resolve `attr.tree`, `GIT_ATTR_SOURCE`, `--source` precedence for check-attr.
1260///
1261/// The second return value is `ignore_bad_resolution`: when true (only for `attr.tree` from
1262/// config), an unresolvable tree-ish falls back to reading `.gitattributes` from the work tree
1263/// or index instead of erroring (matches Git `compute_default_attr_source`).
1264pub 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
1286/// Parse a revision to a tree OID for attribute loading.
1287pub 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
1300/// Load attributes from the index (stage 0) for `.gitattributes` paths only.
1301pub 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/// Whether attribute pattern matching should fold ASCII case (`core.ignorecase`).
1397#[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
1406/// Merged attribute rules for `git check-attr --cached` (global, index `.gitattributes`, `info/attributes`).
1407///
1408/// # Errors
1409///
1410/// Returns an error if the index cannot be read or global attributes path resolution fails.
1411pub 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
1426/// Merged attribute rules for `git check-attr --source=<tree>` (global, tree blobs, `info/attributes`).
1427///
1428/// # Errors
1429///
1430/// Returns an error if the tree cannot be read or global attributes path resolution fails.
1431pub 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/// Return `builtin_objectmode` value for a path (working tree), or `None` if unavailable.
1443///
1444/// Submodule checkout directories (`.git` is a file containing `gitdir:`) report `160000`
1445/// like Git, not `040000`.
1446#[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/// `builtin_objectmode` from the index when `--cached` is used.
1482#[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        // Same size + restored mtime (file and work-tree dir): stat cannot
1664        // tell the difference, so the cached parse is served. This is the
1665        // assertion that proves the cache is actually used.
1666        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        // A size change invalidates even with restored mtimes.
1672        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        // Pre-age the work-tree mtime so the upcoming mkdir visibly bumps it
1685        // even on filesystems with coarse mtime ticks.
1686        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        // Creating a subdirectory bumps the stamped work-tree mtime, forcing
1690        // a re-walk that discovers the new nested file.
1691        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        // Pre-age the work-tree mtime so the later root-level mkdir visibly
1708        // bumps it even on filesystems with coarse mtime ticks.
1709        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        // Nested files are not revalidated per query: within one process a
1713        // content edit is served from cache, matching C git's
1714        // process-lifetime attribute caching.
1715        fs::write(&nested, "two-longer text\n").expect("write v2");
1716        assert_eq!(rules_for(&repo, wt), vec!["one".to_string()]);
1717
1718        // Any root-level signal (here: a new top-level directory) bumps the
1719        // stamped work-tree mtime and the fresh walk picks up the edit.
1720        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        // info/attributes appearing after a cached empty load must be seen.
1790        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}