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::index::normalize_mode;
10use crate::index::Index;
11use crate::index::MODE_EXECUTABLE;
12use crate::index::MODE_GITLINK;
13use crate::index::MODE_REGULAR;
14use crate::index::MODE_SYMLINK;
15use crate::index::MODE_TREE;
16use crate::objects::parse_tree;
17use crate::objects::ObjectId;
18use crate::objects::ObjectKind;
19use crate::odb::Odb;
20use crate::repo::Repository;
21use crate::rev_parse::resolve_revision;
22use crate::wildmatch::{wildmatch, WM_CASEFOLD, WM_PATHNAME};
23use std::collections::HashMap;
24use std::ffi::OsStr;
25use std::fs;
26use std::path::{Component, Path, PathBuf};
27
28/// Maximum length of a single `.gitattributes` line (bytes), matching Git.
29pub const MAX_ATTR_LINE_BYTES: usize = 2048;
30
31/// Maximum `.gitattributes` file size (bytes) before Git ignores the file.
32pub const MAX_ATTR_FILE_BYTES: usize = 100 * 1024 * 1024;
33
34/// Parsed attribute value for display (`check-attr` output).
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum AttrValue {
37    Set,
38    /// Explicit `-attr` in a rule — `check-attr` prints `unset`.
39    Unset,
40    /// Macro body `!attr` — clears the attribute to *unspecified* (not `unset`).
41    Clear,
42    Value(String),
43}
44
45impl AttrValue {
46    /// Text form as printed by `git check-attr`.
47    #[must_use]
48    pub fn display(&self) -> &str {
49        match self {
50            AttrValue::Set => "set",
51            AttrValue::Unset => "unset",
52            AttrValue::Clear => "unspecified",
53            AttrValue::Value(v) => v.as_str(),
54        }
55    }
56}
57
58/// One line in a gitattributes file.
59#[derive(Debug, Clone)]
60pub struct AttrRule {
61    /// Normalized pattern (repo-relative, `/` separators).
62    pub pattern: String,
63    /// If true, this rule was discarded (negative pattern) after emitting a warning.
64    pub skip: bool,
65    /// 1-based line number in the source file.
66    pub line: usize,
67    /// Attribute assignments in source order (last wins for duplicates on this line).
68    pub attrs: Vec<(String, AttrValue)>,
69}
70
71/// Macro definitions from `[attr]name ...` lines.
72#[derive(Debug, Clone, Default)]
73pub struct MacroTable {
74    /// Maps macro name → list of assignments (e.g. `!test` → unset test).
75    pub defs: HashMap<String, Vec<(String, AttrValue)>>,
76}
77
78/// Result of parsing a gitattributes file.
79#[derive(Debug, Default)]
80pub struct ParsedGitAttributes {
81    pub rules: Vec<AttrRule>,
82    pub macros: MacroTable,
83    pub warnings: Vec<String>,
84}
85
86/// Returns true if `name` is reserved (`builtin_*` except the real builtin names Git allows).
87#[must_use]
88pub fn is_reserved_builtin_name(name: &str) -> bool {
89    let Some(rest) = name.strip_prefix("builtin_") else {
90        return false;
91    };
92    matches!(rest, "objectmode")
93}
94
95/// Validate user-defined attribute names in parsed rules (for `git add`).
96///
97/// Returns an error string matching Git when a rule uses an invalid `builtin_*` name.
98pub fn validate_rules_for_add(
99    rules: &[AttrRule],
100    display_path: &str,
101) -> std::result::Result<(), String> {
102    for rule in rules {
103        if rule.skip {
104            continue;
105        }
106        for (name, _) in &rule.attrs {
107            if name.starts_with("builtin_") && !is_reserved_builtin_name(name) {
108                return Err(format!(
109                    "{name} is not a valid attribute name: {display_path}:{}",
110                    rule.line
111                ));
112            }
113        }
114    }
115    Ok(())
116}
117
118/// Collect warnings for invalid `builtin_*` assignments (check-attr continues).
119pub fn builtin_warnings_for_rules(rules: &[AttrRule], display_path: &str) -> Vec<String> {
120    let mut w = Vec::new();
121    for rule in rules {
122        if rule.skip {
123            continue;
124        }
125        for (name, _) in &rule.attrs {
126            if name == "builtin_objectmode" {
127                w.push(format!(
128                    "builtin_objectmode is not a valid attribute name: {display_path}:{}",
129                    rule.line
130                ));
131            } else if name.starts_with("builtin_") && !is_reserved_builtin_name(name) {
132                w.push(format!(
133                    "{name} is not a valid attribute name: {display_path}:{}",
134                    rule.line
135                ));
136            }
137        }
138    }
139    w
140}
141
142fn default_global_attributes_path() -> Option<PathBuf> {
143    let home = std::env::var("HOME").ok()?;
144    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
145        if !xdg.is_empty() {
146            return Some(PathBuf::from(xdg).join("git/attributes"));
147        }
148    }
149    Some(PathBuf::from(home).join(".config/git/attributes"))
150}
151
152fn global_attributes_path(
153    repo: &Repository,
154) -> std::result::Result<Option<PathBuf>, crate::error::Error> {
155    let config = ConfigSet::load(Some(&repo.git_dir), true)?;
156    if let Some(path) = config.get("core.attributesfile") {
157        return Ok(Some(PathBuf::from(parse_path(&path))));
158    }
159    Ok(default_global_attributes_path())
160}
161
162/// Read a `.gitattributes` path; if it is a symlink, record an error and skip (in-tree rules).
163fn read_gitattributes_maybe_symlink(
164    path: &Path,
165    display: &str,
166    warnings: &mut Vec<String>,
167) -> Option<String> {
168    let meta = fs::symlink_metadata(path).ok()?;
169    if meta.file_type().is_symlink() {
170        warnings.push(format!(
171            "unable to access '{display}': Too many levels of symbolic links"
172        ));
173        return None;
174    }
175    fs::read_to_string(path).ok()
176}
177
178/// Parse one gitattributes file from disk (follows symlinks only when reading global/info).
179pub fn parse_gitattributes_file_content(content: &str, display_path: &str) -> ParsedGitAttributes {
180    parse_gitattributes_content_impl(content, display_path, false)
181}
182
183fn parse_gitattributes_content_impl(
184    content: &str,
185    display_path: &str,
186    from_blob: bool,
187) -> ParsedGitAttributes {
188    let mut out = ParsedGitAttributes::default();
189    for (idx, raw_line) in content.lines().enumerate() {
190        let line_no = idx + 1;
191        let line_bytes = raw_line.as_bytes();
192        if line_bytes.len() > MAX_ATTR_LINE_BYTES {
193            out.warnings.push(format!(
194                "warning: ignoring overly long attributes line {line_no}"
195            ));
196            continue;
197        }
198        parse_one_line(raw_line, line_no, display_path, from_blob, &mut out);
199    }
200    out.warnings
201        .extend(builtin_warnings_for_rules(&out.rules, display_path));
202    out
203}
204
205/// Skip leading ASCII blanks only (matches Git's `blank` in `attr.c`).
206fn skip_ascii_blank(s: &str) -> &str {
207    s.trim_start_matches([' ', '\t', '\r', '\n'])
208}
209
210/// First whitespace-delimited token and the remainder (Git `strcspn` on `blank`).
211fn split_at_first_blank(s: &str) -> (&str, &str) {
212    let bytes = s.as_bytes();
213    let n = bytes
214        .iter()
215        .position(|&b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
216        .unwrap_or(bytes.len());
217    s.split_at(n)
218}
219
220/// C-style unquote for a pattern that starts with `"` (see Git `unquote_c_style` in `quote.c`).
221fn unquote_c_style(quoted: &str) -> Result<(String, &str), ()> {
222    let b = quoted.as_bytes();
223    if b.is_empty() || b[0] != b'"' {
224        return Err(());
225    }
226    let mut q = &b[1..];
227    let mut out = Vec::new();
228    loop {
229        let len = q
230            .iter()
231            .position(|&c| c == b'"' || c == b'\\')
232            .unwrap_or(q.len());
233        out.extend_from_slice(&q[..len]);
234        q = &q[len..];
235        if q.is_empty() {
236            return Err(());
237        }
238        match q[0] {
239            b'"' => {
240                let rest = std::str::from_utf8(&q[1..]).map_err(|_| ())?;
241                return Ok((String::from_utf8(out).map_err(|_| ())?, rest));
242            }
243            b'\\' => {
244                q = &q[1..];
245                if q.is_empty() {
246                    return Err(());
247                }
248                let ch = q[0];
249                q = &q[1..];
250                match ch {
251                    b'a' => out.push(0x07),
252                    b'b' => out.push(0x08),
253                    b'f' => out.push(0x0c),
254                    b'n' => out.push(b'\n'),
255                    b'r' => out.push(b'\r'),
256                    b't' => out.push(b'\t'),
257                    b'v' => out.push(0x0b),
258                    b'\\' => out.push(b'\\'),
259                    b'"' => out.push(b'"'),
260                    b'0'..=b'3' => {
261                        let mut ac = u32::from(ch - b'0') << 6;
262                        if q.len() < 2 {
263                            return Err(());
264                        }
265                        let ch2 = q[0];
266                        let ch3 = q[1];
267                        if !(b'0'..=b'7').contains(&ch2) || !(b'0'..=b'7').contains(&ch3) {
268                            return Err(());
269                        }
270                        ac |= u32::from(ch2 - b'0') << 3;
271                        ac |= u32::from(ch3 - b'0');
272                        q = &q[2..];
273                        out.push(ac as u8);
274                    }
275                    _ => return Err(()),
276                }
277            }
278            _ => return Err(()),
279        }
280    }
281}
282
283/// One attribute assignment token (`parse_attr` in Git `attr.c`).
284fn parse_one_attr_token_git(s: &str) -> (&str, Option<&str>, &str) {
285    let bytes = s.as_bytes();
286    let token_end = bytes
287        .iter()
288        .position(|&b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
289        .unwrap_or(bytes.len());
290    let eq_pos = s.find('=');
291    let eq_in_token = eq_pos.filter(|&eq| eq < token_end);
292    let (name, val) = if let Some(eq) = eq_in_token {
293        (&s[..eq], Some(&s[eq + 1..token_end]))
294    } else {
295        (&s[..token_end], None)
296    };
297    let rest = skip_ascii_blank(&s[token_end..]);
298    (name, val, rest)
299}
300
301fn accumulate_attr_states(
302    mut states: &str,
303    attrs: &mut Vec<(String, AttrValue)>,
304    macros: &MacroTable,
305    in_macro_def: bool,
306) {
307    loop {
308        states = skip_ascii_blank(states);
309        if states.is_empty() {
310            break;
311        }
312        let (name, val, rest) = parse_one_attr_token_git(states);
313        states = rest;
314        let tok = match val {
315            Some(v) => format!("{name}={v}"),
316            None => name.to_string(),
317        };
318        push_attr_token(&tok, attrs, macros, in_macro_def);
319    }
320}
321
322const ATTR_MACRO_PREFIX: &str = "[attr]";
323
324fn parse_one_line(
325    raw_line: &str,
326    line_no: usize,
327    display_path: &str,
328    from_blob: bool,
329    out: &mut ParsedGitAttributes,
330) {
331    let _ = display_path;
332    let _ = from_blob;
333    let cp = skip_ascii_blank(raw_line);
334    if cp.is_empty() || cp.starts_with('#') {
335        return;
336    }
337
338    let (pattern_token, states) = if cp.as_bytes().first() == Some(&b'"') {
339        match unquote_c_style(cp) {
340            Ok((pat, rest)) => (pat, rest),
341            Err(()) => {
342                let (a, b) = split_at_first_blank(cp);
343                (a.to_string(), b)
344            }
345        }
346    } else {
347        let (a, b) = split_at_first_blank(cp);
348        (a.to_string(), b)
349    };
350
351    if pattern_token.len() > ATTR_MACRO_PREFIX.len() && pattern_token.starts_with(ATTR_MACRO_PREFIX)
352    {
353        let rest = skip_ascii_blank(&pattern_token[ATTR_MACRO_PREFIX.len()..]);
354        let (macro_name, leftover) = split_at_first_blank(rest);
355        if !leftover.is_empty() || macro_name.is_empty() {
356            return;
357        }
358        let mut attrs = Vec::new();
359        accumulate_attr_states(states, &mut attrs, &out.macros, true);
360        out.macros.defs.insert(macro_name.to_string(), attrs);
361        return;
362    }
363
364    if pattern_token.starts_with('!') && !pattern_token.starts_with("\\!") {
365        out.warnings
366            .push("Negative patterns are ignored".to_string());
367        return;
368    }
369    let pattern = pattern_token.replace("\\!", "!");
370    let mut attrs = Vec::new();
371    accumulate_attr_states(states, &mut attrs, &out.macros, false);
372    if attrs.is_empty() {
373        return;
374    }
375    out.rules.push(AttrRule {
376        pattern,
377        skip: false,
378        line: line_no,
379        attrs,
380    });
381}
382
383fn push_attr_token(
384    tok: &str,
385    attrs: &mut Vec<(String, AttrValue)>,
386    _macros: &MacroTable,
387    in_macro_def: bool,
388) {
389    if tok == "binary" {
390        attrs.push(("text".into(), AttrValue::Unset));
391        attrs.push(("diff".into(), AttrValue::Unset));
392        attrs.push(("merge".into(), AttrValue::Unset));
393        attrs.push(("binary".into(), AttrValue::Set));
394        return;
395    }
396    if in_macro_def {
397        if let Some(rest) = tok.strip_prefix('!') {
398            attrs.push((rest.to_string(), AttrValue::Clear));
399            return;
400        }
401    }
402    if let Some(rest) = tok.strip_prefix('-') {
403        attrs.push((rest.to_string(), AttrValue::Unset));
404        return;
405    }
406    if let Some((k, v)) = tok.split_once('=') {
407        attrs.push((k.to_string(), AttrValue::Value(v.to_string())));
408        return;
409    }
410    attrs.push((tok.to_string(), AttrValue::Set));
411}
412
413/// Match a single gitattributes pattern against a repo-relative path.
414#[must_use]
415pub fn attr_pattern_matches(pattern: &str, rel_path: &str, icase: bool) -> bool {
416    let flags_base = if icase { WM_CASEFOLD } else { 0 };
417    if !pattern.contains('/') {
418        let basename = rel_path.rsplit('/').next().unwrap_or(rel_path);
419        wildmatch(
420            pattern.as_bytes(),
421            basename.as_bytes(),
422            flags_base | WM_PATHNAME,
423        )
424    } else {
425        wildmatch(
426            pattern.as_bytes(),
427            rel_path.as_bytes(),
428            flags_base | WM_PATHNAME,
429        )
430    }
431}
432
433/// Expand macros and `binary` for one rule's assignments into source-order operations.
434///
435/// These must be applied in order to the same map as later rules (not folded into a local map),
436/// so `!attr` / macro clears remove attributes set by earlier rules on the same path.
437fn expand_rule_attrs_flat(rule: &AttrRule, macros: &MacroTable) -> Vec<(String, AttrValue)> {
438    let mut flat: Vec<(String, AttrValue)> = Vec::new();
439    for (name, val) in &rule.attrs {
440        if name == "binary" {
441            flat.push(("text".into(), AttrValue::Unset));
442            flat.push(("diff".into(), AttrValue::Unset));
443            flat.push(("merge".into(), AttrValue::Unset));
444            flat.push(("binary".into(), AttrValue::Set));
445            continue;
446        }
447        if let Some(exp) = macros.defs.get(name) {
448            flat.push((name.clone(), val.clone()));
449            for (n, v) in exp {
450                flat.push((n.clone(), v.clone()));
451            }
452        } else {
453            flat.push((name.clone(), val.clone()));
454        }
455    }
456    flat
457}
458
459/// Merge assignments: later rules override earlier; within one expanded rule, last wins.
460pub fn collect_attrs_for_path(
461    rules: &[AttrRule],
462    macros: &MacroTable,
463    rel_path: &str,
464    icase: bool,
465) -> HashMap<String, AttrValue> {
466    let mut map: HashMap<String, AttrValue> = HashMap::new();
467    for rule in rules {
468        if rule.skip {
469            continue;
470        }
471        if !attr_pattern_matches(&rule.pattern, rel_path, icase) {
472            continue;
473        }
474        let ops = expand_rule_attrs_flat(rule, macros);
475        for (n, v) in ops {
476            match v {
477                AttrValue::Clear => {
478                    map.remove(&n);
479                }
480                _ => {
481                    map.insert(n, v);
482                }
483            }
484        }
485    }
486    map
487}
488
489/// Quote a path for `check-attr` output (C-style) when needed.
490#[must_use]
491pub fn quote_path_for_check_attr(path: &str) -> String {
492    let needs = path
493        .chars()
494        .any(|c| c.is_control() || c == '"' || c == '\\');
495    if !needs {
496        return path.to_string();
497    }
498    let mut s = String::new();
499    s.push('"');
500    for c in path.chars() {
501        match c {
502            '"' => s.push_str("\\\""),
503            '\\' => s.push_str("\\\\"),
504            _ if c.is_control() => s.push_str(&format!("\\{:o}", c as u32)),
505            _ => s.push(c),
506        }
507    }
508    s.push('"');
509    s
510}
511
512/// Normalize `.` / `..` segments in a repo-relative path string.
513#[must_use]
514pub fn normalize_rel_path(path: &str) -> String {
515    let p = Path::new(path);
516    let mut stack: Vec<String> = Vec::new();
517    for c in p.components() {
518        match c {
519            Component::Normal(s) => stack.push(s.to_string_lossy().into_owned()),
520            Component::ParentDir => {
521                let _ = stack.pop();
522            }
523            Component::CurDir => {}
524            _ => {}
525        }
526    }
527    stack.join("/")
528}
529
530/// Resolve a user path to a repo-relative path (forward slashes).
531pub fn path_relative_to_worktree(
532    repo: &Repository,
533    path_str: &str,
534) -> std::result::Result<String, String> {
535    let wt = repo
536        .work_tree
537        .as_ref()
538        .ok_or_else(|| "bare repository — no work tree".to_string())?;
539    let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
540    let p = Path::new(path_str);
541    let abs = if p.is_absolute() {
542        p.to_path_buf()
543    } else {
544        cwd.join(p)
545    };
546    let abs = abs.canonicalize().map_err(|e| e.to_string())?;
547    let wt = wt.canonicalize().map_err(|e| e.to_string())?;
548    let rel = abs
549        .strip_prefix(&wt)
550        .map_err(|_| format!("path outside repository: {}", path_str))?;
551    Ok(normalize_rel_path(
552        rel.to_str().ok_or_else(|| "invalid path".to_string())?,
553    ))
554}
555
556fn collect_nested_gitattributes_dirs(work_tree: &Path) -> Vec<PathBuf> {
557    let mut dirs: Vec<PathBuf> = Vec::new();
558    walk_dirs(work_tree, work_tree, &mut dirs);
559    dirs.sort_by(|a, b| {
560        let da = a.components().count();
561        let db = b.components().count();
562        da.cmp(&db).then_with(|| a.cmp(b))
563    });
564    dirs
565}
566
567fn walk_dirs(root: &Path, cur: &Path, dirs: &mut Vec<PathBuf>) {
568    let Ok(rd) = fs::read_dir(cur) else {
569        return;
570    };
571    for e in rd.flatten() {
572        let p = e.path();
573        let ft = e.file_type().ok();
574        if ft.is_some_and(|t| t.is_dir()) {
575            if p.file_name() == Some(OsStr::new(".git")) {
576                continue;
577            }
578            let rel = p.strip_prefix(root).unwrap_or(&p);
579            dirs.push(rel.to_path_buf());
580            walk_dirs(root, &p, dirs);
581        }
582    }
583}
584
585/// Load the full stack of attribute rules for a normal repository (working tree).
586pub fn load_gitattributes_stack(
587    repo: &Repository,
588    work_tree: &Path,
589) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
590    let mut merged = ParsedGitAttributes::default();
591
592    if let Some(g) = global_attributes_path(repo)? {
593        if g.exists()
594            && !g
595                .symlink_metadata()
596                .map(|m| m.file_type().is_symlink())
597                .unwrap_or(false)
598        {
599            if let Ok(content) = fs::read_to_string(&g) {
600                if content.len() <= MAX_ATTR_FILE_BYTES {
601                    let mut p =
602                        parse_gitattributes_file_content(&content, g.to_string_lossy().as_ref());
603                    merged.rules.append(&mut p.rules);
604                    merged.macros.defs.extend(p.macros.defs.drain());
605                    merged.warnings.append(&mut p.warnings);
606                } else {
607                    merged.warnings.push(format!(
608                        "warning: ignoring overly large gitattributes file '{}'",
609                        g.display()
610                    ));
611                }
612            }
613        }
614    }
615
616    let root_ga = work_tree.join(".gitattributes");
617    if let Some(content) =
618        read_gitattributes_maybe_symlink(&root_ga, ".gitattributes", &mut merged.warnings)
619    {
620        if content.len() <= MAX_ATTR_FILE_BYTES {
621            let mut p = parse_gitattributes_file_content(&content, ".gitattributes");
622            merged.rules.append(&mut p.rules);
623            merged.macros.defs.extend(p.macros.defs.drain());
624            merged.warnings.append(&mut p.warnings);
625        } else {
626            merged.warnings.push(
627                "warning: ignoring overly large gitattributes file '.gitattributes'".to_string(),
628            );
629        }
630    }
631
632    for rel in collect_nested_gitattributes_dirs(work_tree) {
633        let ga = work_tree.join(&rel).join(".gitattributes");
634        if let Some(content) = read_gitattributes_maybe_symlink(
635            &ga,
636            &format!("{}/.gitattributes", rel.display()),
637            &mut merged.warnings,
638        ) {
639            if content.len() > MAX_ATTR_FILE_BYTES {
640                merged.warnings.push(format!(
641                    "warning: ignoring overly large gitattributes file '{}'",
642                    ga.display()
643                ));
644                continue;
645            }
646            let prefix = rel.to_string_lossy().replace('\\', "/");
647            let mut p = parse_gitattributes_file_content(&content, &ga.to_string_lossy());
648            for mut r in p.rules.drain(..) {
649                if !prefix.is_empty() {
650                    r.pattern = format!("{prefix}/{}", r.pattern);
651                }
652                merged.rules.push(r);
653            }
654            merged.macros.defs.extend(p.macros.defs.drain());
655            merged.warnings.append(&mut p.warnings);
656        }
657    }
658
659    let info = repo.git_dir.join("info/attributes");
660    if info.exists() {
661        if let Ok(content) = fs::read_to_string(&info) {
662            if content.len() <= MAX_ATTR_FILE_BYTES {
663                let mut p = parse_gitattributes_file_content(&content, "info/attributes");
664                merged.rules.append(&mut p.rules);
665                merged.macros.defs.extend(p.macros.defs.drain());
666                merged.warnings.append(&mut p.warnings);
667            }
668        }
669    }
670
671    Ok(merged)
672}
673
674/// Bare repository: only `info/attributes` from disk (no in-repo `.gitattributes` file).
675pub fn load_gitattributes_bare(
676    repo: &Repository,
677) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
678    let mut merged = ParsedGitAttributes::default();
679    if let Some(g) = global_attributes_path(repo)? {
680        if g.exists() {
681            if let Ok(content) = fs::read_to_string(&g) {
682                if content.len() <= MAX_ATTR_FILE_BYTES {
683                    let mut p =
684                        parse_gitattributes_file_content(&content, g.to_string_lossy().as_ref());
685                    merged.rules.append(&mut p.rules);
686                    merged.macros.defs.extend(p.macros.defs.drain());
687                    merged.warnings.append(&mut p.warnings);
688                }
689            }
690        }
691    }
692    let info = repo.git_dir.join("info/attributes");
693    if info.exists() {
694        if let Ok(content) = fs::read_to_string(&info) {
695            if content.len() <= MAX_ATTR_FILE_BYTES {
696                let mut p = parse_gitattributes_file_content(&content, "info/attributes");
697                merged.rules.append(&mut p.rules);
698                merged.macros.defs.extend(p.macros.defs.drain());
699                merged.warnings.append(&mut p.warnings);
700            }
701        }
702    }
703    Ok(merged)
704}
705
706/// Read `.gitattributes` blob from a tree object at `tree_oid`, recursively.
707pub fn load_gitattributes_from_tree(
708    odb: &Odb,
709    tree_oid: &ObjectId,
710) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
711    let mut merged = ParsedGitAttributes::default();
712    walk_tree_attrs(odb, tree_oid, "", &mut merged)?;
713    Ok(merged)
714}
715
716fn walk_tree_attrs(
717    odb: &Odb,
718    tree_oid: &ObjectId,
719    prefix: &str,
720    merged: &mut ParsedGitAttributes,
721) -> std::result::Result<(), crate::error::Error> {
722    let obj = odb.read(tree_oid)?;
723    if obj.kind != ObjectKind::Tree {
724        return Ok(());
725    }
726    let entries = parse_tree(&obj.data)?;
727    for e in entries {
728        let name = String::from_utf8_lossy(&e.name).to_string();
729        let path = if prefix.is_empty() {
730            name.clone()
731        } else {
732            format!("{prefix}/{name}")
733        };
734        match e.mode {
735            0o040000 => {
736                walk_tree_attrs(odb, &e.oid, &path, merged)?;
737            }
738            0o100644 | 0o100755 | 0o120000 => {
739                if name == ".gitattributes" {
740                    let oid = e.oid;
741                    {
742                        let blob = odb.read(&oid)?;
743                        if blob.kind != ObjectKind::Blob {
744                            continue;
745                        }
746                        if blob.data.len() > MAX_ATTR_FILE_BYTES {
747                            merged.warnings.push("warning: ignoring overly large gitattributes blob '.gitattributes'".to_string());
748                            continue;
749                        }
750                        let content = String::from_utf8_lossy(&blob.data).into_owned();
751                        let display = format!("{path} (tree)");
752                        let mut p = parse_gitattributes_content_impl(&content, &display, true);
753                        let parent = Path::new(&path)
754                            .parent()
755                            .map(|p| p.to_string_lossy().replace('\\', "/"))
756                            .filter(|s| !s.is_empty());
757                        for mut r in p.rules.drain(..) {
758                            if let Some(ref pre) = parent {
759                                r.pattern = format!("{pre}/{}", r.pattern);
760                            }
761                            merged.rules.push(r);
762                        }
763                        merged.macros.defs.extend(p.macros.defs.drain());
764                        merged.warnings.append(&mut p.warnings);
765                    }
766                }
767            }
768            _ => {}
769        }
770    }
771    Ok(())
772}
773
774/// Resolve `attr.tree`, `GIT_ATTR_SOURCE`, `--source` precedence for check-attr.
775pub fn resolve_attr_treeish(
776    repo: &Repository,
777    source_arg: Option<&str>,
778) -> std::result::Result<Option<String>, crate::error::Error> {
779    let env_src = std::env::var("GIT_ATTR_SOURCE")
780        .ok()
781        .filter(|s| !s.is_empty());
782    let config = ConfigSet::load(Some(&repo.git_dir), true)?;
783    let cfg_tree = config.get("attr.tree");
784    let chosen = source_arg.map(|s| s.to_string()).or(env_src).or(cfg_tree);
785    Ok(chosen)
786}
787
788/// Parse a revision to a tree OID for attribute loading.
789pub fn resolve_tree_oid(repo: &Repository, spec: &str) -> std::result::Result<ObjectId, String> {
790    let oid = resolve_revision(repo, spec).map_err(|e| e.to_string())?;
791    let obj = repo.odb.read(&oid).map_err(|e| e.to_string())?;
792    match obj.kind {
793        ObjectKind::Commit => {
794            let c = crate::objects::parse_commit(&obj.data).map_err(|e| e.to_string())?;
795            Ok(c.tree)
796        }
797        ObjectKind::Tree => Ok(oid),
798        _ => Err("revision is not a commit or tree".to_string()),
799    }
800}
801
802/// Load attributes from the index (stage 0) for `.gitattributes` paths only.
803pub fn load_gitattributes_from_index(
804    index: &Index,
805    odb: &Odb,
806    work_tree: &Path,
807) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
808    let mut merged = ParsedGitAttributes::default();
809    let mut paths: Vec<Vec<u8>> = index
810        .entries
811        .iter()
812        .filter(|e| e.stage() == 0 && e.path.ends_with(b".gitattributes"))
813        .map(|e| e.path.clone())
814        .collect();
815    paths.sort();
816    for path_bytes in paths {
817        let Ok(rel) = std::str::from_utf8(&path_bytes) else {
818            continue;
819        };
820        let Some(entry) = index.get(&path_bytes, 0) else {
821            continue;
822        };
823        let obj = odb.read(&entry.oid)?;
824        if obj.data.len() > MAX_ATTR_FILE_BYTES {
825            merged.warnings.push(format!(
826                "warning: ignoring overly large gitattributes blob '{}'",
827                rel
828            ));
829            continue;
830        }
831        let content = String::from_utf8_lossy(&obj.data);
832        let mut p = parse_gitattributes_content_impl(&content, rel, true);
833        let parent = Path::new(rel).parent().and_then(|p| {
834            let s = p.to_str()?;
835            if s.is_empty() {
836                None
837            } else {
838                Some(s.replace('\\', "/"))
839            }
840        });
841        for mut r in p.rules.drain(..) {
842            if let Some(ref pre) = parent {
843                r.pattern = format!("{pre}/{}", r.pattern);
844            }
845            merged.rules.push(r);
846        }
847        merged.macros.defs.extend(p.macros.defs.drain());
848        merged.warnings.append(&mut p.warnings);
849    }
850    let _ = work_tree;
851    Ok(merged)
852}
853
854/// Return `builtin_objectmode` value for a path (working tree), or `None` if unavailable.
855///
856/// Submodule checkout directories (`.git` is a file containing `gitdir:`) report `160000`
857/// like Git, not `040000`.
858#[must_use]
859pub fn builtin_objectmode_worktree(repo: &Repository, rel_path: &str) -> Option<String> {
860    let wt = repo.work_tree.as_ref()?;
861    let p = wt.join(rel_path);
862    let meta = fs::symlink_metadata(&p).ok()?;
863    let ft = meta.file_type();
864    if ft.is_symlink() {
865        return Some("120000".to_string());
866    }
867    if ft.is_dir() {
868        let git = p.join(".git");
869        if let Ok(git_meta) = fs::symlink_metadata(&git) {
870            if !git_meta.file_type().is_dir() {
871                if let Ok(content) = fs::read_to_string(&git) {
872                    if content.starts_with("gitdir:") {
873                        return Some("160000".to_string());
874                    }
875                }
876            }
877        }
878        return Some("040000".to_string());
879    }
880    #[cfg(unix)]
881    {
882        use std::os::unix::fs::MetadataExt;
883        let m = normalize_mode(meta.mode());
884        Some(format!("{:06o}", m))
885    }
886    #[cfg(not(unix))]
887    {
888        let _ = repo;
889        None
890    }
891}
892
893/// `builtin_objectmode` from the index when `--cached` is used.
894#[must_use]
895pub fn builtin_objectmode_index(index: &Index, rel_path: &str) -> Option<String> {
896    let key = rel_path.as_bytes();
897    let e = index.get(key, 0)?;
898    let m = e.mode;
899    if m == MODE_SYMLINK {
900        return Some("120000".to_string());
901    }
902    if m == MODE_GITLINK {
903        return Some("160000".to_string());
904    }
905    if m == MODE_TREE {
906        return Some("040000".to_string());
907    }
908    if m == MODE_EXECUTABLE {
909        return Some("100755".to_string());
910    }
911    if m == MODE_REGULAR {
912        return Some("100644".to_string());
913    }
914    Some(format!("{:06o}", m))
915}
916
917#[cfg(test)]
918mod tests {
919    use super::*;
920
921    #[test]
922    fn d_yes_rule_clears_test_after_d_star() {
923        let mut merged = ParsedGitAttributes::default();
924        let root = parse_gitattributes_file_content("[attr]notest !test\n", ".gitattributes");
925        merged.macros.defs.extend(root.macros.defs);
926        let ab = parse_gitattributes_file_content(
927            "h test=a/b/h\nd/* test=a/b/d/*\nd/yes notest\n",
928            "a/b/.gitattributes",
929        );
930        assert_eq!(ab.rules.len(), 3);
931        for mut r in ab.rules {
932            r.pattern = format!("a/b/{}", r.pattern);
933            merged.rules.push(r);
934        }
935        merged.macros.defs.extend(ab.macros.defs);
936        assert!(attr_pattern_matches("a/b/d/yes", "a/b/d/yes", false));
937        let m = collect_attrs_for_path(&merged.rules, &merged.macros, "a/b/d/yes", false);
938        assert!(
939            m.get("test").is_none(),
940            "expected test cleared by notest macro, got {:?}",
941            m.get("test")
942        );
943    }
944}