use crate::config::parse_path;
use crate::config::ConfigSet;
use crate::index::normalize_mode;
use crate::index::Index;
use crate::index::MODE_EXECUTABLE;
use crate::index::MODE_GITLINK;
use crate::index::MODE_REGULAR;
use crate::index::MODE_SYMLINK;
use crate::index::MODE_TREE;
use crate::objects::parse_tree;
use crate::objects::ObjectId;
use crate::objects::ObjectKind;
use crate::odb::Odb;
use crate::repo::Repository;
use crate::rev_parse::resolve_revision;
use crate::wildmatch::{wildmatch, WM_CASEFOLD, WM_PATHNAME};
use std::borrow::Cow;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::fs;
use std::path::{Component, Path, PathBuf};
pub const MAX_ATTR_LINE_BYTES: usize = 2048;
pub const MAX_ATTR_FILE_BYTES: usize = 100 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttrValue {
Set,
Unset,
Clear,
Value(String),
}
impl AttrValue {
#[must_use]
pub fn display(&self) -> &str {
match self {
AttrValue::Set => "set",
AttrValue::Unset => "unset",
AttrValue::Clear => "unspecified",
AttrValue::Value(v) => v.as_str(),
}
}
}
const PAT_NODIR: u32 = 1;
const PAT_MUSTBEDIR: u32 = 2;
const PAT_ENDSWITH: u32 = 4;
#[inline]
fn is_glob_special_attr(c: u8) -> bool {
matches!(c, b'*' | b'?' | b'[' | b'\\')
}
fn simple_length_pat(s: &str) -> usize {
let b = s.as_bytes();
let mut i = 0;
while i < b.len() {
if is_glob_special_attr(b[i]) {
return i;
}
i += 1;
}
i
}
fn parse_attr_pattern_fields(pat: &str) -> (String, u32, usize) {
let mut flags = 0u32;
let mut len = pat.len();
if len > 0 && pat.as_bytes()[len - 1] == b'/' {
len -= 1;
flags |= PAT_MUSTBEDIR;
}
let p = &pat[..len];
let has_slash = p.as_bytes().contains(&b'/');
if !has_slash {
flags |= PAT_NODIR;
}
if let Some(rest) = p.strip_prefix('*') {
if !rest.is_empty() && simple_length_pat(rest) == rest.len() {
flags |= PAT_ENDSWITH;
}
}
let mut nowild = simple_length_pat(p);
if nowild > len {
nowild = len;
}
(p.to_string(), flags, nowild)
}
#[derive(Debug, Clone)]
pub struct AttrRule {
pub attr_base: String,
pub pattern: String,
pub pattern_flags: u32,
pub nowildcardlen: usize,
pub skip: bool,
pub line: usize,
pub attrs: Vec<(String, AttrValue)>,
}
#[derive(Debug, Clone, Default)]
pub struct MacroTable {
pub defs: HashMap<String, Vec<(String, AttrValue)>>,
}
#[derive(Debug, Default)]
pub struct ParsedGitAttributes {
pub rules: Vec<AttrRule>,
pub macros: MacroTable,
pub warnings: Vec<String>,
}
#[must_use]
pub fn is_reserved_builtin_name(name: &str) -> bool {
let Some(rest) = name.strip_prefix("builtin_") else {
return false;
};
matches!(rest, "objectmode")
}
pub fn validate_rules_for_add(
rules: &[AttrRule],
display_path: &str,
) -> std::result::Result<(), String> {
for rule in rules {
if rule.skip {
continue;
}
for (name, _) in &rule.attrs {
if name.starts_with("builtin_") && !is_reserved_builtin_name(name) {
return Err(format!(
"{name} is not a valid attribute name: {display_path}:{}",
rule.line
));
}
}
}
Ok(())
}
pub fn builtin_warnings_for_rules(rules: &[AttrRule], display_path: &str) -> Vec<String> {
let mut w = Vec::new();
for rule in rules {
if rule.skip {
continue;
}
for (name, _) in &rule.attrs {
if name == "builtin_objectmode" {
w.push(format!(
"builtin_objectmode is not a valid attribute name: {display_path}:{}",
rule.line
));
} else if name.starts_with("builtin_") && !is_reserved_builtin_name(name) {
w.push(format!(
"{name} is not a valid attribute name: {display_path}:{}",
rule.line
));
}
}
}
w
}
fn default_global_attributes_path() -> Option<PathBuf> {
let home = std::env::var("HOME").ok()?;
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
if !xdg.is_empty() {
return Some(PathBuf::from(xdg).join("git/attributes"));
}
}
Some(PathBuf::from(home).join(".config/git/attributes"))
}
fn global_attributes_path(
repo: &Repository,
) -> std::result::Result<Option<PathBuf>, crate::error::Error> {
let config = ConfigSet::load(Some(&repo.git_dir), true)?;
if let Some(path) = config.get("core.attributesfile") {
return Ok(Some(PathBuf::from(parse_path(&path))));
}
Ok(default_global_attributes_path())
}
fn read_gitattributes_maybe_symlink(
path: &Path,
display: &str,
warnings: &mut Vec<String>,
) -> Option<String> {
let meta = fs::symlink_metadata(path).ok()?;
if meta.file_type().is_symlink() {
warnings.push(format!(
"unable to access '{display}': Too many levels of symbolic links"
));
return None;
}
fs::read_to_string(path).ok()
}
pub fn parse_gitattributes_file_content(content: &str, display_path: &str) -> ParsedGitAttributes {
parse_gitattributes_content_impl(content, display_path, false, "")
}
pub fn parse_gitattributes_file_content_with_base(
content: &str,
display_path: &str,
attr_base: &str,
) -> ParsedGitAttributes {
parse_gitattributes_content_impl(content, display_path, false, attr_base)
}
fn preprocess_gitattributes_blob_text(content: &str) -> Cow<'_, str> {
if !content.contains("\\n") {
return Cow::Borrowed(content);
}
Cow::Owned(content.replace("\\n", "\n"))
}
fn parse_gitattributes_content_impl(
content: &str,
display_path: &str,
from_blob: bool,
attr_base: &str,
) -> ParsedGitAttributes {
let preprocessed = if from_blob {
preprocess_gitattributes_blob_text(content)
} else {
Cow::Borrowed(content)
};
let content = preprocessed.as_ref();
let mut out = ParsedGitAttributes::default();
for (idx, raw_line) in content.lines().enumerate() {
let line_no = idx + 1;
let line_bytes = raw_line.as_bytes();
if line_bytes.len() >= MAX_ATTR_LINE_BYTES {
out.warnings.push(format!(
"warning: ignoring overly long attributes line {line_no}"
));
continue;
}
parse_one_line(
raw_line,
line_no,
display_path,
from_blob,
attr_base,
&mut out,
);
}
out.warnings
.extend(builtin_warnings_for_rules(&out.rules, display_path));
out
}
fn skip_ascii_blank(s: &str) -> &str {
s.trim_start_matches([' ', '\t', '\r', '\n'])
}
fn split_at_first_blank(s: &str) -> (&str, &str) {
let bytes = s.as_bytes();
let n = bytes
.iter()
.position(|&b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
.unwrap_or(bytes.len());
s.split_at(n)
}
fn unquote_c_style(quoted: &str) -> Result<(String, &str), ()> {
let b = quoted.as_bytes();
if b.is_empty() || b[0] != b'"' {
return Err(());
}
let mut q = &b[1..];
let mut out = Vec::new();
loop {
let len = q
.iter()
.position(|&c| c == b'"' || c == b'\\')
.unwrap_or(q.len());
out.extend_from_slice(&q[..len]);
q = &q[len..];
if q.is_empty() {
return Err(());
}
match q[0] {
b'"' => {
let rest = std::str::from_utf8(&q[1..]).map_err(|_| ())?;
return Ok((String::from_utf8(out).map_err(|_| ())?, rest));
}
b'\\' => {
q = &q[1..];
if q.is_empty() {
return Err(());
}
let ch = q[0];
q = &q[1..];
match ch {
b'a' => out.push(0x07),
b'b' => out.push(0x08),
b'f' => out.push(0x0c),
b'n' => out.push(b'\n'),
b'r' => out.push(b'\r'),
b't' => out.push(b'\t'),
b'v' => out.push(0x0b),
b'\\' => out.push(b'\\'),
b'"' => out.push(b'"'),
b'0'..=b'3' => {
let mut ac = u32::from(ch - b'0') << 6;
if q.len() < 2 {
return Err(());
}
let ch2 = q[0];
let ch3 = q[1];
if !(b'0'..=b'7').contains(&ch2) || !(b'0'..=b'7').contains(&ch3) {
return Err(());
}
ac |= u32::from(ch2 - b'0') << 3;
ac |= u32::from(ch3 - b'0');
q = &q[2..];
out.push(ac as u8);
}
_ => return Err(()),
}
}
_ => return Err(()),
}
}
}
fn parse_one_attr_token_git(s: &str) -> (&str, Option<&str>, &str) {
let bytes = s.as_bytes();
let token_end = bytes
.iter()
.position(|&b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
.unwrap_or(bytes.len());
let eq_pos = s.find('=');
let eq_in_token = eq_pos.filter(|&eq| eq < token_end);
let (name, val) = if let Some(eq) = eq_in_token {
(&s[..eq], Some(&s[eq + 1..token_end]))
} else {
(&s[..token_end], None)
};
let rest = skip_ascii_blank(&s[token_end..]);
(name, val, rest)
}
fn accumulate_attr_states(
mut states: &str,
attrs: &mut Vec<(String, AttrValue)>,
macros: &MacroTable,
in_macro_def: bool,
) {
loop {
states = skip_ascii_blank(states);
if states.is_empty() {
break;
}
let (name, val, rest) = parse_one_attr_token_git(states);
states = rest;
let tok = match val {
Some(v) => format!("{name}={v}"),
None => name.to_string(),
};
push_attr_token(&tok, attrs, macros, in_macro_def);
}
}
const ATTR_MACRO_PREFIX: &str = "[attr]";
fn parse_one_line(
raw_line: &str,
line_no: usize,
display_path: &str,
from_blob: bool,
attr_base: &str,
out: &mut ParsedGitAttributes,
) {
let _ = display_path;
let _ = from_blob;
let cp = skip_ascii_blank(raw_line);
if cp.is_empty() || cp.starts_with('#') {
return;
}
let (pattern_token, states) = if cp.as_bytes().first() == Some(&b'"') {
match unquote_c_style(cp) {
Ok((pat, rest)) => (pat, rest),
Err(()) => {
let (a, b) = split_at_first_blank(cp);
(a.to_string(), b)
}
}
} else {
let (a, b) = split_at_first_blank(cp);
(a.to_string(), b)
};
if pattern_token.len() > ATTR_MACRO_PREFIX.len() && pattern_token.starts_with(ATTR_MACRO_PREFIX)
{
let rest = skip_ascii_blank(&pattern_token[ATTR_MACRO_PREFIX.len()..]);
let (macro_name, leftover) = split_at_first_blank(rest);
if !leftover.is_empty() || macro_name.is_empty() {
return;
}
let mut attrs = Vec::new();
accumulate_attr_states(states, &mut attrs, &out.macros, true);
out.macros.defs.insert(macro_name.to_string(), attrs);
return;
}
if pattern_token.starts_with('!') && !pattern_token.starts_with("\\!") {
out.warnings
.push("Negative patterns are ignored".to_string());
return;
}
let pattern_raw = pattern_token.replace("\\!", "!");
let (pattern, pattern_flags, nowildcardlen) = parse_attr_pattern_fields(&pattern_raw);
let mut attrs = Vec::new();
accumulate_attr_states(states, &mut attrs, &out.macros, false);
if attrs.is_empty() {
return;
}
out.rules.push(AttrRule {
attr_base: attr_base.to_string(),
pattern,
pattern_flags,
nowildcardlen,
skip: false,
line: line_no,
attrs,
});
}
fn push_attr_token(
tok: &str,
attrs: &mut Vec<(String, AttrValue)>,
_macros: &MacroTable,
in_macro_def: bool,
) {
if tok == "binary" {
attrs.push(("text".into(), AttrValue::Unset));
attrs.push(("diff".into(), AttrValue::Unset));
attrs.push(("merge".into(), AttrValue::Unset));
attrs.push(("binary".into(), AttrValue::Set));
return;
}
if in_macro_def {
if let Some(rest) = tok.strip_prefix('!') {
attrs.push((rest.to_string(), AttrValue::Clear));
return;
}
}
if let Some(rest) = tok.strip_prefix('-') {
attrs.push((rest.to_string(), AttrValue::Unset));
return;
}
if let Some((k, v)) = tok.split_once('=') {
let v = v.trim_end_matches(|c: char| {
matches!(c, ' ' | '\t' | '\r' | '\n') || c == '\u{000b}' || c == '\u{000c}'
});
attrs.push((k.to_string(), AttrValue::Value(v.to_string())));
return;
}
attrs.push((tok.to_string(), AttrValue::Set));
}
fn fspathncmp(a: &[u8], b: &[u8], count: usize, icase: bool) -> bool {
if a.len() < count || b.len() < count {
return false;
}
if icase {
a[..count]
.iter()
.zip(&b[..count])
.all(|(x, y)| x.eq_ignore_ascii_case(y))
} else {
a[..count] == b[..count]
}
}
fn match_basename_git(
basename: &[u8],
pattern: &[u8],
prefix: usize,
patternlen: usize,
pat_flags: u32,
icase: bool,
) -> bool {
let basenamelen = basename.len();
let wm_flags = if icase { WM_CASEFOLD } else { 0 };
if prefix == patternlen {
return patternlen == basenamelen && fspathncmp(pattern, basename, basenamelen, icase);
}
if (pat_flags & PAT_ENDSWITH) != 0 {
if patternlen <= 1 {
return false;
}
let lit_len = patternlen - 1;
if lit_len > basenamelen {
return false;
}
return fspathncmp(
&pattern[1..patternlen],
&basename[basenamelen - lit_len..],
lit_len,
icase,
);
}
wildmatch(&pattern[..patternlen], basename, wm_flags)
}
#[allow(clippy::too_many_arguments)]
fn match_pathname_git(
pathname: &[u8],
pathlen: usize,
base: &[u8],
baselen: usize,
mut pattern: &[u8],
mut prefix: usize,
mut patternlen: usize,
icase: bool,
) -> bool {
let pathname = &pathname[..pathlen.min(pathname.len())];
if !pattern.is_empty() && pattern[0] == b'/' {
pattern = &pattern[1..];
patternlen -= 1;
prefix = prefix.saturating_sub(1);
}
if pathlen < baselen + 1 {
return false;
}
if baselen > 0 && pathname[baselen] != b'/' {
return false;
}
if !fspathncmp(pathname, base, baselen, icase) {
return false;
}
let namelen = if baselen == 0 {
pathlen
} else {
pathlen - baselen - 1
};
let name = &pathname[pathlen - namelen..];
if prefix > 0 {
if prefix > namelen {
return false;
}
if !fspathncmp(pattern, name, prefix, icase) {
return false;
}
if patternlen == prefix && namelen == prefix {
return true;
}
let advance = prefix - 1;
pattern = &pattern[advance..];
patternlen -= advance;
let name = &name[advance..];
let wm_flags = WM_PATHNAME | if icase { WM_CASEFOLD } else { 0 };
return wildmatch(&pattern[..patternlen], name, wm_flags);
}
let wm_flags = WM_PATHNAME | if icase { WM_CASEFOLD } else { 0 };
wildmatch(&pattern[..patternlen], name, wm_flags)
}
fn path_dir_prefix(rel_path: &str) -> &str {
match rel_path.rfind('/') {
Some(i) => &rel_path[..i],
None => "",
}
}
#[must_use]
pub fn attr_rule_applies_to_path(attr_base: &str, rel_path: &str, icase: bool) -> bool {
if attr_base.is_empty() {
return true;
}
let dir = path_dir_prefix(rel_path);
if dir.is_empty() {
return false;
}
let prefix_eq = |d: &str, b: &str| {
if icase {
d.eq_ignore_ascii_case(b)
} else {
d == b
}
};
if prefix_eq(dir, attr_base) {
return true;
}
let bl = attr_base.len();
if dir.len() > bl && dir.as_bytes()[bl] == b'/' && prefix_eq(&dir[..bl], attr_base) {
return true;
}
false
}
#[must_use]
pub fn attr_rule_matches(rule: &AttrRule, rel_path: &str, icase: bool) -> bool {
if !attr_rule_applies_to_path(&rule.attr_base, rel_path, icase) {
return false;
}
let pathname = rel_path.as_bytes();
let pathlen = pathname.len();
let isdir = pathlen > 0 && pathname[pathlen - 1] == b'/';
if (rule.pattern_flags & PAT_MUSTBEDIR) != 0 && !isdir {
return false;
}
let eff_pathlen = if isdir { pathlen - 1 } else { pathlen };
let pathname_trim = &pathname[..eff_pathlen];
let basename_offset = pathname_trim
.iter()
.rposition(|&b| b == b'/')
.map(|i| i + 1)
.unwrap_or(0);
let pat = rule.pattern.as_bytes();
let prefix = rule.nowildcardlen.min(pat.len());
let patternlen = pat.len();
if (rule.pattern_flags & PAT_NODIR) != 0 {
let bn = &pathname_trim[basename_offset..];
return match_basename_git(bn, pat, prefix, patternlen, rule.pattern_flags, icase);
}
let base = rule.attr_base.as_bytes();
match_pathname_git(
pathname_trim,
eff_pathlen,
base,
base.len(),
pat,
prefix,
patternlen,
icase,
)
}
fn expand_rule_attrs_flat(rule: &AttrRule, macros: &MacroTable) -> Vec<(String, AttrValue)> {
let mut flat: Vec<(String, AttrValue)> = Vec::new();
for (name, val) in &rule.attrs {
if name == "binary" {
flat.push(("text".into(), AttrValue::Unset));
flat.push(("diff".into(), AttrValue::Unset));
flat.push(("merge".into(), AttrValue::Unset));
flat.push(("binary".into(), AttrValue::Set));
continue;
}
if let Some(exp) = macros.defs.get(name) {
flat.push((name.clone(), val.clone()));
for (n, v) in exp {
flat.push((n.clone(), v.clone()));
}
} else {
flat.push((name.clone(), val.clone()));
}
}
flat
}
pub fn collect_attrs_for_path(
rules: &[AttrRule],
macros: &MacroTable,
rel_path: &str,
icase: bool,
) -> HashMap<String, AttrValue> {
let mut map: HashMap<String, AttrValue> = HashMap::new();
for rule in rules {
if rule.skip {
continue;
}
if !attr_rule_matches(rule, rel_path, icase) {
continue;
}
let ops = expand_rule_attrs_flat(rule, macros);
for (n, v) in ops {
match v {
AttrValue::Clear => {
map.remove(&n);
}
_ => {
map.insert(n, v);
}
}
}
}
map
}
#[must_use]
pub fn quote_path_for_check_attr(path: &str) -> String {
let needs = path
.chars()
.any(|c| c.is_control() || c == '"' || c == '\\');
if !needs {
return path.to_string();
}
let mut s = String::new();
s.push('"');
for c in path.chars() {
match c {
'"' => s.push_str("\\\""),
'\\' => s.push_str("\\\\"),
_ if c.is_control() => s.push_str(&format!("\\{:o}", c as u32)),
_ => s.push(c),
}
}
s.push('"');
s
}
#[must_use]
pub fn normalize_rel_path(path: &str) -> String {
let p = Path::new(path);
let mut stack: Vec<String> = Vec::new();
for c in p.components() {
match c {
Component::Normal(s) => stack.push(s.to_string_lossy().into_owned()),
Component::ParentDir => {
let _ = stack.pop();
}
Component::CurDir => {}
_ => {}
}
}
stack.join("/")
}
fn lexical_normalize_path(path: PathBuf) -> PathBuf {
let mut out = PathBuf::new();
for c in path.components() {
match c {
Component::Prefix(prefix) => out.push(prefix.as_os_str()),
Component::RootDir => out.push(c),
Component::CurDir => {}
Component::ParentDir => {
let _ = out.pop();
}
Component::Normal(_) => out.push(c),
}
}
out
}
pub fn path_relative_to_worktree(
repo: &Repository,
path_str: &str,
) -> std::result::Result<String, String> {
let wt = repo
.work_tree
.as_ref()
.ok_or_else(|| "bare repository — no work tree".to_string())?;
let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
let p = Path::new(path_str);
let combined = if p.is_absolute() {
p.to_path_buf()
} else {
cwd.join(p)
};
let wt_canon = wt.canonicalize().map_err(|e| e.to_string())?;
if let Ok(abs) = combined.canonicalize() {
let rel = abs
.strip_prefix(&wt_canon)
.map_err(|_| format!("path outside repository: {}", path_str))?;
return Ok(normalize_rel_path(
rel.to_str().ok_or_else(|| "invalid path".to_string())?,
));
}
let abs_lex = lexical_normalize_path(combined);
let rel = abs_lex
.strip_prefix(&wt_canon)
.map_err(|_| format!("path outside repository: {}", path_str))?;
Ok(normalize_rel_path(
rel.to_str().ok_or_else(|| "invalid path".to_string())?,
))
}
fn collect_nested_gitattributes_dirs(work_tree: &Path) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
walk_dirs(work_tree, work_tree, &mut dirs);
dirs.sort_by(|a, b| {
let da = a.components().count();
let db = b.components().count();
da.cmp(&db).then_with(|| a.cmp(b))
});
dirs
}
fn walk_dirs(root: &Path, cur: &Path, dirs: &mut Vec<PathBuf>) {
let Ok(rd) = fs::read_dir(cur) else {
return;
};
for e in rd.flatten() {
let p = e.path();
let ft = e.file_type().ok();
if ft.is_some_and(|t| t.is_dir()) {
if p.file_name() == Some(OsStr::new(".git")) {
continue;
}
let rel = p.strip_prefix(root).unwrap_or(&p);
dirs.push(rel.to_path_buf());
walk_dirs(root, &p, dirs);
}
}
}
pub fn load_gitattributes_stack(
repo: &Repository,
work_tree: &Path,
) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
let mut merged = ParsedGitAttributes::default();
if let Some(g) = global_attributes_path(repo)? {
if g.exists() {
if let Ok(content) = fs::read_to_string(&g) {
if content.len() <= MAX_ATTR_FILE_BYTES {
let mut p =
parse_gitattributes_file_content(&content, g.to_string_lossy().as_ref());
merged.rules.append(&mut p.rules);
merged.macros.defs.extend(p.macros.defs.drain());
merged.warnings.append(&mut p.warnings);
} else {
merged.warnings.push(format!(
"warning: ignoring overly large gitattributes file '{}'",
g.display()
));
}
}
}
}
let root_ga = work_tree.join(".gitattributes");
if let Some(content) =
read_gitattributes_maybe_symlink(&root_ga, ".gitattributes", &mut merged.warnings)
{
if content.len() <= MAX_ATTR_FILE_BYTES {
let mut p = parse_gitattributes_file_content(&content, ".gitattributes");
merged.rules.append(&mut p.rules);
merged.macros.defs.extend(p.macros.defs.drain());
merged.warnings.append(&mut p.warnings);
} else {
merged.warnings.push(
"warning: ignoring overly large gitattributes file '.gitattributes'".to_string(),
);
}
}
for rel in collect_nested_gitattributes_dirs(work_tree) {
let ga = work_tree.join(&rel).join(".gitattributes");
if let Some(content) = read_gitattributes_maybe_symlink(
&ga,
&format!("{}/.gitattributes", rel.display()),
&mut merged.warnings,
) {
if content.len() > MAX_ATTR_FILE_BYTES {
merged.warnings.push(format!(
"warning: ignoring overly large gitattributes file '{}'",
ga.display()
));
continue;
}
let prefix = rel.to_string_lossy().replace('\\', "/");
let mut p = parse_gitattributes_file_content_with_base(
&content,
&ga.to_string_lossy(),
&prefix,
);
merged.rules.append(&mut p.rules);
merged.macros.defs.extend(p.macros.defs.drain());
merged.warnings.append(&mut p.warnings);
}
}
let info = repo.git_dir.join("info/attributes");
if info.exists() {
if let Ok(content) = fs::read_to_string(&info) {
if content.len() <= MAX_ATTR_FILE_BYTES {
let mut p = parse_gitattributes_file_content(&content, "info/attributes");
merged.rules.append(&mut p.rules);
merged.macros.defs.extend(p.macros.defs.drain());
merged.warnings.append(&mut p.warnings);
}
}
}
Ok(merged)
}
pub fn load_gitattributes_bare(
repo: &Repository,
) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
let mut merged = ParsedGitAttributes::default();
if let Some(g) = global_attributes_path(repo)? {
if g.exists() {
if let Ok(content) = fs::read_to_string(&g) {
if content.len() <= MAX_ATTR_FILE_BYTES {
let mut p =
parse_gitattributes_file_content(&content, g.to_string_lossy().as_ref());
merged.rules.append(&mut p.rules);
merged.macros.defs.extend(p.macros.defs.drain());
merged.warnings.append(&mut p.warnings);
}
}
}
}
let info = repo.git_dir.join("info/attributes");
if info.exists() {
if let Ok(content) = fs::read_to_string(&info) {
if content.len() <= MAX_ATTR_FILE_BYTES {
let mut p = parse_gitattributes_file_content(&content, "info/attributes");
merged.rules.append(&mut p.rules);
merged.macros.defs.extend(p.macros.defs.drain());
merged.warnings.append(&mut p.warnings);
}
}
}
Ok(merged)
}
pub fn load_gitattributes_from_tree(
odb: &Odb,
tree_oid: &ObjectId,
) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
let mut merged = ParsedGitAttributes::default();
walk_tree_attrs(odb, tree_oid, "", &mut merged)?;
Ok(merged)
}
fn walk_tree_attrs(
odb: &Odb,
tree_oid: &ObjectId,
prefix: &str,
merged: &mut ParsedGitAttributes,
) -> std::result::Result<(), crate::error::Error> {
let obj = odb.read(tree_oid)?;
if obj.kind != ObjectKind::Tree {
return Ok(());
}
let entries = parse_tree(&obj.data)?;
for e in entries {
let name = String::from_utf8_lossy(&e.name).to_string();
let path = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix}/{name}")
};
match e.mode {
0o040000 => {
walk_tree_attrs(odb, &e.oid, &path, merged)?;
}
0o100644 | 0o100755 | 0o120000 => {
if name == ".gitattributes" {
let oid = e.oid;
{
let blob = odb.read(&oid)?;
if blob.kind != ObjectKind::Blob {
continue;
}
if blob.data.len() > MAX_ATTR_FILE_BYTES {
merged.warnings.push("warning: ignoring overly large gitattributes blob '.gitattributes'".to_string());
continue;
}
let content = String::from_utf8_lossy(&blob.data).into_owned();
let display = format!("{path} (tree)");
let attr_base = Path::new(&path)
.parent()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
let mut p =
parse_gitattributes_content_impl(&content, &display, true, &attr_base);
merged.rules.append(&mut p.rules);
merged.macros.defs.extend(p.macros.defs.drain());
merged.warnings.append(&mut p.warnings);
}
}
}
_ => {}
}
}
Ok(())
}
pub fn load_gitattributes_for_diff(
repo: &Repository,
) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
let (treeish, ignore_bad_tree) = resolve_attr_treeish(repo, None)?;
if let Some(spec) = treeish.filter(|s| !s.is_empty()) {
match resolve_tree_oid(repo, &spec) {
Ok(oid) => return load_gitattributes_from_tree(&repo.odb, &oid),
Err(_) if ignore_bad_tree => {}
Err(_) => {
return Err(crate::error::Error::InvalidRef(format!(
"bad --attr-source or GIT_ATTR_SOURCE: {spec}"
)));
}
}
}
if let Some(wt) = repo.work_tree.as_deref() {
return load_gitattributes_stack(repo, wt);
}
load_gitattributes_bare(repo)
}
pub fn resolve_attr_treeish(
repo: &Repository,
source_arg: Option<&str>,
) -> std::result::Result<(Option<String>, bool), crate::error::Error> {
let env_src = std::env::var("GIT_ATTR_SOURCE")
.ok()
.filter(|s| !s.is_empty());
let config = ConfigSet::load(Some(&repo.git_dir), true)?;
let cfg_tree = config.get("attr.tree");
if let Some(s) = source_arg.map(|s| s.to_string()) {
return Ok((Some(s), false));
}
if let Some(s) = env_src {
return Ok((Some(s), false));
}
if let Some(s) = cfg_tree {
return Ok((Some(s), true));
}
Ok((None, false))
}
pub fn resolve_tree_oid(repo: &Repository, spec: &str) -> std::result::Result<ObjectId, String> {
let oid = resolve_revision(repo, spec).map_err(|e| e.to_string())?;
let obj = repo.odb.read(&oid).map_err(|e| e.to_string())?;
match obj.kind {
ObjectKind::Commit => {
let c = crate::objects::parse_commit(&obj.data).map_err(|e| e.to_string())?;
Ok(c.tree)
}
ObjectKind::Tree => Ok(oid),
_ => Err("revision is not a commit or tree".to_string()),
}
}
pub fn load_gitattributes_from_index(
index: &Index,
odb: &Odb,
work_tree: &Path,
) -> std::result::Result<ParsedGitAttributes, crate::error::Error> {
let mut merged = ParsedGitAttributes::default();
let mut paths: Vec<Vec<u8>> = index
.entries
.iter()
.filter(|e| e.stage() == 0 && e.path.ends_with(b".gitattributes"))
.map(|e| e.path.clone())
.collect();
paths.sort();
for path_bytes in paths {
let Ok(rel) = std::str::from_utf8(&path_bytes) else {
continue;
};
let Some(entry) = index.get(&path_bytes, 0) else {
continue;
};
let obj = odb.read(&entry.oid)?;
if obj.data.len() > MAX_ATTR_FILE_BYTES {
merged.warnings.push(format!(
"warning: ignoring overly large gitattributes blob '{}'",
rel
));
continue;
}
let content = String::from_utf8_lossy(&obj.data);
let attr_base = Path::new(rel)
.parent()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
let mut p = parse_gitattributes_content_impl(&content, rel, true, &attr_base);
merged.rules.append(&mut p.rules);
merged.macros.defs.extend(p.macros.defs.drain());
merged.warnings.append(&mut p.warnings);
}
let _ = work_tree;
Ok(merged)
}
#[must_use]
pub fn builtin_objectmode_worktree(repo: &Repository, rel_path: &str) -> Option<String> {
let wt = repo.work_tree.as_ref()?;
let p = wt.join(rel_path);
let meta = fs::symlink_metadata(&p).ok()?;
let ft = meta.file_type();
if ft.is_symlink() {
return Some("120000".to_string());
}
if ft.is_dir() {
let git = p.join(".git");
if let Ok(git_meta) = fs::symlink_metadata(&git) {
if !git_meta.file_type().is_dir() {
if let Ok(content) = fs::read_to_string(&git) {
if content.starts_with("gitdir:") {
return Some("160000".to_string());
}
}
}
}
return Some("040000".to_string());
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let m = normalize_mode(meta.mode());
Some(format!("{:06o}", m))
}
#[cfg(not(unix))]
{
let _ = repo;
None
}
}
#[must_use]
pub fn builtin_objectmode_index(index: &Index, rel_path: &str) -> Option<String> {
let key = rel_path.as_bytes();
let e = index.get(key, 0)?;
let m = e.mode;
if m == MODE_SYMLINK {
return Some("120000".to_string());
}
if m == MODE_GITLINK {
return Some("160000".to_string());
}
if m == MODE_TREE {
return Some("040000".to_string());
}
if m == MODE_EXECUTABLE {
return Some("100755".to_string());
}
if m == MODE_REGULAR {
return Some("100644".to_string());
}
Some(format!("{:06o}", m))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn d_yes_rule_clears_test_after_d_star() {
let mut merged = ParsedGitAttributes::default();
let root = parse_gitattributes_file_content("[attr]notest !test\n", ".gitattributes");
merged.macros.defs.extend(root.macros.defs);
let mut ab = parse_gitattributes_file_content_with_base(
"h test=a/b/h\nd/* test=a/b/d/*\nd/yes notest\n",
"a/b/.gitattributes",
"a/b",
);
assert_eq!(ab.rules.len(), 3);
merged.rules.append(&mut ab.rules);
merged.macros.defs.extend(ab.macros.defs);
let d_yes = merged
.rules
.iter()
.find(|r| r.pattern == "d/yes")
.expect("d/yes rule");
assert!(attr_rule_matches(d_yes, "a/b/d/yes", false));
let m = collect_attrs_for_path(&merged.rules, &merged.macros, "a/b/d/yes", false);
assert!(
m.get("test").is_none(),
"expected test cleared by notest macro, got {:?}",
m.get("test")
);
}
}