// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! A `.gitattributes` resolver over tree blobs. //! //! libgit2's attribute lookup is oriented at a working tree; against a bare //! repository, and for per-ref accuracy, it is unreliable. This module resolves //! attributes directly from the object database instead: for a given tree it //! collects every `.gitattributes` blob with its directory prefix, parses each //! into ordered rules, and answers "what attributes apply to this path?" with git's //! precedence — deeper directories win over shallower, and within one file a later //! line wins over an earlier one. //! //! Pattern matching follows gitattributes/gitignore semantics: a pattern with no //! slash matches the basename at any depth below its file; a pattern containing a //! slash is anchored to its file's directory; `**` spans directories; `!key`, //! `-key`, `key`, and `key=value` are the four assignment forms. //! //! Consumers (later phases): the highlight language override //! (`fabrica-language` / `linguist-language`), the "binary, not shown" decision //! (`binary` / `-text`), collapsing generated diffs (`linguist-generated` / //! `fabrica-generated`), and excluding vendored paths from search //! (`linguist-vendored`). use std::collections::HashMap; use std::sync::Arc; use globset::{GlobBuilder, GlobMatcher}; use crate::GitError; use crate::repo::Repo; use crate::types::Oid; /// The value an attribute is assigned by a matching rule. /// /// Mirrors git's four forms. Resolution keeps the last (deepest, latest) value /// per attribute name; [`AttrValue::Unspecified`] deliberately clears a value set /// by a shallower rule. #[derive(Debug, Clone, PartialEq, Eq)] pub enum AttrValue { /// `attr` — set to the implicit boolean true. Set, /// `-attr` — explicitly unset (boolean false). Unset, /// `!attr` — unspecified; removes any value a less specific rule assigned. Unspecified, /// `attr=value` — set to a string value. Value(String), } /// The attributes resolved for one concrete path. /// /// A thin wrapper over the final name→value map, with typed accessors for the /// attributes fabrica actually consults. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Attributes { map: HashMap, } impl Attributes { /// The raw value assigned to `name`, if any rule set one. #[must_use] pub fn get(&self, name: &str) -> Option<&AttrValue> { self.map.get(name) } /// Whether `name` resolves to a truthy state ([`AttrValue::Set`] or a /// non-`false` [`AttrValue::Value`]). #[must_use] pub fn is_set(&self, name: &str) -> bool { match self.map.get(name) { Some(AttrValue::Set) => true, Some(AttrValue::Value(v)) => v != "false", _ => false, } } /// Whether `name` was explicitly unset (`-name`). #[must_use] pub fn is_unset(&self, name: &str) -> bool { matches!(self.map.get(name), Some(AttrValue::Unset)) } /// The highlighting language override, honouring fabrica's own attribute over /// GitHub's `linguist-language` (§8.1 precedence). #[must_use] pub fn language(&self) -> Option<&str> { for key in ["fabrica-language", "linguist-language"] { if let Some(AttrValue::Value(v)) = self.map.get(key) { return Some(v.as_str()); } } None } /// Whether the path should be treated as binary (rendered as "binary file, not /// shown"): `binary` set, or text explicitly disabled with `-text`. #[must_use] pub fn is_binary(&self) -> bool { self.is_set("binary") || self.is_unset("text") } /// Whether diff rendering is suppressed for this path: `-diff`, or `binary` /// (git's `binary` macro implies `-diff`). #[must_use] pub fn no_diff(&self) -> bool { self.is_unset("diff") || self.is_set("binary") } /// Whether the path is machine-generated and should collapse by default. #[must_use] pub fn is_generated(&self) -> bool { self.is_set("linguist-generated") || self.is_set("fabrica-generated") } /// Whether the path is vendored and should be excluded from search. #[must_use] pub fn is_vendored(&self) -> bool { self.is_set("linguist-vendored") } /// Whether the path is managed by git-LFS (`filter=lfs` in `.gitattributes`). #[must_use] pub fn is_lfs(&self) -> bool { matches!(self.map.get("filter"), Some(AttrValue::Value(v)) if v == "lfs") } } /// One `.gitattributes` file: its directory prefix (`""` at the tree root) and its /// parsed rules in file order. #[derive(Debug)] struct AttrFile { /// Directory the file lives in, without a trailing slash (`""` at the root). dir: String, /// The file's rules, in the order they appear. rules: Vec, } /// One parsed line of a `.gitattributes` file. #[derive(Debug)] struct Rule { /// The compiled pattern. matcher: GlobMatcher, /// Whether the pattern floats (no slash → match the basename at any depth) /// rather than being anchored to the file's directory. floating: bool, /// The attribute assignments this line makes, in order. assigns: Vec<(String, AttrValue)>, } /// The resolved rule set for a whole tree: every `.gitattributes` file it /// contains, ordered shallowest-first so application order yields git's /// "deeper wins" precedence. #[derive(Debug, Default)] pub struct AttributesSet { files: Vec, } impl AttributesSet { /// Resolve the attributes that apply to `path` (a repo-relative, `/`-separated /// path with no leading slash). #[must_use] pub fn attributes(&self, path: &str) -> Attributes { let path = path.trim_start_matches('/'); let mut map: HashMap = HashMap::new(); // `files` is shallowest-first; applying in that order lets deeper files // (and later lines within a file) overwrite, which is exactly git's rule. for file in &self.files { let Some(rel) = relative_to(path, &file.dir) else { continue; }; let rel_base = rel.rsplit('/').next().unwrap_or(rel); for rule in &file.rules { let hit = if rule.floating { rule.matcher.is_match(rel_base) } else { rule.matcher.is_match(rel) }; if hit { for (name, value) in &rule.assigns { match value { AttrValue::Unspecified => { map.remove(name); } other => { map.insert(name.clone(), other.clone()); } } } } } } Attributes { map } } } /// Return `path` relative to directory `dir`, or `None` if `path` is not under /// `dir`. `dir` is `""` for the tree root (everything is under it). fn relative_to<'a>(path: &'a str, dir: &str) -> Option<&'a str> { if dir.is_empty() { return Some(path); } path.strip_prefix(dir)?.strip_prefix('/') } /// Parse the text of one `.gitattributes` file at directory `dir` into rules. /// Unparseable patterns are skipped (logged by the caller's fall-through), never /// fatal. fn parse_attr_file(dir: &str, text: &str) -> AttrFile { let mut rules = Vec::new(); for raw in text.lines() { let line = raw.trim(); if line.is_empty() || line.starts_with('#') { continue; } let mut fields = line.split_whitespace(); let Some(pattern) = fields.next() else { continue; }; let assigns: Vec<(String, AttrValue)> = fields.map(parse_assignment).collect(); if assigns.is_empty() { continue; } if let Some((matcher, floating)) = compile_pattern(pattern) { rules.push(Rule { matcher, floating, assigns, }); } } AttrFile { dir: dir.to_string(), rules, } } /// Parse one whitespace-delimited attribute token into a name and value. fn parse_assignment(token: &str) -> (String, AttrValue) { if let Some(name) = token.strip_prefix('!') { (name.to_string(), AttrValue::Unspecified) } else if let Some(name) = token.strip_prefix('-') { (name.to_string(), AttrValue::Unset) } else if let Some((name, value)) = token.split_once('=') { (name.to_string(), AttrValue::Value(value.to_string())) } else { (token.to_string(), AttrValue::Set) } } /// Compile a gitattributes pattern into a matcher plus whether it floats. Returns /// `None` for a pattern globset cannot compile. /// /// Semantics: a pattern with no `/` (a trailing one aside) floats and is matched /// against the basename; a pattern with a slash is anchored to the file's /// directory. `literal_separator(true)` makes `*` stop at `/` while `**` spans /// directories, matching git. fn compile_pattern(pattern: &str) -> Option<(GlobMatcher, bool)> { let trimmed = pattern.trim_end_matches('/'); let floating = !trimmed.contains('/'); let glob_src = trimmed.strip_prefix('/').unwrap_or(trimmed); let glob = GlobBuilder::new(glob_src) .literal_separator(!floating) .build() .ok()?; Some((glob.compile_matcher(), floating)) } impl Repo { /// Collect and parse every `.gitattributes` file reachable in the tree of /// commit `oid`, yielding a queryable [`AttributesSet`]. /// /// # Errors /// /// Returns [`GitError::RevNotFound`] if `oid` is not a commit, or /// [`GitError::Libgit2`] on a walk failure. pub fn attributes_set(&self, oid: &Oid) -> Result { let commit = self.commit_at(oid)?; let tree = commit.tree()?; // Collect (dir, blob_oid) for every `.gitattributes`, then read the blobs. let mut found: Vec<(String, git2::Oid)> = Vec::new(); tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { if entry.name() == Some(".gitattributes") && entry.filemode() != i32::from(git2::FileMode::Tree) { // `root` is the directory prefix, e.g. "" or "src/". found.push((root.trim_end_matches('/').to_string(), entry.id())); } git2::TreeWalkResult::Ok })?; let mut files: Vec = Vec::with_capacity(found.len()); for (dir, blob_oid) in found { let blob = self.raw().find_blob(blob_oid)?; let text = String::from_utf8_lossy(blob.content()); files.push(parse_attr_file(&dir, &text)); } // Shallowest directory first so that applying rules in order lets deeper // files override. files.sort_by(|a, b| { depth(&a.dir) .cmp(&depth(&b.dir)) .then_with(|| a.dir.cmp(&b.dir)) }); Ok(AttributesSet { files }) } } /// The nesting depth of a directory prefix (`""` → 0, `"src"` → 1, `"a/b"` → 2). fn depth(dir: &str) -> usize { if dir.is_empty() { 0 } else { dir.split('/').count() } } /// A bounded cache of resolved [`AttributesSet`]s keyed by tree/commit oid. /// /// [`Repo`] is opened per operation, so the cache lives here — shared across /// requests by the web layer — rather than on the repository handle. Resolving a /// tree's attributes parses every `.gitattributes` blob it contains, which is /// wasteful to repeat per blob view within one commit. #[derive(Clone)] pub struct AttributesCache { inner: moka::sync::Cache>, } impl AttributesCache { /// A cache holding up to `capacity` resolved sets. #[must_use] pub fn new(capacity: u64) -> Self { Self { inner: moka::sync::Cache::new(capacity), } } /// Fetch the resolved set for `oid` in `repo`, resolving and caching on a miss. /// /// # Errors /// /// Propagates any [`GitError`] from resolution on a cache miss. pub fn get(&self, repo: &Repo, oid: &Oid) -> Result, GitError> { if let Some(hit) = self.inner.get(oid.as_str()) { return Ok(hit); } let set = Arc::new(repo.attributes_set(oid)?); self.inner.insert(oid.to_string(), Arc::clone(&set)); Ok(set) } } impl Default for AttributesCache { fn default() -> Self { Self::new(256) } } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] use super::*; /// Build an [`AttributesSet`] from `(dir, text)` pairs, mimicking what a tree /// walk would collect, so the resolution logic can be tested without a repo. fn set_from(files: &[(&str, &str)]) -> AttributesSet { let mut parsed: Vec = files .iter() .map(|(dir, text)| parse_attr_file(dir, text)) .collect(); parsed.sort_by(|a, b| { depth(&a.dir) .cmp(&depth(&b.dir)) .then_with(|| a.dir.cmp(&b.dir)) }); AttributesSet { files: parsed } } #[test] fn floating_pattern_matches_basename_at_any_depth() { let set = set_from(&[("", "*.rs linguist-language=Rust\n")]); assert_eq!(set.attributes("src/deep/main.rs").language(), Some("Rust")); assert_eq!(set.attributes("main.rs").language(), Some("Rust")); assert_eq!(set.attributes("main.py").language(), None); } #[test] fn anchored_pattern_only_matches_its_directory_level() { // A leading slash anchors to the file's directory; `*` does not cross `/`. let set = set_from(&[("", "/*.rs linguist-language=Rust\n")]); assert_eq!(set.attributes("main.rs").language(), Some("Rust")); assert_eq!(set.attributes("src/main.rs").language(), None); } #[test] fn double_star_spans_directories() { let set = set_from(&[("", "src/**/*.rs linguist-language=Rust\n")]); assert_eq!(set.attributes("src/a/b/x.rs").language(), Some("Rust")); assert_eq!(set.attributes("other/x.rs").language(), None); } #[test] fn deeper_file_wins_over_shallower() { // Root marks everything generated; a nested file un-marks its subtree. let set = set_from(&[ ("", "* linguist-generated\n"), ("src", "* -linguist-generated\n"), ]); assert!(set.attributes("README.md").is_generated()); assert!(!set.attributes("src/main.rs").is_generated()); } #[test] fn later_line_wins_within_a_file() { let set = set_from(&[( "", "*.rs linguist-language=Rust\nmain.rs linguist-language=Ruby\n", )]); assert_eq!(set.attributes("main.rs").language(), Some("Ruby")); assert_eq!(set.attributes("lib.rs").language(), Some("Rust")); } #[test] fn negation_clears_a_value_from_a_less_specific_rule() { // `!key` unspecifies, distinct from `-key` which sets false. let set = set_from(&[("", "* linguist-language=Text\n*.rs !linguist-language\n")]); assert_eq!(set.attributes("notes.md").language(), Some("Text")); assert_eq!(set.attributes("main.rs").language(), None); } #[test] fn binary_and_minus_text_are_binary() { let set = set_from(&[("", "*.bin binary\n*.dat -text\n")]); assert!(set.attributes("x.bin").is_binary()); assert!(set.attributes("x.dat").is_binary()); assert!(!set.attributes("x.txt").is_binary()); } #[test] fn minus_diff_suppresses_diff_rendering() { let set = set_from(&[("", "*.lock -diff\n")]); assert!(set.attributes("Cargo.lock").no_diff()); assert!(!set.attributes("src/main.rs").no_diff()); // `binary` also implies no diff. let bin = set_from(&[("", "*.png binary\n")]); assert!(bin.attributes("logo.png").no_diff()); } #[test] fn comments_and_blanks_are_ignored() { let set = set_from(&[("", "# a comment\n\n*.rs linguist-language=Rust\n")]); assert_eq!(set.attributes("main.rs").language(), Some("Rust")); } #[test] fn fabrica_language_beats_linguist_language() { let set = set_from(&[( "", "*.rs linguist-language=Rust\n*.rs fabrica-language=RustCustom\n", )]); assert_eq!(set.attributes("main.rs").language(), Some("RustCustom")); } #[test] fn attributes_set_reads_gitattributes_from_a_real_tree() { use std::path::Path; use git2::{Repository, Signature, Time}; use crate::{create_bare, repo_path}; let dir = tempfile::tempdir().unwrap(); let id = "01hzxk9m2n8p7q6r5s4t3v2w1x"; create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).unwrap(); let path = repo_path(dir.path(), id).unwrap(); let raw = Repository::open_bare(&path).unwrap(); // Root `.gitattributes` marks vendored; a nested one overrides in `src`. let root_attr = raw .blob(b"vendor/** linguist-vendored\n*.rs linguist-language=Rust\n") .unwrap(); let src_attr = raw.blob(b"*.rs -linguist-vendored\n").unwrap(); let code = raw.blob(b"fn main() {}\n").unwrap(); let mut src = raw.treebuilder(None).unwrap(); src.insert(".gitattributes", src_attr, 0o100_644).unwrap(); src.insert("main.rs", code, 0o100_644).unwrap(); let src_tree = src.write().unwrap(); let mut vendor = raw.treebuilder(None).unwrap(); vendor.insert("lib.rs", code, 0o100_644).unwrap(); let vendor_tree = vendor.write().unwrap(); let mut root = raw.treebuilder(None).unwrap(); root.insert(".gitattributes", root_attr, 0o100_644).unwrap(); root.insert("src", src_tree, 0o040_000).unwrap(); root.insert("vendor", vendor_tree, 0o040_000).unwrap(); let root_tree = raw.find_tree(root.write().unwrap()).unwrap(); let sig = Signature::new("Ada", "ada@example.com", &Time::new(1, 0)).unwrap(); let commit = raw .commit(Some("refs/heads/main"), &sig, &sig, "init", &root_tree, &[]) .unwrap(); let repo = Repo::open_path(&path).unwrap(); let set = repo.attributes_set(&Oid::new(commit.to_string())).unwrap(); // `vendor/lib.rs` is vendored; `src/main.rs` is un-vendored by the nested // file but still resolves its language from the root rule. assert!(set.attributes("vendor/lib.rs").is_vendored()); assert!(!set.attributes("src/main.rs").is_vendored()); assert_eq!(set.attributes("src/main.rs").language(), Some("Rust")); } }