1use 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
28pub const MAX_ATTR_LINE_BYTES: usize = 2048;
30
31pub const MAX_ATTR_FILE_BYTES: usize = 100 * 1024 * 1024;
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum AttrValue {
37 Set,
38 Unset,
40 Clear,
42 Value(String),
43}
44
45impl AttrValue {
46 #[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#[derive(Debug, Clone)]
60pub struct AttrRule {
61 pub pattern: String,
63 pub skip: bool,
65 pub line: usize,
67 pub attrs: Vec<(String, AttrValue)>,
69}
70
71#[derive(Debug, Clone, Default)]
73pub struct MacroTable {
74 pub defs: HashMap<String, Vec<(String, AttrValue)>>,
76}
77
78#[derive(Debug, Default)]
80pub struct ParsedGitAttributes {
81 pub rules: Vec<AttrRule>,
82 pub macros: MacroTable,
83 pub warnings: Vec<String>,
84}
85
86#[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
95pub 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
118pub 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
162fn 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
178pub 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
205fn skip_ascii_blank(s: &str) -> &str {
207 s.trim_start_matches([' ', '\t', '\r', '\n'])
208}
209
210fn 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
220fn 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
283fn 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#[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
433fn 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
459pub 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#[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#[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
530pub 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
585pub 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
674pub 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
706pub 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
774pub 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
788pub 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
802pub 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#[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#[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}