Skip to main content

git_lfs_git/
attr.rs

1//! `.gitattributes` parsing and matching.
2//!
3//! Backed by `gix-attributes` + `gix-glob`, which together implement Git's
4//! wildmatch + macro + per-directory precedence semantics. The "shell out
5//! to git, not gix" rule in CLAUDE.md applies to runtime git operations
6//! (rev-list, cat-file, etc.), not to using gix-* crates as parsing libs.
7//!
8//! Two construction modes:
9//!
10//! - [`AttrSet::from_workdir`] — discover and load every `.gitattributes`
11//!   in the working tree, plus `.git/info/attributes`. Per-directory files
12//!   take precedence over `info/attributes`; deeper directories win over
13//!   shallower (Git's standard "more specific wins").
14//! - [`AttrSet::from_buffer`] — load from a single in-memory buffer. For
15//!   tests and one-shot matching that doesn't need a workdir.
16//!
17//! Once built, query with [`AttrSet::value`] / [`AttrSet::is_set`], plus
18//! the LFS-specific helpers [`AttrSet::is_lfs_tracked`] /
19//! [`AttrSet::is_lockable`].
20
21use std::collections::HashMap;
22use std::ffi::OsStr;
23use std::fs;
24use std::io;
25use std::path::{Path, PathBuf};
26
27use bstr::ByteSlice;
28use gix_attributes::{
29    Search, StateRef,
30    search::{MetadataCollection, Outcome},
31};
32use gix_glob::pattern::Case;
33
34/// A queryable set of `.gitattributes` patterns.
35pub struct AttrSet {
36    search: Search,
37    collection: MetadataCollection,
38    /// Macro name → list of attribute keys that macro expands to.
39    /// Tracked alongside gix-attributes' internal macro state so we
40    /// can work around its lack of `!<macro>` expansion: when we see
41    /// `<pattern> !<macro>` in a buffer, we rewrite it to
42    /// `<pattern> !attr1 !attr2 … !<macro>` before handing the bytes
43    /// off, since gix only honors the `!<macro>` token literally.
44    macros: HashMap<String, Vec<String>>,
45}
46
47impl AttrSet {
48    /// Empty set, seeded only with Git's built-in `[attr]binary` macro
49    /// (so patterns referencing `binary` resolve correctly).
50    pub fn empty() -> Self {
51        let mut collection = MetadataCollection::default();
52        let mut search = Search::default();
53        search.add_patterns_buffer(
54            b"[attr]binary -diff -merge -text",
55            "[builtin]".into(),
56            None,
57            &mut collection,
58            true,
59        );
60        let mut macros = HashMap::new();
61        macros.insert(
62            "binary".to_string(),
63            vec!["diff".into(), "merge".into(), "text".into()],
64        );
65        Self {
66            search,
67            collection,
68            macros,
69        }
70    }
71
72    /// Build from a single `.gitattributes`-format buffer.
73    pub fn from_buffer(bytes: &[u8]) -> Self {
74        let mut me = Self::empty();
75        let rewritten = me.intake_buffer(bytes);
76        me.search.add_patterns_buffer(
77            &rewritten,
78            "<memory>".into(),
79            None,
80            &mut me.collection,
81            true,
82        );
83        me
84    }
85
86    /// Add a `.gitattributes` buffer that should match paths under
87    /// `dir` (forward-slash separated, no trailing slash, `""` for the
88    /// repo root). For per-commit evaluation during streaming
89    /// rewrites where the on-disk working tree isn't authoritative.
90    /// Order of calls matters — gix-attributes iterates lists in
91    /// reverse, so deeper directories should be added *after*
92    /// shallower ones to win precedence (matching Git's "more
93    /// specific path overrides shallower" semantics).
94    pub fn add_buffer_at(&mut self, bytes: &[u8], dir: &str) {
95        let virtual_root = std::path::PathBuf::from("/__lfs_virt");
96        let source = if dir.is_empty() {
97            virtual_root.join(".gitattributes")
98        } else {
99            virtual_root.join(dir).join(".gitattributes")
100        };
101        let rewritten = self.intake_buffer(bytes);
102        self.search.add_patterns_buffer(
103            &rewritten,
104            source,
105            Some(&virtual_root),
106            &mut self.collection,
107            true,
108        );
109    }
110
111    /// Discover every `.gitattributes` reachable from `repo_root` (skipping
112    /// the `.git/` directory) and load them along with `.git/info/attributes`
113    /// if it exists.
114    pub fn from_workdir(repo_root: &Path) -> io::Result<Self> {
115        let mut me = Self::empty();
116
117        let info = repo_root.join(".git").join("info").join("attributes");
118        if info.exists() {
119            let bytes = fs::read(&info)?;
120            let rewritten = me.intake_buffer(&bytes);
121            me.search
122                .add_patterns_buffer(&rewritten, info, None, &mut me.collection, true);
123        }
124
125        let mut found = Vec::new();
126        walk_for_gitattributes(repo_root, &mut found)?;
127        // Shallow → deep: gix-attributes iterates pattern lists in reverse
128        // when matching, so the last-added (deepest) wins — matching Git's
129        // "more specific path overrides shallower" semantics.
130        found.sort_by_key(|p| p.components().count());
131        for path in found {
132            let bytes = fs::read(&path)?;
133            let rewritten = me.intake_buffer(&bytes);
134            // `root` is always the repo root. gix-glob computes each file's
135            // relative `base` by stripping the repo-root prefix from
136            // `source.parent()` — so root.gitattributes ends up with no base
137            // (matches paths directly) while sub/.gitattributes ends up with
138            // base `sub/` (strips `sub/` before matching).
139            me.search.add_patterns_buffer(
140                &rewritten,
141                path,
142                Some(repo_root),
143                &mut me.collection,
144                true,
145            );
146        }
147        Ok(me)
148    }
149
150    /// Single-pass macro intake: scans `bytes` for `[attr]<name> ...`
151    /// declarations to update [`Self::macros`] and returns a rewritten
152    /// copy with each `<pattern> !<macro>` token expanded to the
153    /// underlying `!attr1 !attr2 … !<macro>` sequence. Lets us work
154    /// around `gix-attributes` not expanding macro negations
155    /// (test 11 of t-fsck: `b.dat !lfs` should leave `filter`
156    /// unspecified, not just unset the literal `lfs` attribute).
157    /// Macros are processed in declaration order — same constraint
158    /// upstream's `MacroProcessor` documents — so a buffer that
159    /// declares and immediately uses a macro is fine.
160    fn intake_buffer(&mut self, bytes: &[u8]) -> Vec<u8> {
161        let Ok(s) = std::str::from_utf8(bytes) else {
162            // Non-UTF-8 buffer: pass through. We'd rather miss the
163            // negation expansion than corrupt the bytes. Real
164            // .gitattributes files are UTF-8 in practice.
165            return bytes.to_vec();
166        };
167        let mut out = Vec::with_capacity(bytes.len());
168        for line in s.split('\n') {
169            let trimmed = line.trim_start();
170            if let Some(rest) = trimmed.strip_prefix("[attr]") {
171                // `[attr]<name> <attr>...` — register macro, pass line
172                // through verbatim so gix-attributes also knows about it.
173                let mut tokens = rest.split_whitespace();
174                if let Some(name) = tokens.next() {
175                    let attrs: Vec<String> = tokens
176                        .map(|t| {
177                            // Strip leading `-`/`!` and any `=value` suffix
178                            // — we only need the bare key for negation
179                            // expansion later.
180                            let key = t.trim_start_matches(['-', '!']);
181                            key.split_once('=')
182                                .map(|(k, _)| k)
183                                .unwrap_or(key)
184                                .to_string()
185                        })
186                        .filter(|k| !k.is_empty())
187                        .collect();
188                    if !attrs.is_empty() {
189                        self.macros.insert(name.to_string(), attrs);
190                    }
191                }
192                out.extend_from_slice(line.as_bytes());
193                out.push(b'\n');
194                continue;
195            }
196            if trimmed.is_empty() || trimmed.starts_with('#') {
197                out.extend_from_slice(line.as_bytes());
198                out.push(b'\n');
199                continue;
200            }
201            // Pattern line: tokenize and expand any `!<macro>` references.
202            // First whitespace-separated token is the pattern; remainder
203            // are attribute settings. When we expand `!<macro>`, we
204            // *drop* the literal `!<macro>` token from the rewritten
205            // line — feeding gix-attributes both the expanded
206            // `!filter !diff …` set *and* the literal `!lfs` makes it
207            // re-trigger its own macro expansion at lookup time and
208            // wipe out our `!filter`. The trade-off is that the macro
209            // *name* itself stays Set rather than Unspecified for the
210            // negated path; nothing we ship currently looks the macro
211            // name up directly, so that's acceptable.
212            let leading_ws_len = line.len() - trimmed.len();
213            out.extend_from_slice(&line.as_bytes()[..leading_ws_len]);
214            let mut tokens = trimmed.split_whitespace();
215            if let Some(pattern) = tokens.next() {
216                out.extend_from_slice(pattern.as_bytes());
217                for tok in tokens {
218                    if let Some(name) = tok.strip_prefix('!')
219                        && let Some(macro_attrs) = self.macros.get(name)
220                    {
221                        for k in macro_attrs {
222                            out.push(b' ');
223                            out.push(b'!');
224                            out.extend_from_slice(k.as_bytes());
225                        }
226                        // Drop the literal `!<macro>` (see comment above).
227                        continue;
228                    }
229                    out.push(b' ');
230                    out.extend_from_slice(tok.as_bytes());
231                }
232            }
233            out.push(b'\n');
234        }
235        out
236    }
237
238    /// Return the resolved value of `attr` for `path` (relative to the
239    /// repo root, with `/` separators). `None` for unspecified or unset.
240    /// `Set`/`Value(v)` map to `Some("true")` / `Some(v)`.
241    pub fn value(&self, path: &str, attr: &str) -> Option<String> {
242        let mut out = Outcome::default();
243        out.initialize_with_selection(&self.collection, [attr]);
244        self.search
245            .pattern_matching_relative_path(path.into(), Case::Sensitive, None, &mut out);
246        for m in out.iter_selected() {
247            if m.assignment.name.as_str() != attr {
248                continue;
249            }
250            return match m.assignment.state {
251                StateRef::Set => Some("true".into()),
252                StateRef::Value(v) => Some(v.as_bstr().to_str_lossy().into_owned()),
253                StateRef::Unset | StateRef::Unspecified => None,
254            };
255        }
256        None
257    }
258
259    /// True iff `attr` is set for `path` — that is, `attr` or `attr=<v>`
260    /// where `v` is anything other than the literal `"false"`.
261    pub fn is_set(&self, path: &str, attr: &str) -> bool {
262        matches!(self.value(path, attr).as_deref(), Some(v) if v != "false")
263    }
264
265    /// True iff `path` matches a `filter=lfs` line.
266    pub fn is_lfs_tracked(&self, path: &str) -> bool {
267        self.value(path, "filter").as_deref() == Some("lfs")
268    }
269
270    /// True iff `path` matches a `lockable` line.
271    pub fn is_lockable(&self, path: &str) -> bool {
272        self.is_set(path, "lockable")
273    }
274}
275
276/// A single LFS-related pattern line discovered while listing.
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub struct PatternEntry {
279    /// The pattern text exactly as it appears in the file (with any
280    /// surrounding `"..."` quotes stripped).
281    pub pattern: String,
282    /// Path of the `.gitattributes` (or `.git/info/attributes`) file the
283    /// pattern was found in, relative to the repo root and with `/`
284    /// separators.
285    pub source: String,
286    /// True if the line establishes LFS tracking (`filter=lfs`); false if
287    /// it explicitly removes / unspecifies the filter (`-filter`,
288    /// `!filter`, `-filter=...`).
289    pub tracked: bool,
290    /// True if the same line carries the `lockable` attribute (in `set`
291    /// form — `lockable=false` is treated as not lockable).
292    pub lockable: bool,
293}
294
295/// All LFS-related patterns visible in a workdir, in load order
296/// (`.git/info/attributes` first, then `.gitattributes` from shallow to
297/// deep).
298#[derive(Debug, Default, PartialEq, Eq)]
299pub struct PatternListing {
300    pub patterns: Vec<PatternEntry>,
301}
302
303impl PatternListing {
304    /// Lines that establish LFS tracking (`filter=lfs`).
305    pub fn tracked(&self) -> impl Iterator<Item = &PatternEntry> {
306        self.patterns.iter().filter(|p| p.tracked)
307    }
308
309    /// Lines that explicitly remove / unspecify the LFS filter.
310    pub fn excluded(&self) -> impl Iterator<Item = &PatternEntry> {
311        self.patterns.iter().filter(|p| !p.tracked)
312    }
313}
314
315/// Macro table accumulated while walking attribute files. Each macro
316/// name maps to whether the macro's expansion sets `filter=lfs` —
317/// that's the only thing the listing cares about for "already
318/// supported" detection.
319#[derive(Default)]
320struct MacroState {
321    /// Macros that resolve to `filter=lfs` (directly or transitively).
322    /// The bool key in the value position is unused; we store name →
323    /// `()` as a HashSet would, but Vec keeps insertion order
324    /// deterministic for tests.
325    enables_lfs: std::collections::HashSet<String>,
326}
327
328impl MacroState {
329    /// If `line` is `[attr]<name> <tokens...>`, register the macro's
330    /// effective filter setting in `self`. No-op for non-macro lines.
331    fn ingest(&mut self, line: &str) {
332        let trimmed = line.trim_start();
333        let Some(rest) = trimmed.strip_prefix("[attr]") else {
334            return;
335        };
336        let mut tokens = rest.split_whitespace();
337        let Some(name) = tokens.next() else {
338            return;
339        };
340        // The macro's effective filter setting is the *last* `filter=...`,
341        // `-filter`, `!filter`, or referenced macro that wins.
342        let mut enables = false;
343        for tok in tokens {
344            match self.classify(tok) {
345                FilterEffect::SetLfs => enables = true,
346                FilterEffect::Clear => enables = false,
347                FilterEffect::None => {}
348            }
349        }
350        if enables {
351            self.enables_lfs.insert(name.to_owned());
352        } else {
353            self.enables_lfs.remove(name);
354        }
355    }
356
357    /// Classify a single attribute token (post-macro): does it set
358    /// `filter=lfs`, clear filter, or do neither?
359    fn classify(&self, tok: &str) -> FilterEffect {
360        if tok == "filter=lfs" {
361            return FilterEffect::SetLfs;
362        }
363        if tok == "-filter" || tok == "!filter" || tok.starts_with("-filter=") {
364            return FilterEffect::Clear;
365        }
366        // Macro reference: bare name, `-name`, or `!name`. `-NAME` /
367        // `!NAME` unset the macro's attributes (so if it set filter,
368        // we treat this as Clear).
369        if let Some(name) = tok.strip_prefix('-').or_else(|| tok.strip_prefix('!')) {
370            if self.enables_lfs.contains(name) {
371                return FilterEffect::Clear;
372            }
373            return FilterEffect::None;
374        }
375        if self.enables_lfs.contains(tok) {
376            return FilterEffect::SetLfs;
377        }
378        FilterEffect::None
379    }
380}
381
382enum FilterEffect {
383    SetLfs,
384    Clear,
385    None,
386}
387
388/// Walk `.gitattributes` across the workdir plus `.git/info/attributes`
389/// and the user's `core.attributesfile` (if configured), extracting
390/// LFS-related pattern lines for `git lfs track`'s listing mode.
391///
392/// Pattern matching is *not* needed here — we're just enumerating the raw
393/// pattern text per source file — so this uses a simple line tokenizer
394/// rather than [`AttrSet`]'s full wildmatch machinery.
395pub fn list_lfs_patterns(repo_root: &Path) -> io::Result<PatternListing> {
396    let mut listing = PatternListing::default();
397    let mut macros = MacroState::default();
398
399    // The user-level attributes file (`core.attributesfile`, default
400    // `$XDG_CONFIG_HOME/git/attributes`, falling back to
401    // `~/.config/git/attributes`). Looked up before `.git/info/attributes`
402    // and the per-tree files so it shows up first in the listing —
403    // upstream lists global → repo-local → per-dir. Macros declared
404    // here apply to later files.
405    if let Some((path, bytes)) = read_global_attributes(repo_root) {
406        scan_attr_lines(&bytes, &path, &mut listing, &mut macros, true);
407    }
408
409    let info = repo_root.join(".git").join("info").join("attributes");
410    if info.exists() {
411        let bytes = fs::read(&info)?;
412        scan_attr_lines(
413            &bytes,
414            ".git/info/attributes",
415            &mut listing,
416            &mut macros,
417            true,
418        );
419    }
420
421    let mut found = Vec::new();
422    walk_for_gitattributes(repo_root, &mut found)?;
423    found.sort_by_key(|p| p.components().count());
424    for path in found {
425        let bytes = fs::read(&path)?;
426        let rel = path
427            .strip_prefix(repo_root)
428            .unwrap_or(&path)
429            .to_string_lossy()
430            .replace('\\', "/");
431        // Macros are only legal in the repo-root .gitattributes;
432        // subdirectory files emit a "not allowed" warning from git
433        // itself and don't register the macro.
434        let is_root = !rel.contains('/');
435        scan_attr_lines(&bytes, &rel, &mut listing, &mut macros, is_root);
436    }
437    Ok(listing)
438}
439
440/// Locate and read the user-level git attributes file, returning the
441/// display path + content bytes if it exists. Order matches git:
442/// 1. `core.attributesfile` (from any config scope),
443/// 2. `$XDG_CONFIG_HOME/git/attributes`,
444/// 3. `$HOME/.config/git/attributes`.
445fn read_global_attributes(repo_root: &Path) -> Option<(String, Vec<u8>)> {
446    if let Ok(Some(path)) = crate::config::get_effective(repo_root, "core.attributesfile") {
447        let expanded = expand_tilde(&path);
448        if let Ok(bytes) = fs::read(&expanded) {
449            return Some((path, bytes));
450        }
451    }
452    let xdg = std::env::var_os("XDG_CONFIG_HOME")
453        .filter(|v| !v.is_empty())
454        .map(PathBuf::from)
455        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
456    let path = xdg.join("git").join("attributes");
457    let bytes = fs::read(&path).ok()?;
458    Some((path.to_string_lossy().into_owned(), bytes))
459}
460
461/// Resolve a leading `~` / `~/` to the user's home directory. Git's
462/// `core.attributesfile` accepts both forms, but Rust's `Path` doesn't
463/// expand them itself.
464fn expand_tilde(path: &str) -> PathBuf {
465    if let Some(rest) = path.strip_prefix("~/") {
466        if let Some(home) = std::env::var_os("HOME") {
467            return PathBuf::from(home).join(rest);
468        }
469    } else if path == "~"
470        && let Some(home) = std::env::var_os("HOME")
471    {
472        return PathBuf::from(home);
473    }
474    PathBuf::from(path)
475}
476
477fn scan_attr_lines(
478    bytes: &[u8],
479    source: &str,
480    listing: &mut PatternListing,
481    macros: &mut MacroState,
482    allow_macros: bool,
483) {
484    for raw in bytes.split(|&b| b == b'\n') {
485        let line = String::from_utf8_lossy(raw);
486        // Per `gitattributes(5)`, `#` only starts a comment when it's
487        // the first non-whitespace character on the line — patterns like
488        // `\#` are valid and must not be cropped here.
489        let body = line.trim();
490        if body.is_empty() || body.starts_with('#') {
491            continue;
492        }
493        if body.starts_with("[attr]") {
494            // Git itself rejects `[attr]NAME` declarations in
495            // subdirectory `.gitattributes` ("not allowed:
496            // dir/.gitattributes:1"); only honor them from
497            // top-level sources to match.
498            if allow_macros {
499                macros.ingest(body);
500            }
501            continue;
502        }
503        let mut tokens = body.split_whitespace();
504        let Some(pattern) = tokens.next() else {
505            continue;
506        };
507        let mut filter: Option<bool> = None;
508        let mut lockable = false;
509        for tok in tokens {
510            match macros.classify(tok) {
511                FilterEffect::SetLfs => filter = Some(true),
512                FilterEffect::Clear => filter = Some(false),
513                FilterEffect::None => {}
514            }
515            if tok == "lockable" {
516                lockable = true;
517            }
518        }
519        if let Some(tracked) = filter {
520            listing.patterns.push(PatternEntry {
521                pattern: pattern.to_owned(),
522                source: source.to_owned(),
523                tracked,
524                lockable,
525            });
526        }
527    }
528}
529
530fn walk_for_gitattributes(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
531    for entry in fs::read_dir(dir)? {
532        let entry = entry?;
533        let ft = entry.file_type()?;
534        let name = entry.file_name();
535        if name == OsStr::new(".git") {
536            continue;
537        }
538        let path = entry.path();
539        if ft.is_dir() {
540            walk_for_gitattributes(&path, out)?;
541        } else if ft.is_file() && name == OsStr::new(".gitattributes") {
542            out.push(path);
543        }
544    }
545    Ok(())
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use tempfile::TempDir;
552
553    #[test]
554    fn negated_macro_unsets_constituent_attrs() {
555        // Regression for t-fsck 11. `[attr]lfs ...` declares a macro,
556        // `*.dat lfs` applies it (so .dat files become filter=lfs),
557        // `b.dat !lfs` unsets it. After our intake-time rewrite
558        // expands `!lfs` into `!filter !diff !merge !text`, gix
559        // reports filter=None for b.dat and is_lfs_tracked returns
560        // false for it.
561        let s = AttrSet::from_buffer(
562            b"[attr]lfs filter=lfs diff=lfs merge=lfs -text\n\
563              *.dat lfs\n\
564              b.dat !lfs\n",
565        );
566        assert_eq!(s.value("a.dat", "filter").as_deref(), Some("lfs"));
567        assert_eq!(s.value("b.dat", "filter"), None);
568        assert!(s.is_lfs_tracked("a.dat"));
569        assert!(!s.is_lfs_tracked("b.dat"));
570    }
571
572    #[test]
573    fn empty_set_has_no_matches() {
574        let s = AttrSet::empty();
575        assert_eq!(s.value("foo.txt", "filter"), None);
576        assert!(!s.is_lfs_tracked("foo.txt"));
577        assert!(!s.is_lockable("foo.txt"));
578    }
579
580    #[test]
581    fn buffer_basename_match() {
582        let s = AttrSet::from_buffer(b"*.bin filter=lfs diff=lfs merge=lfs -text\n");
583        assert!(s.is_lfs_tracked("foo.bin"));
584        assert!(s.is_lfs_tracked("nested/dir/foo.bin"));
585        assert!(!s.is_lfs_tracked("foo.txt"));
586    }
587
588    #[test]
589    fn value_returns_raw_string() {
590        let s = AttrSet::from_buffer(b"*.txt eol=lf\n");
591        assert_eq!(s.value("a.txt", "eol").as_deref(), Some("lf"));
592    }
593
594    #[test]
595    fn unset_attribute_via_dash_prefix() {
596        let s = AttrSet::from_buffer(
597            b"*.txt filter=lfs\n\
598              special.txt -filter\n",
599        );
600        assert!(s.is_lfs_tracked("a.txt"));
601        // `special.txt -filter` removes the filter attribute → value is None.
602        assert_eq!(s.value("special.txt", "filter"), None);
603        assert!(!s.is_lfs_tracked("special.txt"));
604    }
605
606    #[test]
607    fn lockable_set_form() {
608        let s = AttrSet::from_buffer(b"*.psd lockable\n");
609        assert!(s.is_lockable("art/cover.psd"));
610        assert!(!s.is_lockable("readme.txt"));
611    }
612
613    #[test]
614    fn is_set_treats_false_value_as_unset() {
615        let s = AttrSet::from_buffer(
616            b"truthy lockable\n\
617              falsy  lockable=false\n",
618        );
619        assert!(s.is_set("truthy", "lockable"));
620        assert!(!s.is_set("falsy", "lockable"));
621    }
622
623    #[test]
624    fn rooted_pattern_only_matches_top_level() {
625        let s = AttrSet::from_buffer(b"/top.bin filter=lfs\n");
626        assert!(s.is_lfs_tracked("top.bin"));
627        assert!(!s.is_lfs_tracked("nested/top.bin"));
628    }
629
630    #[test]
631    fn workdir_loads_root_gitattributes() {
632        let tmp = TempDir::new().unwrap();
633        std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap();
634        std::fs::write(
635            tmp.path().join(".gitattributes"),
636            "*.bin filter=lfs diff=lfs merge=lfs -text\n",
637        )
638        .unwrap();
639
640        let s = AttrSet::from_workdir(tmp.path()).unwrap();
641        assert!(s.is_lfs_tracked("a.bin"));
642        assert!(s.is_lfs_tracked("sub/a.bin"));
643    }
644
645    #[test]
646    fn deeper_gitattributes_overrides_root() {
647        let tmp = TempDir::new().unwrap();
648        std::fs::create_dir_all(tmp.path().join("sub/.git_placeholder")).unwrap();
649        std::fs::write(tmp.path().join(".gitattributes"), "*.bin filter=lfs\n").unwrap();
650        std::fs::write(tmp.path().join("sub/.gitattributes"), "*.bin -filter\n").unwrap();
651
652        let s = AttrSet::from_workdir(tmp.path()).unwrap();
653        assert!(s.is_lfs_tracked("a.bin"));
654        // Deeper -filter wins for paths within sub/.
655        assert!(!s.is_lfs_tracked("sub/a.bin"));
656    }
657
658    #[test]
659    fn info_attributes_loaded_from_dotgit() {
660        let tmp = TempDir::new().unwrap();
661        std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap();
662        std::fs::write(
663            tmp.path().join(".git/info/attributes"),
664            "*.bin filter=lfs\n",
665        )
666        .unwrap();
667
668        let s = AttrSet::from_workdir(tmp.path()).unwrap();
669        assert!(s.is_lfs_tracked("a.bin"));
670    }
671
672    #[test]
673    fn list_lfs_patterns_recursive() {
674        // Mirror upstream t-track.sh's "track" test fixture: root
675        // .gitattributes + .git/info/attributes + nested per-directory
676        // files, with one nested dir adding `-filter` exclusions.
677        let tmp = TempDir::new().unwrap();
678        std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap();
679        std::fs::create_dir_all(tmp.path().join("a/b")).unwrap();
680        std::fs::write(
681            tmp.path().join(".gitattributes"),
682            "* text=auto\n\
683             *.jpg filter=lfs diff=lfs merge=lfs -text\n",
684        )
685        .unwrap();
686        std::fs::write(
687            tmp.path().join(".git/info/attributes"),
688            "*.mov filter=lfs -text\n",
689        )
690        .unwrap();
691        std::fs::write(
692            tmp.path().join("a/.gitattributes"),
693            "*.gif filter=lfs -text\n",
694        )
695        .unwrap();
696        std::fs::write(
697            tmp.path().join("a/b/.gitattributes"),
698            "*.png filter=lfs -text\n\
699             *.gif -filter -text\n\
700             *.mov -filter=lfs -text\n",
701        )
702        .unwrap();
703
704        let listing = list_lfs_patterns(tmp.path()).unwrap();
705        let tracked: Vec<(&str, &str)> = listing
706            .tracked()
707            .map(|p| (p.pattern.as_str(), p.source.as_str()))
708            .collect();
709        let excluded: Vec<(&str, &str)> = listing
710            .excluded()
711            .map(|p| (p.pattern.as_str(), p.source.as_str()))
712            .collect();
713
714        // info/attributes is loaded first, then root → deepest .gitattributes.
715        assert_eq!(
716            tracked,
717            vec![
718                ("*.mov", ".git/info/attributes"),
719                ("*.jpg", ".gitattributes"),
720                ("*.gif", "a/.gitattributes"),
721                ("*.png", "a/b/.gitattributes"),
722            ]
723        );
724        assert_eq!(
725            excluded,
726            vec![
727                ("*.gif", "a/b/.gitattributes"),
728                ("*.mov", "a/b/.gitattributes"),
729            ]
730        );
731    }
732
733    #[test]
734    fn list_lfs_patterns_skips_macros_and_comments() {
735        let tmp = TempDir::new().unwrap();
736        std::fs::write(
737            tmp.path().join(".gitattributes"),
738            "[attr]binary -diff -merge -text\n\
739             # *.jpg filter=lfs\n\
740             *.bin filter=lfs -text\n",
741        )
742        .unwrap();
743        let listing = list_lfs_patterns(tmp.path()).unwrap();
744        let tracked: Vec<&PatternEntry> = listing.tracked().collect();
745        assert_eq!(tracked.len(), 1);
746        assert_eq!(tracked[0].pattern, "*.bin");
747    }
748
749    #[test]
750    fn list_picks_up_lockable_attribute() {
751        let tmp = TempDir::new().unwrap();
752        std::fs::write(
753            tmp.path().join(".gitattributes"),
754            "*.psd filter=lfs diff=lfs merge=lfs lockable\n\
755             *.bin filter=lfs diff=lfs merge=lfs\n",
756        )
757        .unwrap();
758        let listing = list_lfs_patterns(tmp.path()).unwrap();
759        assert_eq!(listing.patterns.len(), 2);
760        assert_eq!(listing.patterns[0].pattern, "*.psd");
761        assert!(listing.patterns[0].lockable);
762        assert_eq!(listing.patterns[1].pattern, "*.bin");
763        assert!(!listing.patterns[1].lockable);
764    }
765
766    #[test]
767    fn list_expands_macro_to_lfs() {
768        // `[attr]lfs filter=lfs ...` registers a macro; `*.dat lfs`
769        // applies it. Upstream's `git lfs track` lists `*.dat` as a
770        // tracked pattern because the macro resolves to filter=lfs.
771        // Lands t-attributes test 1.
772        let tmp = TempDir::new().unwrap();
773        std::fs::write(
774            tmp.path().join(".gitattributes"),
775            "[attr]lfs filter=lfs diff=lfs merge=lfs -text\n\
776             *.dat lfs\n",
777        )
778        .unwrap();
779        let listing = list_lfs_patterns(tmp.path()).unwrap();
780        let tracked: Vec<&str> = listing.tracked().map(|p| p.pattern.as_str()).collect();
781        assert_eq!(tracked, vec!["*.dat"]);
782    }
783
784    #[test]
785    fn list_expands_macro_defined_in_earlier_file() {
786        // Macro defined in `.git/info/attributes`, referenced by a
787        // pattern in `.gitattributes`. The pattern still resolves
788        // because we accumulate macros across files in load order.
789        // Models t-attributes test 3 (global → local).
790        let tmp = TempDir::new().unwrap();
791        std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap();
792        std::fs::write(
793            tmp.path().join(".git/info/attributes"),
794            "[attr]lfs filter=lfs diff=lfs merge=lfs -text\n",
795        )
796        .unwrap();
797        std::fs::write(tmp.path().join(".gitattributes"), "*.dat lfs\n").unwrap();
798        let listing = list_lfs_patterns(tmp.path()).unwrap();
799        let tracked: Vec<&str> = listing.tracked().map(|p| p.pattern.as_str()).collect();
800        assert_eq!(tracked, vec!["*.dat"]);
801    }
802
803    #[test]
804    fn list_negated_macro_marks_excluded() {
805        // `*.dat !lfs` — the `!` negation clears the macro's filter
806        // attribute, so the pattern is recorded as excluded rather
807        // than tracked. Models the dir-level override in t-attributes
808        // "macros with unspecified flag".
809        let tmp = TempDir::new().unwrap();
810        std::fs::write(
811            tmp.path().join(".gitattributes"),
812            "[attr]lfs filter=lfs diff=lfs merge=lfs -text\n\
813             **/*.dat lfs\n\
814             other.dat !lfs\n",
815        )
816        .unwrap();
817        let listing = list_lfs_patterns(tmp.path()).unwrap();
818        let tracked: Vec<&str> = listing.tracked().map(|p| p.pattern.as_str()).collect();
819        let excluded: Vec<&str> = listing.excluded().map(|p| p.pattern.as_str()).collect();
820        assert_eq!(tracked, vec!["**/*.dat"]);
821        assert_eq!(excluded, vec!["other.dat"]);
822    }
823
824    #[test]
825    fn bang_filter_treated_as_excluded() {
826        let tmp = TempDir::new().unwrap();
827        std::fs::write(
828            tmp.path().join(".gitattributes"),
829            "*.dat filter=lfs\n\
830             a.dat !filter\n",
831        )
832        .unwrap();
833        let listing = list_lfs_patterns(tmp.path()).unwrap();
834        assert_eq!(listing.patterns.len(), 2);
835        assert!(listing.patterns[0].tracked);
836        assert_eq!(listing.patterns[1].pattern, "a.dat");
837        assert!(!listing.patterns[1].tracked);
838    }
839
840    #[test]
841    fn workdir_skips_dotgit_directory() {
842        // A .gitattributes inside .git/ must NOT be picked up — only
843        // .git/info/attributes is, and it's loaded explicitly above.
844        let tmp = TempDir::new().unwrap();
845        std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
846        std::fs::write(tmp.path().join(".git/.gitattributes"), "*.bin filter=lfs\n").unwrap();
847
848        let s = AttrSet::from_workdir(tmp.path()).unwrap();
849        assert!(!s.is_lfs_tracked("a.bin"));
850    }
851}