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/// Walk `.gitattributes` across the workdir plus `.git/info/attributes`
316/// and the user's `core.attributesfile` (if configured), extracting
317/// LFS-related pattern lines for `git lfs track`'s listing mode.
318///
319/// Pattern matching is *not* needed here — we're just enumerating the raw
320/// pattern text per source file — so this uses a simple line tokenizer
321/// rather than [`AttrSet`]'s full wildmatch machinery.
322pub fn list_lfs_patterns(repo_root: &Path) -> io::Result<PatternListing> {
323    let mut listing = PatternListing::default();
324
325    // The user-level attributes file (`core.attributesfile`, default
326    // `~/.config/git/attributes`). Looked up before `.git/info/attributes`
327    // and the per-tree files so it shows up first in the listing —
328    // upstream lists global → repo-local → per-dir.
329    if let Ok(Some(path)) = crate::config::get_effective(repo_root, "core.attributesfile") {
330        let expanded = expand_tilde(&path);
331        if let Ok(bytes) = fs::read(&expanded) {
332            scan_attr_lines(&bytes, &path, &mut listing);
333        }
334    }
335
336    let info = repo_root.join(".git").join("info").join("attributes");
337    if info.exists() {
338        let bytes = fs::read(&info)?;
339        scan_attr_lines(&bytes, ".git/info/attributes", &mut listing);
340    }
341
342    let mut found = Vec::new();
343    walk_for_gitattributes(repo_root, &mut found)?;
344    found.sort_by_key(|p| p.components().count());
345    for path in found {
346        let bytes = fs::read(&path)?;
347        let rel = path
348            .strip_prefix(repo_root)
349            .unwrap_or(&path)
350            .to_string_lossy()
351            .replace('\\', "/");
352        scan_attr_lines(&bytes, &rel, &mut listing);
353    }
354    Ok(listing)
355}
356
357/// Resolve a leading `~` / `~/` to the user's home directory. Git's
358/// `core.attributesfile` accepts both forms, but Rust's `Path` doesn't
359/// expand them itself.
360fn expand_tilde(path: &str) -> PathBuf {
361    if let Some(rest) = path.strip_prefix("~/") {
362        if let Some(home) = std::env::var_os("HOME") {
363            return PathBuf::from(home).join(rest);
364        }
365    } else if path == "~"
366        && let Some(home) = std::env::var_os("HOME")
367    {
368        return PathBuf::from(home);
369    }
370    PathBuf::from(path)
371}
372
373fn scan_attr_lines(bytes: &[u8], source: &str, listing: &mut PatternListing) {
374    for raw in bytes.split(|&b| b == b'\n') {
375        let line = String::from_utf8_lossy(raw);
376        // Per `gitattributes(5)`, `#` only starts a comment when it's
377        // the first non-whitespace character on the line — patterns like
378        // `\#` are valid and must not be cropped here.
379        let body = line.trim();
380        if body.is_empty() || body.starts_with('#') || body.starts_with("[attr]") {
381            continue;
382        }
383        let mut tokens = body.split_whitespace();
384        let Some(pattern) = tokens.next() else {
385            continue;
386        };
387        let mut filter: Option<bool> = None;
388        let mut lockable = false;
389        for tok in tokens {
390            if tok == "filter=lfs" {
391                filter = Some(true);
392            } else if tok == "-filter" || tok == "!filter" || tok.starts_with("-filter=") {
393                filter = Some(false);
394            } else if tok == "lockable" {
395                lockable = true;
396            }
397        }
398        if let Some(tracked) = filter {
399            listing.patterns.push(PatternEntry {
400                pattern: pattern.to_owned(),
401                source: source.to_owned(),
402                tracked,
403                lockable,
404            });
405        }
406    }
407}
408
409fn walk_for_gitattributes(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
410    for entry in fs::read_dir(dir)? {
411        let entry = entry?;
412        let ft = entry.file_type()?;
413        let name = entry.file_name();
414        if name == OsStr::new(".git") {
415            continue;
416        }
417        let path = entry.path();
418        if ft.is_dir() {
419            walk_for_gitattributes(&path, out)?;
420        } else if ft.is_file() && name == OsStr::new(".gitattributes") {
421            out.push(path);
422        }
423    }
424    Ok(())
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use tempfile::TempDir;
431
432    #[test]
433    fn negated_macro_unsets_constituent_attrs() {
434        // Regression for t-fsck 11. `[attr]lfs ...` declares a macro,
435        // `*.dat lfs` applies it (so .dat files become filter=lfs),
436        // `b.dat !lfs` unsets it. After our intake-time rewrite
437        // expands `!lfs` into `!filter !diff !merge !text`, gix
438        // reports filter=None for b.dat and is_lfs_tracked returns
439        // false for it.
440        let s = AttrSet::from_buffer(
441            b"[attr]lfs filter=lfs diff=lfs merge=lfs -text\n\
442              *.dat lfs\n\
443              b.dat !lfs\n",
444        );
445        assert_eq!(s.value("a.dat", "filter").as_deref(), Some("lfs"));
446        assert_eq!(s.value("b.dat", "filter"), None);
447        assert!(s.is_lfs_tracked("a.dat"));
448        assert!(!s.is_lfs_tracked("b.dat"));
449    }
450
451    #[test]
452    fn empty_set_has_no_matches() {
453        let s = AttrSet::empty();
454        assert_eq!(s.value("foo.txt", "filter"), None);
455        assert!(!s.is_lfs_tracked("foo.txt"));
456        assert!(!s.is_lockable("foo.txt"));
457    }
458
459    #[test]
460    fn buffer_basename_match() {
461        let s = AttrSet::from_buffer(b"*.bin filter=lfs diff=lfs merge=lfs -text\n");
462        assert!(s.is_lfs_tracked("foo.bin"));
463        assert!(s.is_lfs_tracked("nested/dir/foo.bin"));
464        assert!(!s.is_lfs_tracked("foo.txt"));
465    }
466
467    #[test]
468    fn value_returns_raw_string() {
469        let s = AttrSet::from_buffer(b"*.txt eol=lf\n");
470        assert_eq!(s.value("a.txt", "eol").as_deref(), Some("lf"));
471    }
472
473    #[test]
474    fn unset_attribute_via_dash_prefix() {
475        let s = AttrSet::from_buffer(
476            b"*.txt filter=lfs\n\
477              special.txt -filter\n",
478        );
479        assert!(s.is_lfs_tracked("a.txt"));
480        // `special.txt -filter` removes the filter attribute → value is None.
481        assert_eq!(s.value("special.txt", "filter"), None);
482        assert!(!s.is_lfs_tracked("special.txt"));
483    }
484
485    #[test]
486    fn lockable_set_form() {
487        let s = AttrSet::from_buffer(b"*.psd lockable\n");
488        assert!(s.is_lockable("art/cover.psd"));
489        assert!(!s.is_lockable("readme.txt"));
490    }
491
492    #[test]
493    fn is_set_treats_false_value_as_unset() {
494        let s = AttrSet::from_buffer(
495            b"truthy lockable\n\
496              falsy  lockable=false\n",
497        );
498        assert!(s.is_set("truthy", "lockable"));
499        assert!(!s.is_set("falsy", "lockable"));
500    }
501
502    #[test]
503    fn rooted_pattern_only_matches_top_level() {
504        let s = AttrSet::from_buffer(b"/top.bin filter=lfs\n");
505        assert!(s.is_lfs_tracked("top.bin"));
506        assert!(!s.is_lfs_tracked("nested/top.bin"));
507    }
508
509    #[test]
510    fn workdir_loads_root_gitattributes() {
511        let tmp = TempDir::new().unwrap();
512        std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap();
513        std::fs::write(
514            tmp.path().join(".gitattributes"),
515            "*.bin filter=lfs diff=lfs merge=lfs -text\n",
516        )
517        .unwrap();
518
519        let s = AttrSet::from_workdir(tmp.path()).unwrap();
520        assert!(s.is_lfs_tracked("a.bin"));
521        assert!(s.is_lfs_tracked("sub/a.bin"));
522    }
523
524    #[test]
525    fn deeper_gitattributes_overrides_root() {
526        let tmp = TempDir::new().unwrap();
527        std::fs::create_dir_all(tmp.path().join("sub/.git_placeholder")).unwrap();
528        std::fs::write(tmp.path().join(".gitattributes"), "*.bin filter=lfs\n").unwrap();
529        std::fs::write(tmp.path().join("sub/.gitattributes"), "*.bin -filter\n").unwrap();
530
531        let s = AttrSet::from_workdir(tmp.path()).unwrap();
532        assert!(s.is_lfs_tracked("a.bin"));
533        // Deeper -filter wins for paths within sub/.
534        assert!(!s.is_lfs_tracked("sub/a.bin"));
535    }
536
537    #[test]
538    fn info_attributes_loaded_from_dotgit() {
539        let tmp = TempDir::new().unwrap();
540        std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap();
541        std::fs::write(
542            tmp.path().join(".git/info/attributes"),
543            "*.bin filter=lfs\n",
544        )
545        .unwrap();
546
547        let s = AttrSet::from_workdir(tmp.path()).unwrap();
548        assert!(s.is_lfs_tracked("a.bin"));
549    }
550
551    #[test]
552    fn list_lfs_patterns_recursive() {
553        // Mirror upstream t-track.sh's "track" test fixture: root
554        // .gitattributes + .git/info/attributes + nested per-directory
555        // files, with one nested dir adding `-filter` exclusions.
556        let tmp = TempDir::new().unwrap();
557        std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap();
558        std::fs::create_dir_all(tmp.path().join("a/b")).unwrap();
559        std::fs::write(
560            tmp.path().join(".gitattributes"),
561            "* text=auto\n\
562             *.jpg filter=lfs diff=lfs merge=lfs -text\n",
563        )
564        .unwrap();
565        std::fs::write(
566            tmp.path().join(".git/info/attributes"),
567            "*.mov filter=lfs -text\n",
568        )
569        .unwrap();
570        std::fs::write(
571            tmp.path().join("a/.gitattributes"),
572            "*.gif filter=lfs -text\n",
573        )
574        .unwrap();
575        std::fs::write(
576            tmp.path().join("a/b/.gitattributes"),
577            "*.png filter=lfs -text\n\
578             *.gif -filter -text\n\
579             *.mov -filter=lfs -text\n",
580        )
581        .unwrap();
582
583        let listing = list_lfs_patterns(tmp.path()).unwrap();
584        let tracked: Vec<(&str, &str)> = listing
585            .tracked()
586            .map(|p| (p.pattern.as_str(), p.source.as_str()))
587            .collect();
588        let excluded: Vec<(&str, &str)> = listing
589            .excluded()
590            .map(|p| (p.pattern.as_str(), p.source.as_str()))
591            .collect();
592
593        // info/attributes is loaded first, then root → deepest .gitattributes.
594        assert_eq!(
595            tracked,
596            vec![
597                ("*.mov", ".git/info/attributes"),
598                ("*.jpg", ".gitattributes"),
599                ("*.gif", "a/.gitattributes"),
600                ("*.png", "a/b/.gitattributes"),
601            ]
602        );
603        assert_eq!(
604            excluded,
605            vec![
606                ("*.gif", "a/b/.gitattributes"),
607                ("*.mov", "a/b/.gitattributes"),
608            ]
609        );
610    }
611
612    #[test]
613    fn list_lfs_patterns_skips_macros_and_comments() {
614        let tmp = TempDir::new().unwrap();
615        std::fs::write(
616            tmp.path().join(".gitattributes"),
617            "[attr]binary -diff -merge -text\n\
618             # *.jpg filter=lfs\n\
619             *.bin filter=lfs -text\n",
620        )
621        .unwrap();
622        let listing = list_lfs_patterns(tmp.path()).unwrap();
623        let tracked: Vec<&PatternEntry> = listing.tracked().collect();
624        assert_eq!(tracked.len(), 1);
625        assert_eq!(tracked[0].pattern, "*.bin");
626    }
627
628    #[test]
629    fn list_picks_up_lockable_attribute() {
630        let tmp = TempDir::new().unwrap();
631        std::fs::write(
632            tmp.path().join(".gitattributes"),
633            "*.psd filter=lfs diff=lfs merge=lfs lockable\n\
634             *.bin filter=lfs diff=lfs merge=lfs\n",
635        )
636        .unwrap();
637        let listing = list_lfs_patterns(tmp.path()).unwrap();
638        assert_eq!(listing.patterns.len(), 2);
639        assert_eq!(listing.patterns[0].pattern, "*.psd");
640        assert!(listing.patterns[0].lockable);
641        assert_eq!(listing.patterns[1].pattern, "*.bin");
642        assert!(!listing.patterns[1].lockable);
643    }
644
645    #[test]
646    fn bang_filter_treated_as_excluded() {
647        let tmp = TempDir::new().unwrap();
648        std::fs::write(
649            tmp.path().join(".gitattributes"),
650            "*.dat filter=lfs\n\
651             a.dat !filter\n",
652        )
653        .unwrap();
654        let listing = list_lfs_patterns(tmp.path()).unwrap();
655        assert_eq!(listing.patterns.len(), 2);
656        assert!(listing.patterns[0].tracked);
657        assert_eq!(listing.patterns[1].pattern, "a.dat");
658        assert!(!listing.patterns[1].tracked);
659    }
660
661    #[test]
662    fn workdir_skips_dotgit_directory() {
663        // A .gitattributes inside .git/ must NOT be picked up — only
664        // .git/info/attributes is, and it's loaded explicitly above.
665        let tmp = TempDir::new().unwrap();
666        std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
667        std::fs::write(tmp.path().join(".git/.gitattributes"), "*.bin filter=lfs\n").unwrap();
668
669        let s = AttrSet::from_workdir(tmp.path()).unwrap();
670        assert!(!s.is_lfs_tracked("a.bin"));
671    }
672}