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;
9#[cfg(unix)]
10use crate::index::normalize_mode;
11use crate::index::Index;
12use crate::index::MODE_EXECUTABLE;
13use crate::index::MODE_GITLINK;
14use crate::index::MODE_REGULAR;
15use crate::index::MODE_SYMLINK;
16use crate::index::MODE_TREE;
17use crate::objects::parse_tree;
18use crate::objects::ObjectId;
19use crate::objects::ObjectKind;
20use crate::odb::Odb;
21use crate::repo::Repository;
22use crate::rev_parse::resolve_revision;
23use crate::wildmatch::{wildmatch, WM_CASEFOLD, WM_PATHNAME};
24use std::borrow::Cow;
25use std::collections::HashMap;
26use std::ffi::OsStr;
27use std::fs;
28use std::path::{Component, Path, PathBuf};
29use std::sync::{Arc, Mutex, OnceLock};
30use std::time::SystemTime;
31
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,
43    /// Explicit `-attr` in a rule — `check-attr` prints `unset`.
44    Unset,
45    /// Macro body `!attr` — clears the attribute to *unspecified* (not `unset`).
46    Clear,
47    Value(String),
48}
49
50impl AttrValue {
51    /// Text form as printed by `git check-attr`.
52    #[must_use]
53    pub fn display(&self) -> &str {
54        match self {
55            AttrValue::Set => "set",
56            AttrValue::Unset => "unset",
57            AttrValue::Clear => "unspecified",
58            AttrValue::Value(v) => v.as_str(),
59        }
60    }
61}
62
63/// Pattern flags after Git `parse_path_pattern` (`dir.c`).
64const PAT_NODIR: u32 = 1;
65const PAT_MUSTBEDIR: u32 = 2;
66const PAT_ENDSWITH: u32 = 4;
67
68#[inline]
69fn is_glob_special_attr(c: u8) -> bool {
70    matches!(c, b'*' | b'?' | b'[' | b'\\')
71}
72
73/// Length of initial literal segment before the first glob special (Git `simple_length`).
74fn simple_length_pat(s: &str) -> usize {
75    let b = s.as_bytes();
76    let mut i = 0;
77    while i < b.len() {
78        if is_glob_special_attr(b[i]) {
79            return i;
80        }
81        i += 1;
82    }
83    i
84}
85
86/// Parse pattern text like Git `parse_path_pattern` (after `!` and unquoting are handled).
87fn parse_attr_pattern_fields(pat: &str) -> (String, u32, usize) {
88    let mut flags = 0u32;
89    let mut len = pat.len();
90    if len > 0 && pat.as_bytes()[len - 1] == b'/' {
91        len -= 1;
92        flags |= PAT_MUSTBEDIR;
93    }
94    let p = &pat[..len];
95    let has_slash = p.as_bytes().contains(&b'/');
96    if !has_slash {
97        flags |= PAT_NODIR;
98    }
99    if let Some(rest) = p.strip_prefix('*') {
100        if !rest.is_empty() && simple_length_pat(rest) == rest.len() {
101            flags |= PAT_ENDSWITH;
102        }
103    }
104    let mut nowild = simple_length_pat(p);
105    if nowild > len {
106        nowild = len;
107    }
108    (p.to_string(), flags, nowild)
109}
110
111/// One line in a gitattributes file.
112#[derive(Debug, Clone)]
113pub struct AttrRule {
114    /// Directory of the `.gitattributes` file that defined this rule (repo-relative, `/`,
115    /// no trailing slash). Empty for the repository root file.
116    pub attr_base: String,
117    /// Pattern body (no leading `!`; trailing `/` stripped; same as Git after `parse_path_pattern` prep).
118    pub pattern: String,
119    /// From `parse_path_pattern`: basename-only match vs full path under `attr_base`.
120    pub pattern_flags: u32,
121    /// Length of leading literal segment before first wildcard (Git `nowildcardlen`).
122    pub nowildcardlen: usize,
123    /// If true, this rule was discarded (negative pattern) after emitting a warning.
124    pub skip: bool,
125    /// 1-based line number in the source file.
126    pub line: usize,
127    /// Attribute assignments in source order (last wins for duplicates on this line).
128    pub attrs: Vec<(String, AttrValue)>,
129}
130
131/// Macro definitions from `[attr]name ...` lines.
132#[derive(Debug, Clone, Default)]
133pub struct MacroTable {
134    /// Maps macro name → list of assignments (e.g. `!test` → unset test).
135    pub defs: HashMap<String, Vec<(String, AttrValue)>>,
136}
137
138/// Result of parsing a gitattributes file.
139#[derive(Debug, Clone, Default)]
140pub struct ParsedGitAttributes {
141    pub rules: Vec<AttrRule>,
142    pub macros: MacroTable,
143    pub warnings: Vec<String>,
144}
145
146/// Returns true if `name` is reserved (`builtin_*` except the real builtin names Git allows).
147#[must_use]
148pub fn is_reserved_builtin_name(name: &str) -> bool {
149    let Some(rest) = name.strip_prefix("builtin_") else {
150        return false;
151    };
152    matches!(rest, "objectmode")
153}
154
155/// Validate user-defined attribute names in parsed rules (for `git add`).
156///
157/// Returns an error string matching Git when a rule uses an invalid `builtin_*` name.
158pub fn validate_rules_for_add(
159    rules: &[AttrRule],
160    display_path: &str,
161) -> std::result::Result<(), String> {
162    for rule in rules {
163        if rule.skip {
164            continue;
165        }
166        for (name, _) in &rule.attrs {
167            if name.starts_with("builtin_") && !is_reserved_builtin_name(name) {
168                return Err(format!(
169                    "{name} is not a valid attribute name: {display_path}:{}",
170                    rule.line
171                ));
172            }
173        }
174    }
175    Ok(())
176}
177
178/// Collect warnings for invalid `builtin_*` assignments (check-attr continues).
179pub fn builtin_warnings_for_rules(rules: &[AttrRule], display_path: &str) -> Vec<String> {
180    let mut w = Vec::new();
181    for rule in rules {
182        if rule.skip {
183            continue;
184        }
185        for (name, _) in &rule.attrs {
186            if name == "builtin_objectmode" {
187                w.push(format!(
188                    "builtin_objectmode is not a valid attribute name: {display_path}:{}",
189                    rule.line
190                ));
191            } else if name.starts_with("builtin_") && !is_reserved_builtin_name(name) {
192                w.push(format!(
193                    "{name} is not a valid attribute name: {display_path}:{}",
194                    rule.line
195                ));
196            }
197        }
198    }
199    w
200}
201
202fn default_global_attributes_path() -> Option<PathBuf> {
203    let home = std::env::var("HOME").ok()?;
204    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
205        if !xdg.is_empty() {
206            return Some(PathBuf::from(xdg).join("git/attributes"));
207        }
208    }
209    Some(PathBuf::from(home).join(".config/git/attributes"))
210}
211
212fn global_attributes_path(
213    repo: &Repository,
214) -> std::result::Result<Option<PathBuf>, crate::error::Error> {
215    let config = ConfigSet::load(Some(&repo.git_dir), true)?;
216    if let Some(path) = config.get("core.attributesfile") {
217        return Ok(Some(PathBuf::from(parse_path(&path))));
218    }
219    Ok(default_global_attributes_path())
220}
221
222/// Read a `.gitattributes` path; if it is a symlink, record an error and skip (in-tree rules).
223fn read_gitattributes_maybe_symlink(
224    path: &Path,
225    display: &str,
226    warnings: &mut Vec<String>,
227) -> Option<String> {
228    let meta = fs::symlink_metadata(path).ok()?;
229    if meta.file_type().is_symlink() {
230        warnings.push(format!(
231            "unable to access '{display}': Too many levels of symbolic links"
232        ));
233        return None;
234    }
235    fs::read_to_string(path).ok()
236}
237
238/// Parse one gitattributes file from disk (patterns are relative to `attr_base`, the directory
239/// containing the file — use `""` for the repository root file).
240pub fn parse_gitattributes_file_content(content: &str, display_path: &str) -> ParsedGitAttributes {
241    parse_gitattributes_content_impl(content, display_path, false, "")
242}
243
244/// Parse attributes defined in a `.gitattributes` file located in `attr_base` (repo-relative,
245/// `/` separators, no trailing slash; empty string for the repository root).
246pub fn parse_gitattributes_file_content_with_base(
247    content: &str,
248    display_path: &str,
249    attr_base: &str,
250) -> ParsedGitAttributes {
251    parse_gitattributes_content_impl(content, display_path, false, attr_base)
252}
253
254fn preprocess_gitattributes_blob_text(content: &str) -> Cow<'_, str> {
255    if !content.contains("\\n") {
256        return Cow::Borrowed(content);
257    }
258    Cow::Owned(content.replace("\\n", "\n"))
259}
260
261fn parse_gitattributes_content_impl(
262    content: &str,
263    display_path: &str,
264    from_blob: bool,
265    attr_base: &str,
266) -> ParsedGitAttributes {
267    let preprocessed = if from_blob {
268        preprocess_gitattributes_blob_text(content)
269    } else {
270        Cow::Borrowed(content)
271    };
272    let content = preprocessed.as_ref();
273
274    let mut out = ParsedGitAttributes::default();
275    for (idx, raw_line) in content.lines().enumerate() {
276        let line_no = idx + 1;
277        let line_bytes = raw_line.as_bytes();
278        if line_bytes.len() >= MAX_ATTR_LINE_BYTES {
279            out.warnings.push(format!(
280                "warning: ignoring overly long attributes line {line_no}"
281            ));
282            continue;
283        }
284        parse_one_line(
285            raw_line,
286            line_no,
287            display_path,
288            from_blob,
289            attr_base,
290            &mut out,
291        );
292    }
293    out.warnings
294        .extend(builtin_warnings_for_rules(&out.rules, display_path));
295    out
296}
297
298/// Skip leading ASCII blanks only (matches Git's `blank` in `attr.c`).
299fn skip_ascii_blank(s: &str) -> &str {
300    s.trim_start_matches([' ', '\t', '\r', '\n'])
301}
302
303/// First whitespace-delimited token and the remainder (Git `strcspn` on `blank`).
304fn split_at_first_blank(s: &str) -> (&str, &str) {
305    let bytes = s.as_bytes();
306    let n = bytes
307        .iter()
308        .position(|&b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
309        .unwrap_or(bytes.len());
310    s.split_at(n)
311}
312
313/// C-style unquote for a pattern that starts with `"` (see Git `unquote_c_style` in `quote.c`).
314fn unquote_c_style(quoted: &str) -> Result<(String, &str), ()> {
315    let b = quoted.as_bytes();
316    if b.is_empty() || b[0] != b'"' {
317        return Err(());
318    }
319    let mut q = &b[1..];
320    let mut out = Vec::new();
321    loop {
322        let len = q
323            .iter()
324            .position(|&c| c == b'"' || c == b'\\')
325            .unwrap_or(q.len());
326        out.extend_from_slice(&q[..len]);
327        q = &q[len..];
328        if q.is_empty() {
329            return Err(());
330        }
331        match q[0] {
332            b'"' => {
333                let rest = std::str::from_utf8(&q[1..]).map_err(|_| ())?;
334                return Ok((String::from_utf8(out).map_err(|_| ())?, rest));
335            }
336            b'\\' => {
337                q = &q[1..];
338                if q.is_empty() {
339                    return Err(());
340                }
341                let ch = q[0];
342                q = &q[1..];
343                match ch {
344                    b'a' => out.push(0x07),
345                    b'b' => out.push(0x08),
346                    b'f' => out.push(0x0c),
347                    b'n' => out.push(b'\n'),
348                    b'r' => out.push(b'\r'),
349                    b't' => out.push(b'\t'),
350                    b'v' => out.push(0x0b),
351                    b'\\' => out.push(b'\\'),
352                    b'"' => out.push(b'"'),
353                    b'0'..=b'3' => {
354                        let mut ac = u32::from(ch - b'0') << 6;
355                        if q.len() < 2 {
356                            return Err(());
357                        }
358                        let ch2 = q[0];
359                        let ch3 = q[1];
360                        if !(b'0'..=b'7').contains(&ch2) || !(b'0'..=b'7').contains(&ch3) {
361                            return Err(());
362                        }
363                        ac |= u32::from(ch2 - b'0') << 3;
364                        ac |= u32::from(ch3 - b'0');
365                        q = &q[2..];
366                        out.push(ac as u8);
367                    }
368                    _ => return Err(()),
369                }
370            }
371            _ => return Err(()),
372        }
373    }
374}
375
376/// One attribute assignment token (`parse_attr` in Git `attr.c`).
377fn parse_one_attr_token_git(s: &str) -> (&str, Option<&str>, &str) {
378    let bytes = s.as_bytes();
379    let token_end = bytes
380        .iter()
381        .position(|&b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
382        .unwrap_or(bytes.len());
383    let eq_pos = s.find('=');
384    let eq_in_token = eq_pos.filter(|&eq| eq < token_end);
385    let (name, val) = if let Some(eq) = eq_in_token {
386        (&s[..eq], Some(&s[eq + 1..token_end]))
387    } else {
388        (&s[..token_end], None)
389    };
390    let rest = skip_ascii_blank(&s[token_end..]);
391    (name, val, rest)
392}
393
394fn accumulate_attr_states(
395    mut states: &str,
396    attrs: &mut Vec<(String, AttrValue)>,
397    macros: &MacroTable,
398    in_macro_def: bool,
399) {
400    loop {
401        states = skip_ascii_blank(states);
402        if states.is_empty() {
403            break;
404        }
405        let (name, val, rest) = parse_one_attr_token_git(states);
406        states = rest;
407        let tok = match val {
408            Some(v) => format!("{name}={v}"),
409            None => name.to_string(),
410        };
411        push_attr_token(&tok, attrs, macros, in_macro_def);
412    }
413}
414
415const ATTR_MACRO_PREFIX: &str = "[attr]";
416
417fn parse_one_line(
418    raw_line: &str,
419    line_no: usize,
420    display_path: &str,
421    from_blob: bool,
422    attr_base: &str,
423    out: &mut ParsedGitAttributes,
424) {
425    let _ = display_path;
426    let _ = from_blob;
427    let cp = skip_ascii_blank(raw_line);
428    if cp.is_empty() || cp.starts_with('#') {
429        return;
430    }
431
432    let (pattern_token, states) = if cp.as_bytes().first() == Some(&b'"') {
433        match unquote_c_style(cp) {
434            Ok((pat, rest)) => (pat, rest),
435            Err(()) => {
436                let (a, b) = split_at_first_blank(cp);
437                (a.to_string(), b)
438            }
439        }
440    } else {
441        let (a, b) = split_at_first_blank(cp);
442        (a.to_string(), b)
443    };
444
445    if pattern_token.len() > ATTR_MACRO_PREFIX.len() && pattern_token.starts_with(ATTR_MACRO_PREFIX)
446    {
447        let rest = skip_ascii_blank(&pattern_token[ATTR_MACRO_PREFIX.len()..]);
448        let (macro_name, leftover) = split_at_first_blank(rest);
449        if !leftover.is_empty() || macro_name.is_empty() {
450            return;
451        }
452        let mut attrs = Vec::new();
453        accumulate_attr_states(states, &mut attrs, &out.macros, true);
454        out.macros.defs.insert(macro_name.to_string(), attrs);
455        return;
456    }
457
458    if pattern_token.starts_with('!') && !pattern_token.starts_with("\\!") {
459        out.warnings
460            .push("Negative patterns are ignored".to_string());
461        return;
462    }
463    let pattern_raw = pattern_token.replace("\\!", "!");
464    let (pattern, pattern_flags, nowildcardlen) = parse_attr_pattern_fields(&pattern_raw);
465    let mut attrs = Vec::new();
466    accumulate_attr_states(states, &mut attrs, &out.macros, false);
467    if attrs.is_empty() {
468        return;
469    }
470    out.rules.push(AttrRule {
471        attr_base: attr_base.to_string(),
472        pattern,
473        pattern_flags,
474        nowildcardlen,
475        skip: false,
476        line: line_no,
477        attrs,
478    });
479}
480
481fn push_attr_token(
482    tok: &str,
483    attrs: &mut Vec<(String, AttrValue)>,
484    _macros: &MacroTable,
485    in_macro_def: bool,
486) {
487    if tok == "binary" {
488        attrs.push(("text".into(), AttrValue::Unset));
489        attrs.push(("diff".into(), AttrValue::Unset));
490        attrs.push(("merge".into(), AttrValue::Unset));
491        attrs.push(("binary".into(), AttrValue::Set));
492        return;
493    }
494    if in_macro_def {
495        if let Some(rest) = tok.strip_prefix('!') {
496            attrs.push((rest.to_string(), AttrValue::Clear));
497            return;
498        }
499    }
500    if let Some(rest) = tok.strip_prefix('-') {
501        attrs.push((rest.to_string(), AttrValue::Unset));
502        return;
503    }
504    if let Some((k, v)) = tok.split_once('=') {
505        let v = v.trim_end_matches(|c: char| {
506            matches!(c, ' ' | '\t' | '\r' | '\n') || c == '\u{000b}' || c == '\u{000c}'
507        });
508        attrs.push((k.to_string(), AttrValue::Value(v.to_string())));
509        return;
510    }
511    attrs.push((tok.to_string(), AttrValue::Set));
512}
513
514fn fspathncmp(a: &[u8], b: &[u8], count: usize, icase: bool) -> bool {
515    if a.len() < count || b.len() < count {
516        return false;
517    }
518    if icase {
519        a[..count]
520            .iter()
521            .zip(&b[..count])
522            .all(|(x, y)| x.eq_ignore_ascii_case(y))
523    } else {
524        a[..count] == b[..count]
525    }
526}
527
528/// Git `match_basename` (`dir.c`) for attribute patterns.
529fn match_basename_git(
530    basename: &[u8],
531    pattern: &[u8],
532    prefix: usize,
533    patternlen: usize,
534    pat_flags: u32,
535    icase: bool,
536) -> bool {
537    let basenamelen = basename.len();
538    let wm_flags = if icase { WM_CASEFOLD } else { 0 };
539    if prefix == patternlen {
540        return patternlen == basenamelen && fspathncmp(pattern, basename, basenamelen, icase);
541    }
542    if (pat_flags & PAT_ENDSWITH) != 0 {
543        if patternlen <= 1 {
544            return false;
545        }
546        let lit_len = patternlen - 1;
547        if lit_len > basenamelen {
548            return false;
549        }
550        return fspathncmp(
551            &pattern[1..patternlen],
552            &basename[basenamelen - lit_len..],
553            lit_len,
554            icase,
555        );
556    }
557    wildmatch(&pattern[..patternlen], basename, wm_flags)
558}
559
560/// Git `match_pathname` (`dir.c`) for attribute patterns.
561#[allow(clippy::too_many_arguments)]
562fn match_pathname_git(
563    pathname: &[u8],
564    pathlen: usize,
565    base: &[u8],
566    baselen: usize,
567    mut pattern: &[u8],
568    mut prefix: usize,
569    mut patternlen: usize,
570    icase: bool,
571) -> bool {
572    let pathname = &pathname[..pathlen.min(pathname.len())];
573
574    if !pattern.is_empty() && pattern[0] == b'/' {
575        pattern = &pattern[1..];
576        patternlen -= 1;
577        prefix = prefix.saturating_sub(1);
578    }
579
580    if pathlen < baselen + 1 {
581        return false;
582    }
583    if baselen > 0 && pathname[baselen] != b'/' {
584        return false;
585    }
586    if !fspathncmp(pathname, base, baselen, icase) {
587        return false;
588    }
589
590    let namelen = if baselen == 0 {
591        pathlen
592    } else {
593        pathlen - baselen - 1
594    };
595    let name = &pathname[pathlen - namelen..];
596
597    if prefix > 0 {
598        if prefix > namelen {
599            return false;
600        }
601        if !fspathncmp(pattern, name, prefix, icase) {
602            return false;
603        }
604        if patternlen == prefix && namelen == prefix {
605            return true;
606        }
607        let advance = prefix - 1;
608        pattern = &pattern[advance..];
609        patternlen -= advance;
610        let name = &name[advance..];
611        let wm_flags = WM_PATHNAME | if icase { WM_CASEFOLD } else { 0 };
612        return wildmatch(&pattern[..patternlen], name, wm_flags);
613    }
614
615    let wm_flags = WM_PATHNAME | if icase { WM_CASEFOLD } else { 0 };
616    wildmatch(&pattern[..patternlen], name, wm_flags)
617}
618
619/// Directory prefix of `rel_path` (no trailing slash), or `""` for a top-level file.
620fn path_dir_prefix(rel_path: &str) -> &str {
621    match rel_path.rfind('/') {
622        Some(i) => &rel_path[..i],
623        None => "",
624    }
625}
626
627/// Whether a rule from `dir/.gitattributes` may apply to `rel_path` (Git `prepare_attr_stack`).
628///
629/// Rules from nested attribute files only affect paths inside that directory tree.
630#[must_use]
631pub fn attr_rule_applies_to_path(attr_base: &str, rel_path: &str, icase: bool) -> bool {
632    if attr_base.is_empty() {
633        return true;
634    }
635    let dir = path_dir_prefix(rel_path);
636    if dir.is_empty() {
637        return false;
638    }
639    let prefix_eq = |d: &str, b: &str| {
640        if icase {
641            d.eq_ignore_ascii_case(b)
642        } else {
643            d == b
644        }
645    };
646    if prefix_eq(dir, attr_base) {
647        return true;
648    }
649    let bl = attr_base.len();
650    if dir.len() > bl && dir.as_bytes()[bl] == b'/' && prefix_eq(&dir[..bl], attr_base) {
651        return true;
652    }
653    false
654}
655
656/// Match one parsed rule against a repo-relative path (Git `path_matches` / `attr.c`).
657#[must_use]
658pub fn attr_rule_matches(rule: &AttrRule, rel_path: &str, icase: bool) -> bool {
659    if !attr_rule_applies_to_path(&rule.attr_base, rel_path, icase) {
660        return false;
661    }
662    let pathname = rel_path.as_bytes();
663    let pathlen = pathname.len();
664    let isdir = pathlen > 0 && pathname[pathlen - 1] == b'/';
665
666    if (rule.pattern_flags & PAT_MUSTBEDIR) != 0 && !isdir {
667        return false;
668    }
669
670    let eff_pathlen = if isdir { pathlen - 1 } else { pathlen };
671    let pathname_trim = &pathname[..eff_pathlen];
672
673    let basename_offset = pathname_trim
674        .iter()
675        .rposition(|&b| b == b'/')
676        .map(|i| i + 1)
677        .unwrap_or(0);
678
679    let pat = rule.pattern.as_bytes();
680    let prefix = rule.nowildcardlen.min(pat.len());
681    let patternlen = pat.len();
682
683    if (rule.pattern_flags & PAT_NODIR) != 0 {
684        let bn = &pathname_trim[basename_offset..];
685        return match_basename_git(bn, pat, prefix, patternlen, rule.pattern_flags, icase);
686    }
687
688    let base = rule.attr_base.as_bytes();
689    match_pathname_git(
690        pathname_trim,
691        eff_pathlen,
692        base,
693        base.len(),
694        pat,
695        prefix,
696        patternlen,
697        icase,
698    )
699}
700
701/// Expand macros and `binary` for one rule's assignments into source-order operations.
702///
703/// These must be applied in order to the same map as later rules (not folded into a local map),
704/// so `!attr` / macro clears remove attributes set by earlier rules on the same path.
705fn expand_rule_attrs_flat(rule: &AttrRule, macros: &MacroTable) -> Vec<(String, AttrValue)> {
706    let mut flat: Vec<(String, AttrValue)> = Vec::new();
707    for (name, val) in &rule.attrs {
708        if name == "binary" {
709            flat.push(("text".into(), AttrValue::Unset));
710            flat.push(("diff".into(), AttrValue::Unset));
711            flat.push(("merge".into(), AttrValue::Unset));
712            flat.push(("binary".into(), AttrValue::Set));
713            continue;
714        }
715        if let Some(exp) = macros.defs.get(name) {
716            flat.push((name.clone(), val.clone()));
717            for (n, v) in exp {
718                flat.push((n.clone(), v.clone()));
719            }
720        } else {
721            flat.push((name.clone(), val.clone()));
722        }
723    }
724    flat
725}
726
727/// Merge assignments: later rules override earlier; within one expanded rule, last wins.
728pub fn collect_attrs_for_path(
729    rules: &[AttrRule],
730    macros: &MacroTable,
731    rel_path: &str,
732    icase: bool,
733) -> HashMap<String, AttrValue> {
734    let mut map: HashMap<String, AttrValue> = HashMap::new();
735    for rule in rules {
736        if rule.skip {
737            continue;
738        }
739        if !attr_rule_matches(rule, rel_path, icase) {
740            continue;
741        }
742        let ops = expand_rule_attrs_flat(rule, macros);
743        for (n, v) in ops {
744            match v {
745                AttrValue::Clear => {
746                    map.remove(&n);
747                }
748                _ => {
749                    map.insert(n, v);
750                }
751            }
752        }
753    }
754    map
755}
756
757/// Quote a path for `check-attr` output (C-style) when needed.
758#[must_use]
759pub fn quote_path_for_check_attr(path: &str) -> String {
760    let needs = path
761        .chars()
762        .any(|c| c.is_control() || c == '"' || c == '\\');
763    if !needs {
764        return path.to_string();
765    }
766    let mut s = String::new();
767    s.push('"');
768    for c in path.chars() {
769        match c {
770            '"' => s.push_str("\\\""),
771            '\\' => s.push_str("\\\\"),
772            _ if c.is_control() => s.push_str(&format!("\\{:o}", c as u32)),
773            _ => s.push(c),
774        }
775    }
776    s.push('"');
777    s
778}
779
780/// Normalize `.` / `..` segments in a repo-relative path string.
781#[must_use]
782pub fn normalize_rel_path(path: &str) -> String {
783    let p = Path::new(path);
784    let mut stack: Vec<String> = Vec::new();
785    for c in p.components() {
786        match c {
787            Component::Normal(s) => stack.push(s.to_string_lossy().into_owned()),
788            Component::ParentDir => {
789                let _ = stack.pop();
790            }
791            Component::CurDir => {}
792            _ => {}
793        }
794    }
795    stack.join("/")
796}
797
798fn lexical_normalize_path(path: PathBuf) -> PathBuf {
799    let mut out = PathBuf::new();
800    for c in path.components() {
801        match c {
802            Component::Prefix(prefix) => out.push(prefix.as_os_str()),
803            Component::RootDir => out.push(c),
804            Component::CurDir => {}
805            Component::ParentDir => {
806                let _ = out.pop();
807            }
808            Component::Normal(_) => out.push(c),
809        }
810    }
811    out
812}
813
814/// Resolve a user path to a repo-relative path (forward slashes).
815///
816/// Uses [`std::fs::canonicalize`] when the target exists; otherwise resolves `..` lexically from the
817/// current directory so paths like `../f` work for missing files (Git `prefix_path`, t0003).
818pub fn path_relative_to_worktree(
819    repo: &Repository,
820    path_str: &str,
821) -> std::result::Result<String, String> {
822    let wt = repo
823        .work_tree
824        .as_ref()
825        .ok_or_else(|| "bare repository — no work tree".to_string())?;
826    let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
827    let p = Path::new(path_str);
828    let combined = if p.is_absolute() {
829        p.to_path_buf()
830    } else {
831        cwd.join(p)
832    };
833
834    let wt_canon = wt.canonicalize().map_err(|e| e.to_string())?;
835
836    if let Ok(abs) = combined.canonicalize() {
837        let rel = abs
838            .strip_prefix(&wt_canon)
839            .map_err(|_| format!("path outside repository: {}", path_str))?;
840        return Ok(normalize_rel_path(
841            rel.to_str().ok_or_else(|| "invalid path".to_string())?,
842        ));
843    }
844
845    let abs_lex = lexical_normalize_path(combined);
846    let rel = abs_lex
847        .strip_prefix(&wt_canon)
848        .map_err(|_| format!("path outside repository: {}", path_str))?;
849    Ok(normalize_rel_path(
850        rel.to_str().ok_or_else(|| "invalid path".to_string())?,
851    ))
852}
853
854fn collect_nested_gitattributes_dirs(work_tree: &Path) -> Vec<PathBuf> {
855    let mut dirs: Vec<PathBuf> = Vec::new();
856    walk_dirs(work_tree, work_tree, &mut dirs);
857    dirs.sort_by(|a, b| {
858        let da = a.components().count();
859        let db = b.components().count();
860        da.cmp(&db).then_with(|| a.cmp(b))
861    });
862    dirs
863}
864
865fn walk_dirs(root: &Path, cur: &Path, dirs: &mut Vec<PathBuf>) {
866    let Ok(rd) = fs::read_dir(cur) else {
867        return;
868    };
869    for e in rd.flatten() {
870        let p = e.path();
871        let ft = e.file_type().ok();
872        if ft.is_some_and(|t| t.is_dir()) {
873            if p.file_name() == Some(OsStr::new(".git")) {
874                continue;
875            }
876            let rel = p.strip_prefix(root).unwrap_or(&p);
877            dirs.push(rel.to_path_buf());
878            walk_dirs(root, &p, dirs);
879        }
880    }
881}
882
883// ── Process-lifetime gitattributes cache ─────────────────────────────
884//
885// `load_gitattributes_stack` re-walks the entire working tree (`read_dir`
886// per directory) and re-parses every `.gitattributes` on each call, and hot
887// paths (grep/diff/add/checkout) call it per file. The parsed stack is
888// memoized for the process lifetime and revalidated with stat stamps on
889// every call:
890//
891// - the global attributes file, root `.gitattributes`, and
892//   `info/attributes` are stamped (mtime + size, or "absent"), recorded
893//   *before* the parse;
894// - the work-tree root directory is mtime-stamped, so creating or deleting
895//   a root-level entry forces a re-walk. Nested `.gitattributes` files are
896//   *not* revalidated per query (see `collect_stack_stamps`); within one
897//   process they behave like C git's process-lifetime attribute cache.
898//
899// Tree-sourced stacks (`attr.tree` / `GIT_ATTR_SOURCE`) are keyed by tree
900// OID and never revalidated: tree objects are content-addressed and
901// immutable.
902//
903// The resolved global-attributes *path* (from `core.attributesFile`) is
904// recorded at parse time and only re-statted afterwards; a mid-process
905// change to that config value is not detected. C git caches the attribute
906// stack per directory for the whole process with no revalidation at all,
907// so serving a stamped copy is strictly more conservative than upstream.
908
909type AttrFileStamp = (PathBuf, Option<(SystemTime, u64)>);
910type AttrDirStamp = (PathBuf, Option<SystemTime>);
911
912struct AttrStackCacheEntry {
913    file_stamps: Vec<AttrFileStamp>,
914    dir_stamps: Vec<AttrDirStamp>,
915    parsed: Arc<ParsedGitAttributes>,
916}
917
918fn attr_stack_cache() -> &'static Mutex<HashMap<(PathBuf, PathBuf), AttrStackCacheEntry>> {
919    static CACHE: OnceLock<Mutex<HashMap<(PathBuf, PathBuf), AttrStackCacheEntry>>> =
920        OnceLock::new();
921    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
922}
923
924fn attr_bare_cache() -> &'static Mutex<HashMap<PathBuf, AttrStackCacheEntry>> {
925    static CACHE: OnceLock<Mutex<HashMap<PathBuf, AttrStackCacheEntry>>> = OnceLock::new();
926    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
927}
928
929fn attr_tree_cache() -> &'static Mutex<HashMap<ObjectId, Arc<ParsedGitAttributes>>> {
930    static CACHE: OnceLock<Mutex<HashMap<ObjectId, Arc<ParsedGitAttributes>>>> = OnceLock::new();
931    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
932}
933
934/// `symlink_metadata`-based stamp, matching `read_gitattributes_maybe_symlink`
935/// (symlinked `.gitattributes` files are skipped by the parser, but stamping
936/// the link still detects replacement by a regular file).
937fn attr_file_stamp(path: &Path) -> Option<(SystemTime, u64)> {
938    fs::symlink_metadata(path)
939        .ok()
940        .and_then(|m| Some((m.modified().ok()?, m.len())))
941}
942
943fn attr_dir_stamp(path: &Path) -> Option<SystemTime> {
944    fs::symlink_metadata(path)
945        .ok()
946        .and_then(|m| m.modified().ok())
947}
948
949fn attr_stamps_valid(entry: &AttrStackCacheEntry) -> bool {
950    entry
951        .file_stamps
952        .iter()
953        .all(|(path, stamp)| attr_file_stamp(path) == *stamp)
954        && entry
955            .dir_stamps
956            .iter()
957            .all(|(path, stamp)| attr_dir_stamp(path) == *stamp)
958}
959
960/// Stamp the cheap top-level inputs of the stack: the global attributes
961/// file, root `.gitattributes`, `info/attributes`, and the work-tree root
962/// directory's mtime (~4 stats per validation).
963///
964/// Nested per-directory `.gitattributes` files are deliberately *not*
965/// stamped: revalidating them costs two stats per walked directory, which
966/// dominates per-file hot loops on large trees. Within one process a change
967/// to an already-loaded nested file is therefore served stale — matching
968/// C git, which caches attribute stacks for the whole process with no
969/// revalidation at all. The checkout/apply/merge materialization paths are
970/// unaffected: they read attributes through
971/// `crlf::load_gitattributes_for_checkout` (index/odb-sourced), not this
972/// work-tree stack. Creating or deleting entries in the work-tree *root*
973/// still bumps its stamped mtime and forces a fresh walk.
974fn collect_stack_stamps(
975    repo: &Repository,
976    work_tree: &Path,
977) -> std::result::Result<(Vec<AttrFileStamp>, Vec<AttrDirStamp>), crate::error::Error> {
978    let mut file_stamps = Vec::new();
979    if let Some(g) = global_attributes_path(repo)? {
980        let stamp = attr_file_stamp(&g);
981        file_stamps.push((g, stamp));
982    }
983    let root_ga = work_tree.join(".gitattributes");
984    let stamp = attr_file_stamp(&root_ga);
985    file_stamps.push((root_ga, stamp));
986    let info = repo.git_dir.join("info/attributes");
987    let stamp = attr_file_stamp(&info);
988    file_stamps.push((info, stamp));
989    let dir_stamps = vec![(work_tree.to_path_buf(), attr_dir_stamp(work_tree))];
990    Ok((file_stamps, dir_stamps))
991}
992
993/// Load the full stack of attribute rules for a normal repository (working tree).
994///
995/// Results are memoized for the process lifetime and revalidated against
996/// stat stamps on every call (see the cache notes above).
997pub fn load_gitattributes_stack(
998    repo: &Repository,
999    work_tree: &Path,
1000) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1001    let key = (repo.git_dir.clone(), work_tree.to_path_buf());
1002    {
1003        let cache = attr_stack_cache()
1004            .lock()
1005            .unwrap_or_else(std::sync::PoisonError::into_inner);
1006        if let Some(entry) = cache.get(&key) {
1007            if attr_stamps_valid(entry) {
1008                return Ok((*entry.parsed).clone());
1009            }
1010        }
1011    }
1012    let (file_stamps, dir_stamps) = collect_stack_stamps(repo, work_tree)?;
1013    let parsed = load_gitattributes_stack_uncached(repo, work_tree)?;
1014    let mut cache = attr_stack_cache()
1015        .lock()
1016        .unwrap_or_else(std::sync::PoisonError::into_inner);
1017    cache.insert(
1018        key,
1019        AttrStackCacheEntry {
1020            file_stamps,
1021            dir_stamps,
1022            parsed: Arc::new(parsed.clone()),
1023        },
1024    );
1025    Ok(parsed)
1026}
1027
1028fn load_gitattributes_stack_uncached(
1029    repo: &Repository,
1030    work_tree: &Path,
1031) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1032    let mut merged = ParsedGitAttributes::default();
1033
1034    if let Some(g) = global_attributes_path(repo)? {
1035        if g.exists() {
1036            if let Ok(content) = fs::read_to_string(&g) {
1037                if content.len() <= MAX_ATTR_FILE_BYTES {
1038                    let mut p =
1039                        parse_gitattributes_file_content(&content, g.to_string_lossy().as_ref());
1040                    merged.rules.append(&mut p.rules);
1041                    merged.macros.defs.extend(p.macros.defs.drain());
1042                    merged.warnings.append(&mut p.warnings);
1043                } else {
1044                    merged.warnings.push(format!(
1045                        "warning: ignoring overly large gitattributes file '{}'",
1046                        g.display()
1047                    ));
1048                }
1049            }
1050        }
1051    }
1052
1053    let root_ga = work_tree.join(".gitattributes");
1054    if let Some(content) =
1055        read_gitattributes_maybe_symlink(&root_ga, ".gitattributes", &mut merged.warnings)
1056    {
1057        if content.len() <= MAX_ATTR_FILE_BYTES {
1058            let mut p = parse_gitattributes_file_content(&content, ".gitattributes");
1059            merged.rules.append(&mut p.rules);
1060            merged.macros.defs.extend(p.macros.defs.drain());
1061            merged.warnings.append(&mut p.warnings);
1062        } else {
1063            merged.warnings.push(
1064                "warning: ignoring overly large gitattributes file '.gitattributes'".to_string(),
1065            );
1066        }
1067    }
1068
1069    for rel in collect_nested_gitattributes_dirs(work_tree) {
1070        let ga = work_tree.join(&rel).join(".gitattributes");
1071        if let Some(content) = read_gitattributes_maybe_symlink(
1072            &ga,
1073            &format!("{}/.gitattributes", rel.display()),
1074            &mut merged.warnings,
1075        ) {
1076            if content.len() > MAX_ATTR_FILE_BYTES {
1077                merged.warnings.push(format!(
1078                    "warning: ignoring overly large gitattributes file '{}'",
1079                    ga.display()
1080                ));
1081                continue;
1082            }
1083            let prefix = rel.to_string_lossy().replace('\\', "/");
1084            let mut p = parse_gitattributes_file_content_with_base(
1085                &content,
1086                &ga.to_string_lossy(),
1087                &prefix,
1088            );
1089            merged.rules.append(&mut p.rules);
1090            merged.macros.defs.extend(p.macros.defs.drain());
1091            merged.warnings.append(&mut p.warnings);
1092        }
1093    }
1094
1095    let info = repo.git_dir.join("info/attributes");
1096    if info.exists() {
1097        if let Ok(content) = fs::read_to_string(&info) {
1098            if content.len() <= MAX_ATTR_FILE_BYTES {
1099                let mut p = parse_gitattributes_file_content(&content, "info/attributes");
1100                merged.rules.append(&mut p.rules);
1101                merged.macros.defs.extend(p.macros.defs.drain());
1102                merged.warnings.append(&mut p.warnings);
1103            }
1104        }
1105    }
1106
1107    Ok(merged)
1108}
1109
1110/// Bare repository: only `info/attributes` from disk (no in-repo `.gitattributes` file).
1111///
1112/// Memoized like [`load_gitattributes_stack`], keyed by `git_dir`.
1113pub fn load_gitattributes_bare(
1114    repo: &Repository,
1115) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1116    let key = repo.git_dir.clone();
1117    {
1118        let cache = attr_bare_cache()
1119            .lock()
1120            .unwrap_or_else(std::sync::PoisonError::into_inner);
1121        if let Some(entry) = cache.get(&key) {
1122            if attr_stamps_valid(entry) {
1123                return Ok((*entry.parsed).clone());
1124            }
1125        }
1126    }
1127    let mut file_stamps = Vec::new();
1128    if let Some(g) = global_attributes_path(repo)? {
1129        let stamp = attr_file_stamp(&g);
1130        file_stamps.push((g, stamp));
1131    }
1132    let info = repo.git_dir.join("info/attributes");
1133    let stamp = attr_file_stamp(&info);
1134    file_stamps.push((info, stamp));
1135    let parsed = load_gitattributes_bare_uncached(repo)?;
1136    let mut cache = attr_bare_cache()
1137        .lock()
1138        .unwrap_or_else(std::sync::PoisonError::into_inner);
1139    cache.insert(
1140        key,
1141        AttrStackCacheEntry {
1142            file_stamps,
1143            dir_stamps: Vec::new(),
1144            parsed: Arc::new(parsed.clone()),
1145        },
1146    );
1147    Ok(parsed)
1148}
1149
1150fn load_gitattributes_bare_uncached(
1151    repo: &Repository,
1152) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1153    let mut merged = ParsedGitAttributes::default();
1154    if let Some(g) = global_attributes_path(repo)? {
1155        if g.exists() {
1156            if let Ok(content) = fs::read_to_string(&g) {
1157                if content.len() <= MAX_ATTR_FILE_BYTES {
1158                    let mut p =
1159                        parse_gitattributes_file_content(&content, g.to_string_lossy().as_ref());
1160                    merged.rules.append(&mut p.rules);
1161                    merged.macros.defs.extend(p.macros.defs.drain());
1162                    merged.warnings.append(&mut p.warnings);
1163                }
1164            }
1165        }
1166    }
1167    let info = repo.git_dir.join("info/attributes");
1168    if info.exists() {
1169        if let Ok(content) = fs::read_to_string(&info) {
1170            if content.len() <= MAX_ATTR_FILE_BYTES {
1171                let mut p = parse_gitattributes_file_content(&content, "info/attributes");
1172                merged.rules.append(&mut p.rules);
1173                merged.macros.defs.extend(p.macros.defs.drain());
1174                merged.warnings.append(&mut p.warnings);
1175            }
1176        }
1177    }
1178    // Without a work tree, Git reads tracked `.gitattributes` from the index (Git
1179    // `read_attr_from_index`), so e.g. `git -C .git diff-tree --check` still honours a
1180    // committed `* -whitespace` attribute. Prepend index rules so work-tree-equivalent
1181    // ordering (closer paths win) is preserved relative to info/global.
1182    if let Ok(index) = Index::load(&repo.git_dir.join("index")) {
1183        if let Ok(mut from_index) = load_gitattributes_from_index(&index, &repo.odb, &repo.git_dir)
1184        {
1185            // info/global attributes are lower priority than per-tree `.gitattributes`,
1186            // so place the index rules ahead of what we have collected so far.
1187            from_index.rules.append(&mut merged.rules);
1188            merged.rules = from_index.rules;
1189            for (k, v) in from_index.macros.defs.drain() {
1190                merged.macros.defs.entry(k).or_insert(v);
1191            }
1192            merged.warnings.append(&mut from_index.warnings);
1193        }
1194    }
1195    Ok(merged)
1196}
1197
1198/// Read `.gitattributes` blob from a tree object at `tree_oid`, recursively.
1199pub fn load_gitattributes_from_tree(
1200    odb: &Odb,
1201    tree_oid: &ObjectId,
1202) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1203    // Tree objects are content-addressed and immutable: no revalidation.
1204    {
1205        let cache = attr_tree_cache()
1206            .lock()
1207            .unwrap_or_else(std::sync::PoisonError::into_inner);
1208        if let Some(parsed) = cache.get(tree_oid) {
1209            return Ok((**parsed).clone());
1210        }
1211    }
1212    let mut merged = ParsedGitAttributes::default();
1213    walk_tree_attrs(odb, tree_oid, "", &mut merged)?;
1214    let mut cache = attr_tree_cache()
1215        .lock()
1216        .unwrap_or_else(std::sync::PoisonError::into_inner);
1217    cache.insert(*tree_oid, Arc::new(merged.clone()));
1218    Ok(merged)
1219}
1220
1221fn walk_tree_attrs(
1222    odb: &Odb,
1223    tree_oid: &ObjectId,
1224    prefix: &str,
1225    merged: &mut ParsedGitAttributes,
1226) -> std::result::Result<(), crate::error::Error> {
1227    let obj = odb.read(tree_oid)?;
1228    if obj.kind != ObjectKind::Tree {
1229        return Ok(());
1230    }
1231    let entries = parse_tree(&obj.data)?;
1232    for e in entries {
1233        let name = String::from_utf8_lossy(&e.name).to_string();
1234        let path = if prefix.is_empty() {
1235            name.clone()
1236        } else {
1237            format!("{prefix}/{name}")
1238        };
1239        match e.mode {
1240            0o040000 => {
1241                walk_tree_attrs(odb, &e.oid, &path, merged)?;
1242            }
1243            0o100644 | 0o100755 | 0o120000 if name == ".gitattributes" => {
1244                let oid = e.oid;
1245                {
1246                    let blob = odb.read(&oid)?;
1247                    if blob.kind != ObjectKind::Blob {
1248                        continue;
1249                    }
1250                    if blob.data.len() > MAX_ATTR_FILE_BYTES {
1251                        merged.warnings.push(
1252                            "warning: ignoring overly large gitattributes blob '.gitattributes'"
1253                                .to_string(),
1254                        );
1255                        continue;
1256                    }
1257                    let content = String::from_utf8_lossy(&blob.data).into_owned();
1258                    let display = format!("{path} (tree)");
1259                    let attr_base = Path::new(&path)
1260                        .parent()
1261                        .map(|p| p.to_string_lossy().replace('\\', "/"))
1262                        .unwrap_or_default();
1263                    let mut p =
1264                        parse_gitattributes_content_impl(&content, &display, true, &attr_base);
1265                    merged.rules.append(&mut p.rules);
1266                    merged.macros.defs.extend(p.macros.defs.drain());
1267                    merged.warnings.append(&mut p.warnings);
1268                }
1269            }
1270            _ => {}
1271        }
1272    }
1273    Ok(())
1274}
1275
1276/// Load merged `.gitattributes` rules for diff and merge (respects `GIT_ATTR_SOURCE` / `attr.tree`).
1277///
1278/// Resolution order matches Git's attribute source for diff: optional tree from
1279/// [`resolve_attr_treeish`], then work tree stack (or bare `info/attributes` only).
1280///
1281/// # Errors
1282///
1283/// Returns an error when a tree-ish source is set from the environment or command line and cannot
1284/// be resolved (Git: *"bad --attr-source or GIT_ATTR_SOURCE"*).
1285pub fn load_gitattributes_for_diff(
1286    repo: &Repository,
1287) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1288    let (treeish, ignore_bad_tree) = resolve_attr_treeish(repo, None)?;
1289    if let Some(spec) = treeish.filter(|s| !s.is_empty()) {
1290        match resolve_tree_oid(repo, &spec) {
1291            Ok(oid) => return load_gitattributes_from_tree(&repo.odb, &oid),
1292            Err(_) if ignore_bad_tree => {}
1293            Err(_) => {
1294                return Err(crate::error::Error::InvalidRef(format!(
1295                    "bad --attr-source or GIT_ATTR_SOURCE: {spec}"
1296                )));
1297            }
1298        }
1299    }
1300    if let Some(wt) = repo.work_tree.as_deref() {
1301        return load_gitattributes_stack(repo, wt);
1302    }
1303    load_gitattributes_bare(repo)
1304}
1305
1306/// Resolve `attr.tree`, `GIT_ATTR_SOURCE`, `--source` precedence for check-attr.
1307///
1308/// The second return value is `ignore_bad_resolution`: when true (only for `attr.tree` from
1309/// config), an unresolvable tree-ish falls back to reading `.gitattributes` from the work tree
1310/// or index instead of erroring (matches Git `compute_default_attr_source`).
1311pub fn resolve_attr_treeish(
1312    repo: &Repository,
1313    source_arg: Option<&str>,
1314) -> std::result::Result<(Option<String>, bool), crate::error::Error> {
1315    let env_src = std::env::var("GIT_ATTR_SOURCE")
1316        .ok()
1317        .filter(|s| !s.is_empty());
1318    let config = ConfigSet::load(Some(&repo.git_dir), true)?;
1319    let cfg_tree = config.get("attr.tree");
1320    if let Some(s) = source_arg.map(|s| s.to_string()) {
1321        return Ok((Some(s), false));
1322    }
1323    if let Some(s) = env_src {
1324        return Ok((Some(s), false));
1325    }
1326    if let Some(s) = cfg_tree {
1327        return Ok((Some(s), true));
1328    }
1329    Ok((None, false))
1330}
1331
1332/// Parse a revision to a tree OID for attribute loading.
1333pub fn resolve_tree_oid(repo: &Repository, spec: &str) -> std::result::Result<ObjectId, String> {
1334    let oid = resolve_revision(repo, spec).map_err(|e| e.to_string())?;
1335    let obj = repo.read_replaced(&oid).map_err(|e| e.to_string())?;
1336    match obj.kind {
1337        ObjectKind::Commit => {
1338            let c = crate::objects::parse_commit(&obj.data).map_err(|e| e.to_string())?;
1339            Ok(c.tree)
1340        }
1341        ObjectKind::Tree => Ok(oid),
1342        _ => Err("revision is not a commit or tree".to_string()),
1343    }
1344}
1345
1346/// Load attributes from the index (stage 0) for `.gitattributes` paths only.
1347pub fn load_gitattributes_from_index(
1348    index: &Index,
1349    odb: &Odb,
1350    work_tree: &Path,
1351) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
1352    let mut merged = ParsedGitAttributes::default();
1353    let mut paths: Vec<Vec<u8>> = index
1354        .entries
1355        .iter()
1356        .filter(|e| e.stage() == 0 && e.path.ends_with(b".gitattributes"))
1357        .map(|e| e.path.clone())
1358        .collect();
1359    paths.sort();
1360    for path_bytes in paths {
1361        let Ok(rel) = std::str::from_utf8(&path_bytes) else {
1362            continue;
1363        };
1364        let Some(entry) = index.get(&path_bytes, 0) else {
1365            continue;
1366        };
1367        let obj = odb.read(&entry.oid)?;
1368        if obj.data.len() > MAX_ATTR_FILE_BYTES {
1369            merged.warnings.push(format!(
1370                "warning: ignoring overly large gitattributes blob '{}'",
1371                rel
1372            ));
1373            continue;
1374        }
1375        let content = String::from_utf8_lossy(&obj.data);
1376        let attr_base = Path::new(rel)
1377            .parent()
1378            .map(|p| p.to_string_lossy().replace('\\', "/"))
1379            .unwrap_or_default();
1380        let mut p = parse_gitattributes_content_impl(&content, rel, true, &attr_base);
1381        merged.rules.append(&mut p.rules);
1382        merged.macros.defs.extend(p.macros.defs.drain());
1383        merged.warnings.append(&mut p.warnings);
1384    }
1385    let _ = work_tree;
1386    Ok(merged)
1387}
1388
1389/// Return `builtin_objectmode` value for a path (working tree), or `None` if unavailable.
1390///
1391/// Submodule checkout directories (`.git` is a file containing `gitdir:`) report `160000`
1392/// like Git, not `040000`.
1393#[must_use]
1394pub fn builtin_objectmode_worktree(repo: &Repository, rel_path: &str) -> Option<String> {
1395    let wt = repo.work_tree.as_ref()?;
1396    let p = wt.join(rel_path);
1397    let meta = fs::symlink_metadata(&p).ok()?;
1398    let ft = meta.file_type();
1399    if ft.is_symlink() {
1400        return Some("120000".to_string());
1401    }
1402    if ft.is_dir() {
1403        let git = p.join(".git");
1404        if let Ok(git_meta) = fs::symlink_metadata(&git) {
1405            if !git_meta.file_type().is_dir() {
1406                if let Ok(content) = fs::read_to_string(&git) {
1407                    if content.starts_with("gitdir:") {
1408                        return Some("160000".to_string());
1409                    }
1410                }
1411            }
1412        }
1413        return Some("040000".to_string());
1414    }
1415    #[cfg(unix)]
1416    {
1417        use std::os::unix::fs::MetadataExt;
1418        let m = normalize_mode(meta.mode());
1419        Some(format!("{:06o}", m))
1420    }
1421    #[cfg(not(unix))]
1422    {
1423        let _ = repo;
1424        None
1425    }
1426}
1427
1428/// `builtin_objectmode` from the index when `--cached` is used.
1429#[must_use]
1430pub fn builtin_objectmode_index(index: &Index, rel_path: &str) -> Option<String> {
1431    let key = rel_path.as_bytes();
1432    let e = index.get(key, 0)?;
1433    let m = e.mode;
1434    if m == MODE_SYMLINK {
1435        return Some("120000".to_string());
1436    }
1437    if m == MODE_GITLINK {
1438        return Some("160000".to_string());
1439    }
1440    if m == MODE_TREE {
1441        return Some("040000".to_string());
1442    }
1443    if m == MODE_EXECUTABLE {
1444        return Some("100755".to_string());
1445    }
1446    if m == MODE_REGULAR {
1447        return Some("100644".to_string());
1448    }
1449    Some(format!("{:06o}", m))
1450}
1451
1452#[cfg(test)]
1453mod tests {
1454    use super::*;
1455
1456    #[test]
1457    fn d_yes_rule_clears_test_after_d_star() {
1458        let mut merged = ParsedGitAttributes::default();
1459        let root = parse_gitattributes_file_content("[attr]notest !test\n", ".gitattributes");
1460        merged.macros.defs.extend(root.macros.defs);
1461        let mut ab = parse_gitattributes_file_content_with_base(
1462            "h test=a/b/h\nd/* test=a/b/d/*\nd/yes notest\n",
1463            "a/b/.gitattributes",
1464            "a/b",
1465        );
1466        assert_eq!(ab.rules.len(), 3);
1467        merged.rules.append(&mut ab.rules);
1468        merged.macros.defs.extend(ab.macros.defs);
1469        let d_yes = merged
1470            .rules
1471            .iter()
1472            .find(|r| r.pattern == "d/yes")
1473            .expect("d/yes rule");
1474        assert!(attr_rule_matches(d_yes, "a/b/d/yes", false));
1475        let m = collect_attrs_for_path(&merged.rules, &merged.macros, "a/b/d/yes", false);
1476        assert!(
1477            m.get("test").is_none(),
1478            "expected test cleared by notest macro, got {:?}",
1479            m.get("test")
1480        );
1481    }
1482}
1483
1484#[cfg(test)]
1485mod attr_cache_tests {
1486    use super::*;
1487    use filetime::FileTime;
1488
1489    fn test_repo(td: &Path) -> Repository {
1490        crate::repo::init_repository(td, false, "main", None, "files").expect("init repo")
1491    }
1492
1493    fn rules_for(repo: &Repository, wt: &Path) -> Vec<String> {
1494        let parsed = load_gitattributes_stack(repo, wt).expect("load stack");
1495        parsed.rules.iter().map(|r| r.pattern.clone()).collect()
1496    }
1497
1498    fn mtime_of(path: &Path) -> FileTime {
1499        FileTime::from_last_modification_time(&fs::symlink_metadata(path).expect("stat"))
1500    }
1501
1502    fn restore_mtime(path: &Path, stamp: FileTime) {
1503        filetime::set_file_mtime(path, stamp).expect("restore mtime");
1504    }
1505
1506    #[test]
1507    fn stack_cache_serves_same_stamp_and_invalidates_on_change() {
1508        let td = tempfile::tempdir().expect("tempdir");
1509        let wt = td.path();
1510        let repo = test_repo(wt);
1511        let ga = wt.join(".gitattributes");
1512        fs::write(&ga, "*.aaa text\n").expect("write v1");
1513        let wt_t0 = mtime_of(wt);
1514        restore_mtime(wt, wt_t0);
1515        let t0 = mtime_of(&ga);
1516        assert_eq!(rules_for(&repo, wt), vec!["*.aaa".to_string()]);
1517
1518        // Same size + restored mtime (file and work-tree dir): stat cannot
1519        // tell the difference, so the cached parse is served. This is the
1520        // assertion that proves the cache is actually used.
1521        fs::write(&ga, "*.bbb text\n").expect("write v2");
1522        restore_mtime(&ga, t0);
1523        restore_mtime(wt, wt_t0);
1524        assert_eq!(rules_for(&repo, wt), vec!["*.aaa".to_string()]);
1525
1526        // A size change invalidates even with restored mtimes.
1527        fs::write(&ga, "*.ccc-longer text\n").expect("write v3");
1528        restore_mtime(&ga, t0);
1529        restore_mtime(wt, wt_t0);
1530        assert_eq!(rules_for(&repo, wt), vec!["*.ccc-longer".to_string()]);
1531    }
1532
1533    #[test]
1534    fn new_nested_gitattributes_is_detected() {
1535        let td = tempfile::tempdir().expect("tempdir");
1536        let wt = td.path();
1537        let repo = test_repo(wt);
1538        fs::write(wt.join(".gitattributes"), "root-rule text\n").expect("write root");
1539        // Pre-age the work-tree mtime so the upcoming mkdir visibly bumps it
1540        // even on filesystems with coarse mtime ticks.
1541        restore_mtime(wt, FileTime::from_unix_time(1_000_000_000, 0));
1542        assert_eq!(rules_for(&repo, wt), vec!["root-rule".to_string()]);
1543
1544        // Creating a subdirectory bumps the stamped work-tree mtime, forcing
1545        // a re-walk that discovers the new nested file.
1546        fs::create_dir(wt.join("sub")).expect("mkdir");
1547        fs::write(wt.join("sub/.gitattributes"), "nested-rule text\n").expect("write nested");
1548        assert_eq!(
1549            rules_for(&repo, wt),
1550            vec!["root-rule".to_string(), "nested-rule".to_string()]
1551        );
1552    }
1553
1554    #[test]
1555    fn modified_nested_gitattributes_follows_c_git_process_semantics() {
1556        let td = tempfile::tempdir().expect("tempdir");
1557        let wt = td.path();
1558        let repo = test_repo(wt);
1559        fs::create_dir(wt.join("sub")).expect("mkdir");
1560        let nested = wt.join("sub/.gitattributes");
1561        fs::write(&nested, "one text\n").expect("write v1");
1562        // Pre-age the work-tree mtime so the later root-level mkdir visibly
1563        // bumps it even on filesystems with coarse mtime ticks.
1564        restore_mtime(wt, FileTime::from_unix_time(1_000_000_000, 0));
1565        assert_eq!(rules_for(&repo, wt), vec!["one".to_string()]);
1566
1567        // Nested files are not revalidated per query: within one process a
1568        // content edit is served from cache, matching C git's
1569        // process-lifetime attribute caching.
1570        fs::write(&nested, "two-longer text\n").expect("write v2");
1571        assert_eq!(rules_for(&repo, wt), vec!["one".to_string()]);
1572
1573        // Any root-level signal (here: a new top-level directory) bumps the
1574        // stamped work-tree mtime and the fresh walk picks up the edit.
1575        fs::create_dir(wt.join("poke")).expect("mkdir poke");
1576        assert_eq!(rules_for(&repo, wt), vec!["two-longer".to_string()]);
1577    }
1578
1579    #[test]
1580    fn info_attributes_is_stamped() {
1581        let td = tempfile::tempdir().expect("tempdir");
1582        let wt = td.path();
1583        let repo = test_repo(wt);
1584        assert!(rules_for(&repo, wt).is_empty());
1585
1586        // info/attributes appearing after a cached empty load must be seen.
1587        fs::write(repo.git_dir.join("info/attributes"), "from-info text\n").expect("write info");
1588        assert_eq!(rules_for(&repo, wt), vec!["from-info".to_string()]);
1589    }
1590}