Skip to main content

git_xcrypt/git/
attributes.rs

1//! The managed section of `.gitattributes`.
2//!
3//! The section holds one static line, `* filter=git-xcrypt`, and the whole
4//! security guarantee rests on it. It does not depend on the contents of
5//! `.git-xcrypt`, so it cannot drift from it — that is the entire point of the
6//! catch-all construction.
7//!
8//! Everything below that line is **cosmetic** in the sense that letting it go
9//! stale never stores a secret in the clear. It is not cosmetic in the sense of
10//! being optional: `-text` is what keeps git's own CRLF conversion off the
11//! ciphertext. Git applies that conversion to the *output* of the clean filter,
12//! so on a path where some other rule sets `text` — a user's own `*.env text`
13//! line is entirely ordinary — the conversion eats the `CR` bytes inside the
14//! ciphertext. `git add` still exits 0, the damaged blob is committed, and the
15//! loss only surfaces at the next checkout as a failed authentication tag, with
16//! the plaintext already gone.
17//!
18//! So the rendered lines have to cover **exactly** the set of paths the filter
19//! encrypts. Neither direction is free: too narrow leaves the hole above, too
20//! broad turns line-ending conversion off for files that are not encrypted at
21//! all. The two syntaxes make that harder than it sounds — see [`translate`].
22
23use std::fs;
24use std::path::{Path, PathBuf};
25
26use crate::git::repo::{ATTRIBUTES_FILE, CONFIG_FILE, DRIVER, KEY_ENVELOPE_DIR, git_spelling};
27use crate::rules::declaration::Config;
28use crate::{Error, Result};
29
30/// Opens the section this tool owns.
31const BEGIN: &str = "# >>> git-xcrypt >>>";
32
33/// Closes it. Everything outside the pair belongs to the user.
34const END: &str = "# <<< git-xcrypt <<<";
35
36/// The line the filter actually hangs on.
37///
38/// Static by design: it names no pattern, so changing `.git-xcrypt` never makes
39/// it stale. The filter is invoked for every file and decides for itself.
40/// The one line the whole guarantee hangs on. Public so `lock` can check for
41/// it without guessing at its spelling: git reads a missing attribute exactly
42/// as it reads a missing driver, as no filter at all.
43pub const CATCH_ALL: &str = "* filter=git-xcrypt";
44
45/// Renders the per-pattern lines for `config`.
46///
47/// An encrypted path gets `-text diff=git-xcrypt`; a path a negation took back
48/// out gets `!text !diff`, which restores git's defaults for it. Leaving the
49/// negation unrendered would keep `-text` on a file that is stored in the clear,
50/// so git would stop managing its line endings.
51///
52/// Two resolution rules have to be reconciled, and each one dictates part of the
53/// layout:
54///
55/// * **Selection is last match, in both.** git takes the last matching line and
56///   so does [`Config::decide`], so the two kinds of line are emitted strictly
57///   in the order of `.git-xcrypt`. Grouping them by kind — negations last, as
58///   an earlier version did — silently inverted `!secrets/README.md` written
59///   *above* `secrets/`, leaving an encrypted file without `-text`.
60/// * **`binary` is sticky in [`Config::decide`] and positional in git.** A
61///   declaration anywhere suppresses the diff driver for the path, so those
62///   patterns get a trailing `-diff` line: last, and naming only `diff`, so the
63///   `-text` established above it survives.
64///
65/// Finally, the files needed to bootstrap — see [`crate::rules::declaration::is_never_encrypted`] —
66/// get their defaults back if any pattern reached them, because they are stored
67/// in the clear whatever the patterns say.
68///
69/// Within each group the order is the input's, so the section is a pure function
70/// of the configuration and two runs produce the same file.
71#[must_use]
72pub fn render_lines(config: &Config, rendering: Rendering) -> Vec<String> {
73    let fold = match rendering {
74        // One line, and nothing about it depends on the declaration — which is
75        // exactly what makes a stale section impossible in this mode.
76        Rendering::Global => return vec![GLOBAL_LINE.to_string()],
77        Rendering::PerPattern { fold_case } => fold_case,
78    };
79    let mut lines: Vec<String> = Vec::new();
80    let mut suppressed: Vec<String> = Vec::new();
81
82    for pattern in config.patterns() {
83        for spelling in translate(pattern.source, fold) {
84            if pattern.negated {
85                lines.push(format!("{spelling} !text !diff"));
86            } else {
87                lines.push(format!("{spelling} filter={DRIVER} -text diff={DRIVER}"));
88                if pattern.suppress_diff && !suppressed.contains(&spelling) {
89                    suppressed.push(spelling);
90                }
91            }
92        }
93    }
94
95    // A repeated line is noise, but only the *last* copy may be kept: an earlier
96    // one could otherwise outlive a line between them that says the opposite.
97    let mut seen: Vec<&String> = Vec::new();
98    let mut deduplicated: Vec<String> = Vec::new();
99    for line in lines.iter().rev() {
100        if !seen.contains(&line) {
101            seen.push(line);
102            deduplicated.push(line.clone());
103        }
104    }
105    deduplicated.reverse();
106
107    deduplicated.extend(
108        suppressed
109            .into_iter()
110            .map(|pattern| format!("{pattern} -diff")),
111    );
112    deduplicated.extend(bootstrap_exclusions(config, fold));
113    deduplicated
114}
115
116/// Lines putting git's defaults back on the files that bootstrap the tool.
117///
118/// `.gitattributes`, `.git-xcrypt` and the envelope directory are never
119/// encrypted, whatever the patterns say — git needs the first to know to call us
120/// at all. A pattern broad enough to name them would otherwise leave `-text` on
121/// a file that is stored in the clear, and point a decrypting diff driver at it
122/// once S-05 registers one. The lines are emitted only when a pattern actually
123/// reaches them, so an ordinary configuration never carries them.
124///
125/// Folded like every other line here, because [`crate::rules::declaration::is_never_encrypted`]
126/// compares these three names with ASCII case folded too. A line narrower than
127/// the exclusion would put `-text` and a decrypting diff driver on a file that is
128/// stored in the clear; a line broader than it would take them off one that is
129/// not. Both halves have to move together.
130///
131/// **The probes are representatives, not an enumeration, and the gap is known
132/// and measured (2026-08-05).** `sub/.gitattributes` stands in for "a nested
133/// attributes file", so a pattern that reaches one only under its own directory
134/// — `secrets/` reaching `secrets/.gitattributes` — emits no exclusion, and
135/// that file carries `-text diff=git-xcrypt` while stored in the clear.
136/// Measured on git 2.55 with `core.autocrlf=true`: the file round-trips byte
137/// for byte, `git status` stays clean, `git diff` renders normally (the
138/// textconv driver passes plain text through), and git itself strips the `CR`
139/// of a CRLF-spelled attributes line when parsing, so even the un-managed line
140/// endings change nothing the file *does*. The only observable effect is that
141/// git stops normalising that one clear file's line endings. Enumerating
142/// faithfully would need the working tree at render time — the section is a
143/// pure function of the configuration on purpose — and emitting the exclusion
144/// unconditionally would rewrite every generated file to cover a state with no
145/// measured cost.
146fn bootstrap_exclusions(config: &Config, fold: bool) -> Vec<String> {
147    let mut lines = Vec::new();
148
149    // `.gitattributes` is excluded by basename at any depth, the other two only
150    // where they are read from.
151    let reached = |path: &str| config.decide_ignoring_exclusions(path.as_bytes()).encrypt;
152
153    if reached(ATTRIBUTES_FILE) || reached(&format!("sub/{ATTRIBUTES_FILE}")) {
154        lines.push(format!("**/{} !text !diff", folded(ATTRIBUTES_FILE, fold)));
155    }
156    if reached(CONFIG_FILE) {
157        lines.push(format!("/{} !text !diff", folded(CONFIG_FILE, fold)));
158    }
159    if reached(&format!("{KEY_ENVELOPE_DIR}/recipient")) {
160        lines.push(format!(
161            "/{}/** !text !diff",
162            folded(KEY_ENVELOPE_DIR, fold)
163        ));
164    }
165    lines
166}
167
168/// Spells one `.git-xcrypt` pattern the way `.gitattributes` needs it.
169///
170/// Returns every spelling the pattern needs — one or two lines, or none for a
171/// pattern with nothing left to render. Four differences between the two
172/// syntaxes matter, all measured against git 2.55:
173///
174/// * **A pattern with no slash floats; one with a slash is anchored.** That rule
175///   is the same in both files, but the translation itself introduces slashes,
176///   so it has to be undone deliberately: `secrets/` matches `app/secrets/x` in
177///   `.gitignore`, while a bare `secrets/**` in `.gitattributes` reaches only
178///   the root one. Hence the `**/` prefix on anything the trailing slash did not
179///   already anchor. Getting this wrong is not cosmetic — see the module doc.
180/// * **A trailing `/` matches a directory in `.gitignore` and nothing at all in
181///   `.gitattributes`**, so the subtree has to be spelled `.../**`.
182/// * **A pattern without a trailing slash can still match a directory**, and
183///   this tool encrypts everything under a matched directory. That needs a
184///   second line: `*.env` covers the file `a.env` and, separately, everything
185///   inside a directory named `a.env`.
186/// * **A leading `/` is kept.** It anchors in both files, and dropping it would
187///   let `/build.env` float to every subdirectory — `-text` on files that are
188///   not encrypted.
189///
190/// Whitespace ends a pattern in `.gitattributes` unless the whole pattern is
191/// C-quoted — which is also how `.git-xcrypt` has spelled such a pattern since
192/// 2026-08-05, so the two files now close a space the same way and [`spell`]
193/// only has to put the quotes back around what the parser took them off.
194///
195/// Two openings send git somewhere other than its pattern matcher, and quoting
196/// rescues neither: a line opening with `[attr]` is a macro definition, and one
197/// opening with `!` is discarded outright (`warning: Negative patterns are
198/// ignored in git attributes`). Both are given a leading `**/` or `/` by
199/// [`guard`] instead, which means exactly what the unprefixed spelling meant in
200/// a root `.gitattributes`.
201fn translate(pattern: &str, fold: bool) -> Vec<String> {
202    let directory_only = pattern.ends_with('/');
203    let core = pattern.strip_suffix('/').unwrap_or(pattern);
204    if core.trim_matches('/').is_empty() {
205        return Vec::new();
206    }
207
208    // `.gitignore`: a slash anywhere but at the very end anchors the pattern to
209    // the root; without one it matches at any depth.
210    let anchored = core.contains('/');
211
212    let mut spellings = Vec::with_capacity(2);
213    if !directory_only {
214        spellings.push(spell(&folded(&guard(core.to_string(), anchored), fold)));
215    }
216    spellings.push(spell(&folded(
217        &guard(
218            if anchored {
219                format!("{core}/**")
220            } else {
221                format!("**/{core}/**")
222            },
223            anchored,
224        ),
225        fold,
226    )));
227    spellings
228}
229
230/// Spells a pattern so it matches either case of every ASCII letter.
231///
232/// The other half of open decision 13, settled on 2026-08-05, and it is not
233/// optional: [`crate::rules::declaration::MATCHING`] folds ASCII case unconditionally, so a
234/// rendered line that did not would be **narrower** than the filter — and the
235/// narrow direction is the one measured eating 34 `CR` bytes out of a 2 MB
236/// ciphertext and losing the file at checkout. Emitting the fold rather than
237/// leaning on `core.ignorecase` is what makes the two agree on every machine:
238/// measured on git 2.55, `**/secrets/**` answers `unspecified` for
239/// `SEcrets/db.txt` where the setting is false, while
240/// `**/[sS][eE][cC][rR][eE][tT][sS]/**` answers `unset` whatever it is set to.
241///
242/// Four things in a pattern are not plain letters, and each is left meaning what
243/// it meant:
244///
245/// * **a glob escape.** `\s` is the literal `s`, so it becomes `[sS]` — the
246///   backslash was doing nothing a character class does not. `\*` and every
247///   other escaped metacharacter is passed through with its backslash.
248/// * **a character class.** `[a-z]` cannot become `[[aA]-[zZ]]`; the counterpart
249///   is added *inside* the brackets instead, giving `[a-zA-Z]`. A negated class
250///   gets it too, which is right: folding `[!a]` must stop it matching `A`.
251/// * **a POSIX class**, `[[:alpha:]]`, whose members are named rather than
252///   spelled, so there is nothing to rewrite and it is copied whole — except
253///   the two classes that *are* a case: `[:upper:]` and `[:lower:]` each gain
254///   their counterpart, because the selection side folds them too. Measured:
255///   `gix-glob` under `Case::Fold` lowercases the candidate first, so
256///   `[[:upper:]]dir/` selects `xdir/a.env` — and a verbatim copy answers
257///   `unspecified` for it at `core.ignorecase=false`, the narrower-than-the-
258///   filter direction that costs the file.
259/// * **anything outside ASCII**, which is copied byte for byte. That is the
260///   documented boundary — see [`crate::rules::declaration::MATCHING`].
261///
262/// Quoting is not this function's business. It runs before [`spell`], which puts
263/// the quotes back around a pattern that needs them, and `[`, `]` and `-` are
264/// ordinary characters to git's C-unquoting. It runs *after* [`guard`] for the
265/// opposite reason: `guard` recognises a literal `[attr]` opening, and folding
266/// first would turn it into `[attrATTR]` and hide it.
267/// How the managed section spells what it protects.
268///
269/// Two shapes, and the choice is a trade this project measured rather than
270/// guessed.
271///
272/// `PerPattern` writes a line per declared pattern, each naming the filter, the
273/// `-text` that protects the ciphertext and the diff driver — the shape
274/// `git-crypt` users will recognise. It is what `sync` writes when nothing asks
275/// otherwise, and it confines the diff driver to declared paths.
276///
277/// `Global` is one line covering the whole repository. `init` writes it, so a
278/// repository works correctly before `sync` has ever run and nothing can go
279/// stale; `sync --global` puts it back. Its cost is the diff driver on every
280/// file.
281///
282/// The cost that decides between them is the `diff` driver, and it is a process
283/// per blob — git has no long-running protocol for `textconv` the way it has one
284/// for filters. Measured on git 2.55, 2026-08-06, against the same repository
285/// with the driver unregistered:
286///
287/// | files in the diff | global | per pattern |
288/// | --- | --- | --- |
289/// | 5 | 72 ms | 21 ms |
290/// | 20 | 201 ms | 22 ms |
291/// | 100 | 899 ms | 25 ms |
292/// | 1000 | 8461 ms | 23 ms |
293///
294/// So an everyday diff pays nothing anyone notices, and a thousand-file review
295/// pays eight seconds. `init` writes `Global` so a fresh repository is correct
296/// with no second command; `sync` writes `PerPattern`, which is what a
297/// repository settles into once anyone runs it.
298///
299/// The other half of `Global` is `-text` on every path, which stops git
300/// normalising line endings anywhere in the repository. That is the price of
301/// needing no `sync`: the same attribute is what keeps git's CRLF conversion
302/// off the ciphertext, and one line cannot say it for some paths only.
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum Rendering {
305    /// One line, covering everything. Correct with no `sync` in the flow.
306    Global,
307    /// One line per declared pattern, with ASCII case folded when asked.
308    PerPattern { fold_case: bool },
309}
310
311/// The one line `Rendering::Global` emits after the catch-all.
312const GLOBAL_LINE: &str = "* -text diff=git-xcrypt";
313
314/// [`render_lines`] in whichever spelling the file already uses.
315///
316/// For the three commands that repair this section without being asked to
317/// change it — `init`, `unlock`, `lock`. Only `sync` decides the spelling, and
318/// a repair that silently picked the other one would undo that decision:
319/// measured before this existed, an ordinary `unlock` rewrote a section written
320/// by `sync --ignorecase` back to the literal form and left `git status` dirty.
321///
322/// A file that matches neither spelling is out of date either way, and gets the
323/// literal one — the same default `sync` uses when nothing asks otherwise.
324#[must_use]
325pub fn render_lines_as_written(path: &std::path::Path, config: &Config) -> Vec<String> {
326    for rendering in ACCEPTED {
327        let lines = render_lines(config, rendering);
328        if desired(path, &lines).is_ok_and(|(existing, wanted)| existing == wanted) {
329            return lines;
330        }
331    }
332    render_lines(config, Rendering::Global)
333}
334
335/// Every spelling of the section this build considers current.
336///
337/// Order matters only for the tie nobody can hit — a repository declaring
338/// nothing renders the same either way. `Global` comes first because it is what
339/// `init` wrote, so it is what a repository nobody has run `sync` in will
340/// match.
341pub const ACCEPTED: [Rendering; 3] = [
342    Rendering::Global,
343    Rendering::PerPattern { fold_case: false },
344    Rendering::PerPattern { fold_case: true },
345];
346
347/// [`fold_case`] when asked, the pattern untouched when not.
348///
349/// The choice belongs to `sync --ignorecase` and is threaded through every
350/// renderer rather than read from anywhere, because four other commands write
351/// this same section and a setting only one of them knew would be undone by the
352/// next one — measured: a hand-written literal section was rewritten folded by
353/// an ordinary `unlock`, leaving `git status` dirty.
354fn folded(pattern: &str, fold: bool) -> String {
355    if fold {
356        fold_case(pattern)
357    } else {
358        pattern.to_string()
359    }
360}
361
362fn fold_case(pattern: &str) -> String {
363    let mut out = String::with_capacity(pattern.len() * 4);
364    let mut index = 0;
365
366    while index < pattern.len() {
367        let character = pattern[index..]
368            .chars()
369            .next()
370            .expect("index sits on a character boundary");
371
372        if character == '\\' {
373            match pattern[index + 1..].chars().next() {
374                Some(escaped) if escaped.is_ascii_alphabetic() => {
375                    push_either_case(&mut out, escaped);
376                    index += 1 + escaped.len_utf8();
377                }
378                Some(escaped) => {
379                    out.push('\\');
380                    out.push(escaped);
381                    index += 1 + escaped.len_utf8();
382                }
383                // A trailing backslash escapes nothing. The parser refuses one
384                // in `.git-xcrypt`, so this is only ever reached through a
385                // quoted pattern, and passing it through is what keeps the two
386                // files spelling the same name.
387                None => {
388                    out.push('\\');
389                    index += 1;
390                }
391            }
392            continue;
393        }
394
395        if character == '[' {
396            if let Some((folded, after)) = fold_class(pattern, index) {
397                out.push_str(&folded);
398                index = after;
399                continue;
400            }
401            // Unterminated: wildmatch reads the `[` as an ordinary character,
402            // and so must this.
403            out.push('[');
404            index += 1;
405            continue;
406        }
407
408        if character.is_ascii_alphabetic() {
409            push_either_case(&mut out, character);
410        } else {
411            out.push(character);
412        }
413        index += character.len_utf8();
414    }
415
416    out
417}
418
419/// Writes `letter` as the two-character class matching either of its cases.
420fn push_either_case(out: &mut String, letter: char) {
421    out.push('[');
422    out.push(letter.to_ascii_lowercase());
423    out.push(letter.to_ascii_uppercase());
424    out.push(']');
425}
426
427/// The same letter in the other case.
428fn flip_case(letter: char) -> char {
429    if letter.is_ascii_lowercase() {
430        letter.to_ascii_uppercase()
431    } else {
432        letter.to_ascii_lowercase()
433    }
434}
435
436/// Folds one bracket expression starting at `start`, returning it and its end.
437///
438/// `None` when the bracket is never closed, which wildmatch reads as a literal
439/// `[`. Members are kept exactly as written and their counterparts appended, so
440/// the class grows rather than changing shape: `[a-z_]` becomes `[a-z_A-Z]`.
441fn fold_class(pattern: &str, start: usize) -> Option<(String, usize)> {
442    let bytes = pattern.as_bytes();
443    let mut index = start + 1;
444    let mut body = String::new();
445    let mut extra = String::new();
446
447    // A leading `!` or `^` negates; a `]` straight after either is a member.
448    if matches!(bytes.get(index), Some(b'!' | b'^')) {
449        body.push(char::from(bytes[index]));
450        index += 1;
451    }
452    if bytes.get(index) == Some(&b']') {
453        body.push(']');
454        index += 1;
455    }
456
457    while index < bytes.len() {
458        if bytes[index] == b']' {
459            // A literal `-` is a member only when nothing follows it, so the
460            // case counterparts have to slide in *before* it. Appending them
461            // after made `[a-]` render as `[a-A]` — measured on git 2.55 with
462            // `core.ignorecase=false`, that line matches `a.env` and neither
463            // `A.env` nor `-.env`, both of which the filter selects: encrypted
464            // paths with no `-text`, the half of the covering rule that costs
465            // the file. An *escaped* trailing dash stays where it is — it
466            // cannot open a range.
467            let (kept, trailing_dash) = if body.ends_with('-') && !body.ends_with("\\-") {
468                (&body[..body.len() - 1], true)
469            } else {
470                (body.as_str(), false)
471            };
472            let mut out = String::with_capacity(body.len() + extra.len() + 2);
473            out.push('[');
474            out.push_str(kept);
475            out.push_str(&extra);
476            if trailing_dash {
477                out.push('-');
478            }
479            out.push(']');
480            return Some((out, index + 1));
481        }
482
483        // `[:alpha:]` and friends name their members, so there is nothing to
484        // add — and a `[` that opens one is not a member either. Two of the
485        // names *are* a case, and selection folds them: under `Case::Fold`
486        // `gix-glob` lowercases the candidate before the class test and lets
487        // `[:upper:]` accept a lowercase letter, so each of the pair matches
488        // every ASCII letter — exactly what the named counterpart adds here.
489        if bytes[index] == b'[' && bytes.get(index + 1) == Some(&b':') {
490            let end = index + pattern[index..].find(":]")? + 2;
491            body.push_str(&pattern[index..end]);
492            match &pattern[index + 2..end - 2] {
493                "upper" => extra.push_str("[:lower:]"),
494                "lower" => extra.push_str("[:upper:]"),
495                _ => {}
496            }
497            index = end;
498            continue;
499        }
500
501        if bytes[index] == b'\\' {
502            let escaped = pattern[index + 1..].chars().next()?;
503            body.push('\\');
504            body.push(escaped);
505            if escaped.is_ascii_alphabetic() {
506                extra.push('\\');
507                extra.push(flip_case(escaped));
508            }
509            index += 1 + escaped.len_utf8();
510            continue;
511        }
512
513        let low = pattern[index..]
514            .chars()
515            .next()
516            .expect("index sits on a character boundary");
517        let after_low = index + low.len_utf8();
518
519        // A range, but only where the `-` really separates two members: a `-`
520        // immediately before the closing bracket is itself a member.
521        if bytes.get(after_low) == Some(&b'-')
522            && bytes.get(after_low + 1).is_some_and(|byte| *byte != b']')
523        {
524            let high = pattern[after_low + 1..].chars().next()?;
525            body.push(low);
526            body.push('-');
527            body.push(high);
528            // Only a range whose ends share a case has a counterpart range; the
529            // ends of anything else are not letters in the same alphabet and
530            // flipping them could invert the range.
531            if low.is_ascii_alphabetic()
532                && high.is_ascii_alphabetic()
533                && low.is_ascii_lowercase() == high.is_ascii_lowercase()
534            {
535                extra.push(flip_case(low));
536                extra.push('-');
537                extra.push(flip_case(high));
538            }
539            index = after_low + 1 + high.len_utf8();
540            continue;
541        }
542
543        body.push(low);
544        if low.is_ascii_alphabetic() {
545            extra.push(flip_case(low));
546        }
547        index = after_low;
548    }
549
550    None
551}
552
553/// What git reads as the start of a macro definition rather than a pattern.
554const MACRO_PREFIX: &str = "[attr]";
555
556/// Keeps a spelling out of the branches git takes before it ever matches it.
557///
558/// An anchored pattern already carries a slash, so a leading one only makes
559/// explicit what a root `.gitattributes` does anyway; a floating one gets the
560/// `**/` that git documents as equivalent to no prefix at all.
561///
562/// Two openings need it, and in both cases the line is lost in silence without
563/// it — the pattern simply never reaches git's matcher, so the `-text` this
564/// renderer exists to place is not there:
565///
566/// * `[attr]`, which git reads as a macro definition rather than a pattern;
567/// * `!`, which git discards with `warning: Negative patterns are ignored in
568///   git attributes`. A name whose leading `!` is part of it became spellable on
569///   2026-08-05, when quoting arrived in `.git-xcrypt` and the parser stopped
570///   reading a quoted `!` as the negation marker — so `"!weird.env"` selects a
571///   real file, and `.gitattributes` has to cover it.
572///
573/// **Quoting rescues neither**, which is why this is a prefix rather than a
574/// stanza in [`spell`]. Measured on git 2.55: the macro check runs before the
575/// unquoting, and `"!weird.env"` draws the negative-pattern warning too, so git
576/// tests for `!` *after* unquoting. `**/!weird.env` and `/!secrets/x.env` were
577/// measured resolving `text: unset` and `diff: xc` for the paths they name.
578fn guard(spelling: String, anchored: bool) -> String {
579    if !spelling.starts_with(MACRO_PREFIX) && !spelling.starts_with('!') {
580        return spelling;
581    }
582    if anchored {
583        format!("/{spelling}")
584    } else {
585        format!("**/{spelling}")
586    }
587}
588
589/// One pattern, escaped and quoted the way git's attribute parser reads it.
590///
591/// The pattern arrives literal: `.git-xcrypt` has already unwrapped its own
592/// quoting, so every backslash still standing here is wildmatch's — and
593/// wildmatch is the same engine on both sides, so it is passed through
594/// untouched, doubled only where the C-quoting layer would otherwise eat it.
595fn spell(pattern: &str) -> String {
596    // Three shapes have to be quoted, and each one is a silent failure
597    // otherwise: whitespace ends the pattern and turns the rest into
598    // attributes; a leading quote sends git into its C-quoting parser
599    // mid-pattern; a leading `#` makes git read the whole line as a comment, so
600    // the `-text` for that path would simply not be there. A leading `[attr]`
601    // and a leading `!` are handled by `guard` rather than here, because quoting
602    // does not rescue either — measured on git 2.55, the macro check runs before
603    // the unquoting, and the negative-pattern check runs after it.
604    if !pattern.contains(char::is_whitespace)
605        && !pattern.starts_with('"')
606        && !pattern.starts_with('#')
607    {
608        return pattern.to_string();
609    }
610
611    let mut quoted = String::with_capacity(pattern.len() + 2);
612    quoted.push('"');
613    for character in pattern.chars() {
614        match character {
615            '"' | '\\' => {
616                quoted.push('\\');
617                quoted.push(character);
618            }
619            '\t' => quoted.push_str("\\t"),
620            '\r' => quoted.push_str("\\r"),
621            '\n' => quoted.push_str("\\n"),
622            other => quoted.push(other),
623        }
624    }
625    quoted.push('"');
626    quoted
627}
628
629/// Renders the body of the managed section, LF-terminated.
630#[must_use]
631pub fn render_section(extra_lines: &[String]) -> String {
632    render_section_with(extra_lines, "\n")
633}
634
635/// Renders the body of the managed section with a chosen line terminator.
636///
637/// `ending` is `"\n"` everywhere a file is being created from nothing, and
638/// `"\r\n"` when the file being edited already spells its lines that way — see
639/// [`line_ending_of`] for why that is not cosmetic.
640#[must_use]
641pub fn render_section_with(extra_lines: &[String], ending: &str) -> String {
642    let mut out = String::new();
643    out.push_str(BEGIN);
644    out.push_str(ending);
645    out.push_str(CATCH_ALL);
646    out.push_str(ending);
647    for line in extra_lines {
648        out.push_str(line);
649        out.push_str(ending);
650    }
651    out.push_str(END);
652    out.push_str(ending);
653    out
654}
655
656/// The line terminator a rewrite of this file should reproduce.
657///
658/// **Not always LF, and that is a correctness matter rather than tidiness.**
659/// Nothing in the managed section declares `.gitattributes` itself, so under
660/// `core.autocrlf=true` — what Git for Windows installs by default — git checks
661/// that file out with CRLF. Rendering the replacement with LF then made the
662/// comparison in [`desired`] fail on a section that was current in every way git
663/// can see, and the consequences were all in the wrong direction:
664///
665/// * `sync --check`, which exists to be a CI gate, exited `1` on a healthy
666///   repository — and a gate that fires on the platform's default configuration
667///   is a gate that gets switched off, the same argument that won `status` its
668///   own exit code `6`;
669/// * `status` printed a note saying the per-pattern lines were out of date and
670///   the ciphertext was at risk of silent corruption, which was false;
671/// * running `sync` to settle it wrote an LF section into a CRLF file, leaving
672///   `git status` dirty with no way out: the next checkout put the CRLF back.
673///
674/// Measured on git 2.55: for a CRLF-spelled section `git check-attr filter text
675/// diff` answers `git-xcrypt`, `unset`, `git-xcrypt` — identical to the LF
676/// spelling. Git strips the `CR` itself, so both spellings enforce the same
677/// thing and the file's own convention is the one worth keeping.
678///
679/// The section's opening marker decides, because the section is the only part of
680/// the file a rewrite touches. With no section yet the file's first line decides,
681/// and an empty file gets LF.
682fn line_ending_of(contents: &str) -> &'static str {
683    let sample = marker_line(contents, BEGIN)
684        .map(|(begin, after)| &contents[begin..after])
685        .or_else(|| contents.split_inclusive('\n').next())
686        .unwrap_or_default();
687
688    if sample.ends_with("\r\n") {
689        "\r\n"
690    } else {
691        "\n"
692    }
693}
694
695/// Whether `contents` shows any sign of a managed section.
696///
697/// Deliberately looser than [`upsert`]'s boundary detection: this answer decides
698/// whether `init` refuses to generate a second key, and there the safe direction
699/// is to see a trace that is not there rather than to miss one that is.
700#[must_use]
701pub fn has_section(contents: &str) -> bool {
702    contents.contains(BEGIN)
703}
704
705/// Where the line that is exactly `marker` starts, and where the line after it
706/// begins.
707///
708/// Matching a marker as a whole line rather than as a substring is what keeps
709/// `# >>> git-xcrypt >>> (legacy)` in a user's own comment from being taken for
710/// the start of our section — which would put everything after it inside the
711/// region the next write replaces.
712fn marker_line(contents: &str, marker: &str) -> Option<(usize, usize)> {
713    let mut offset = 0;
714    for line in contents.split_inclusive('\n') {
715        let text = line.strip_suffix('\n').unwrap_or(line);
716        let text = text.strip_suffix('\r').unwrap_or(text);
717        if text == marker {
718            return Some((offset, offset + line.len()));
719        }
720        offset += line.len();
721    }
722    None
723}
724
725/// Replaces the managed section in `contents`, or appends one.
726///
727/// Everything outside the markers is preserved byte for byte. A file with an
728/// opening marker and no closing one is refused rather than guessed at: guessing
729/// the boundary would destroy the user's own attributes.
730///
731/// # Errors
732///
733/// [`Error::Config`] when the markers are unbalanced.
734pub fn upsert(contents: &str, section: &str) -> Result<String> {
735    let Some((begin, _)) = marker_line(contents, BEGIN) else {
736        if marker_line(contents, END).is_some() {
737            return Err(Error::Config(format!(
738                "{ATTRIBUTES}: found the closing git-xcrypt marker without the opening one; \
739                 fix it by hand so nothing of yours is lost",
740                ATTRIBUTES = crate::git::repo::ATTRIBUTES_FILE
741            )));
742        }
743        let mut out = contents.to_string();
744        if !out.is_empty() && !out.ends_with('\n') {
745            out.push('\n');
746        }
747        out.push_str(section);
748        return Ok(out);
749    };
750
751    // The closing marker is looked for after the opening one, so a stray copy
752    // above the section cannot shorten it.
753    let Some((_, after_end)) = marker_line(&contents[begin..], END) else {
754        return Err(Error::Config(format!(
755            "{ATTRIBUTES}: the git-xcrypt section is opened but never closed; \
756             fix it by hand so nothing of yours is lost",
757            ATTRIBUTES = crate::git::repo::ATTRIBUTES_FILE
758        )));
759    };
760
761    // A *second* balanced pair is refused for the same reason an unbalanced one
762    // is: this function rewrites the first region and leaves the rest alone, so
763    // a duplicate survives every `sync` while `sync --check` compares the result
764    // against the input, finds them equal and reports "up to date". Git takes
765    // the **last** matching attribute line, so the copy nobody is maintaining is
766    // the one that decides — and a stale `!text` on a path the filter still
767    // encrypts is the CRLF corruption this module's opening comment describes.
768    // A merge conflict on `.gitattributes` resolved by keeping both sides
769    // produces exactly this shape.
770    let rest = &contents[begin + after_end..];
771    if marker_line(rest, BEGIN).is_some() || marker_line(rest, END).is_some() {
772        return Err(Error::Config(format!(
773            "{ATTRIBUTES}: it carries more than one git-xcrypt section. Only the first \
774             would be kept up to date, and git takes the last matching line, so the \
775             stale copy would win. Delete all but one by hand, then run \
776             `git-xcrypt sync`.",
777            ATTRIBUTES = crate::git::repo::ATTRIBUTES_FILE
778        )));
779    }
780
781    let mut out = String::with_capacity(contents.len() + section.len());
782    out.push_str(&contents[..begin]);
783    out.push_str(section);
784    out.push_str(rest);
785    Ok(out)
786}
787
788/// Reads the attributes file at `path`, treating an absent one as empty.
789///
790/// # Errors
791///
792/// [`Error::Io`] when the file exists but cannot be read, [`Error::Config`] when
793/// it is not text. An unreadable file is never silently treated as an empty one:
794/// that would replace the user's own attributes with a bare managed section.
795pub fn read(path: &Path) -> Result<String> {
796    match fs::read_to_string(path) {
797        Ok(text) => Ok(text),
798        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
799        // `read_to_string` reports "stream did not contain valid UTF-8" as an
800        // I/O error, which tells a user nothing about which file is at fault.
801        Err(err) if err.kind() == std::io::ErrorKind::InvalidData => Err(Error::Config(format!(
802            "{}: not valid UTF-8, so the managed section cannot be edited safely; \
803             fix the file by hand",
804            path.display()
805        ))),
806        Err(err) => Err(Error::Io(err)),
807    }
808}
809
810/// What the attributes file at `path` should contain for `extra_lines`.
811///
812/// Split out from [`write_section`] so `sync --check` can compare without
813/// writing — the check and the write must never answer differently.
814///
815/// # Errors
816///
817/// [`Error::Io`] on a read failure, [`Error::Config`] on unbalanced markers.
818pub fn desired(path: &Path, extra_lines: &[String]) -> Result<(String, String)> {
819    let existing = read(path)?;
820    // The file's own spelling, not ours: see [`line_ending_of`]. A file that
821    // already uses LF — every repository on Unix, and every one this tool
822    // created — renders exactly as it always did.
823    let section = render_section_with(extra_lines, line_ending_of(&existing));
824    let updated = upsert(&existing, &section)?;
825    Ok((existing, updated))
826}
827
828/// Writes the managed section into the attributes file at `path`.
829///
830/// # Errors
831///
832/// [`Error::Io`] on a read or write failure, [`Error::Config`] on unbalanced
833/// markers.
834pub fn write_section(path: &Path, extra_lines: &[String]) -> Result<bool> {
835    let (existing, updated) = desired(path, extra_lines)?;
836    if updated == existing {
837        return Ok(false);
838    }
839    // Never `fs::write`: truncating this file is what turns encryption off, and
840    // it also loses whatever the user keeps outside our markers.
841    crate::util::atomic::write(path, updated.as_bytes())?;
842    Ok(true)
843}
844
845/// Whether the attributes file at `path` carries the catch-all line.
846///
847/// The one question that decides whether git invokes the filter at all, so both
848/// `lock` and `status` ask it — through the same function, because two spellings
849/// of "is the guarantee in place" is one too many. An absent file answers `false`
850/// rather than failing: it is missing the line as surely as an empty one is.
851///
852/// # Errors
853///
854/// [`Error::Io`] when the file exists but cannot be read, [`Error::Config`] when
855/// it is not text.
856pub fn catch_all_present(path: &Path) -> Result<bool> {
857    Ok(read(path)?.lines().any(|line| line.trim_end() == CATCH_ALL))
858}
859
860/// Lines outside the managed section that touch one of `axes`.
861///
862/// The catch-all is one line among many, and git takes the **last** match — so
863/// a line below the managed section saying `secrets/** -filter`, or setting
864/// `filter=lfs`, turns this tool off for those paths. Measured on git 2.55:
865/// `git check-attr filter` then reports `unset`, `git add` stores the plaintext,
866/// and `status` — which only looked for the catch-all line — called the
867/// repository healthy.
868///
869/// Reading only. The question "does any of this actually reach a declared path"
870/// is answered by [`FilterResolver`], which runs git's own attribute stack; what
871/// this function contributes is the text of the offending lines, so a report can
872/// show a reader what to delete instead of only telling them a path is
873/// unfiltered.
874///
875/// `axes` names the attributes worth looking for — `status` asks about `filter`
876/// alone, `sync` about the axes that can cost something. `diff` is deliberately
877/// on nobody's list: measured 2026-08-05, a foreign line setting `diff=lfs`,
878/// `-diff` or `diff` on a declared path costs a readable `git diff` and not one
879/// byte in the repository.
880///
881/// # Errors
882///
883/// [`Error::Io`] when the file exists but cannot be read, [`Error::Config`] when
884/// it is not text.
885pub fn foreign_lines_touching(path: &Path, axes: &[&str]) -> Result<Vec<String>> {
886    let text = read(path)?;
887    let mut inside = false;
888    let mut found = Vec::new();
889
890    for line in text.lines() {
891        let trimmed = line.trim_end().trim_end_matches('\r');
892        if trimmed == BEGIN {
893            inside = true;
894            continue;
895        }
896        if trimmed == END {
897            inside = false;
898            continue;
899        }
900        if inside || trimmed.is_empty() || trimmed.trim_start().starts_with('#') {
901            continue;
902        }
903        // The pattern is the first field; everything after it is attributes.
904        let attributes = trimmed
905            .split_once(char::is_whitespace)
906            .map(|(_, rest)| rest);
907        if attributes.is_some_and(|rest| {
908            rest.split_whitespace().any(|token| {
909                axes.iter().any(|axis| {
910                    let bare = token
911                        .strip_prefix('-')
912                        .or_else(|| token.strip_prefix('!'))
913                        .unwrap_or(token);
914                    bare == *axis || bare.starts_with(&format!("{axis}="))
915                })
916            })
917        }) {
918            found.push(trimmed.trim().to_string());
919        }
920    }
921    Ok(found)
922}
923
924/// Every `.gitattributes` under `root`, `.git` excluded.
925///
926/// **Not the resolver's discovery any more — since 2026-08-07 only
927/// [`attribute_files_under`] calls this.** [`AttributeResolver`] probes the
928/// ancestor chain of each resolved path instead, because this walk costs a
929/// `read_dir` per directory and a `file_type()` per entry and everything off
930/// the ancestors is inert for a resolution — measured at 220 ms per `git add`
931/// on a tree with a large build directory. The walk stays for the one question
932/// that genuinely is about the whole tree.
933///
934/// Iterative rather than recursive, for the same reason the history walk is: a
935/// working tree may be arbitrarily deep and a diagnostic command must not be the
936/// thing that crashes on it. Directories that will not open are skipped, exactly
937/// as git skips a file it cannot read — see [`FilterResolver`].
938///
939/// **Each directory's file is probed by name, never matched against the
940/// listing.** Git does the same — it `open`s `<dir>/.gitattributes` and lets
941/// the filesystem resolve the name — and the two ways differ exactly where it
942/// costs a file: on APFS and NTFS a file *stored* as `.GITATTRIBUTES` **is**
943/// the attributes file to git, measured on git 2.55 (`* -text` in
944/// `secrets/.GITATTRIBUTES` answered `text: unset` for `secrets/db.env`),
945/// while a listing comparison never saw it — so a `text` line in one converted
946/// the ciphertext with no gate firing anywhere. On a case-sensitive filesystem
947/// the same probe finds nothing, which is also exactly git. The probe works in
948/// a directory whose mode allows lookup but not listing, too — git never lists
949/// either.
950fn collect_attribute_files(root: &Path, out: &mut Vec<PathBuf>) {
951    let mut pending = vec![root.to_path_buf()];
952    while let Some(directory) = pending.pop() {
953        let file = directory.join(crate::git::repo::ATTRIBUTES_FILE);
954        // Never followed: a symbolic link out of the working tree would walk
955        // somewhere that is not this repository, and one pointing back into
956        // it would loop.
957        if fs::symlink_metadata(&file).is_ok_and(|metadata| metadata.is_file()) {
958            out.push(file);
959        }
960
961        let Ok(entries) = fs::read_dir(&directory) else {
962            continue;
963        };
964        for entry in entries.flatten() {
965            let Ok(kind) = entry.file_type() else {
966                continue;
967            };
968            if kind.is_symlink() {
969                continue;
970            }
971            if kind.is_dir() && entry.file_name() != std::ffi::OsStr::new(".git") {
972                pending.push(entry.path());
973            }
974        }
975    }
976}
977
978/// Every `.gitattributes` in the working tree under `work_tree`, sorted
979/// shallowest first, ties by path.
980///
981/// For questions about the **whole tree**, which a lazy resolver can no longer
982/// answer: the `status` note about foreign `filter` lines exists precisely to
983/// name an attributes file that reaches paths the index does not hold yet —
984/// a file that may sit in a directory with no tracked path, which is exactly
985/// the file no ancestor probe would ever visit. The sort is the resolver's own
986/// precedence order — shallowest first — so the note reads the same as it did
987/// when the resolver's source list fed it.
988#[must_use]
989pub fn attribute_files_under(work_tree: &Path) -> Vec<PathBuf> {
990    let mut files: Vec<PathBuf> = Vec::new();
991    collect_attribute_files(work_tree, &mut files);
992    files.sort_by_key(|path| (path.components().count(), path.clone()));
993    files
994}
995
996/// A `.gitattributes` whose working-tree file is gone but whose staged copy
997/// git still reads on the check-in path.
998///
999/// Git's `read_attr` tries the working-tree file first and, when it is not
1000/// there, reads the **index** copy — measured on git 2.55: with a
1001/// `secrets/** text` line staged in `.gitattributes` and the file deleted from
1002/// the working tree, `git add` still converted the filter's output (7003 `CR`
1003/// bytes eaten out of a 512 KiB ciphertext, exit 0, file unrecoverable at
1004/// checkout). A resolver that read only the working tree was blind to exactly
1005/// that — and deleting the file is the very move the check-in refusal's own
1006/// message can prompt, when it says to delete the offending *line*.
1007#[derive(Debug, Clone)]
1008pub struct StagedAttributes {
1009    /// The absolute path the file would occupy in the working tree.
1010    pub path: PathBuf,
1011    /// The staged contents.
1012    pub contents: Vec<u8>,
1013}
1014
1015/// The `.gitattributes` files git would read from the index for check-in.
1016///
1017/// One entry per `.gitattributes` recorded in the index whose working-tree file
1018/// is absent; a file that is present wins outright on the check-in side, so it
1019/// is not listed here at all. The name is compared the way git looks it up:
1020/// byte-exact, or ASCII-case-folded when `core.ignorecase` is set — the same
1021/// split the resolver itself applies to pattern matching.
1022///
1023/// Everything that cannot be read answers with an **empty list** rather than an
1024/// error, and that direction is deliberate: the resolver's one consumer that
1025/// must never grow a false refusal is the filter, and a missing fallback only
1026/// ever reproduces the pre-2026-08-05 behaviour. `status` reports an unreadable
1027/// index through its own `undetermined` section, not through this.
1028#[must_use]
1029pub fn staged_fallbacks(
1030    work_tree: &Path,
1031    index_path: &Path,
1032    common_dir: &Path,
1033    hash: gix_hash::Kind,
1034    ignore_case: bool,
1035) -> Vec<StagedAttributes> {
1036    let Ok(crate::git::index::Listed::Read(entries)) = crate::git::index::list(index_path, hash)
1037    else {
1038        return Vec::new();
1039    };
1040
1041    let wanted = crate::git::repo::ATTRIBUTES_FILE.as_bytes();
1042    let named = |name: &[u8]| {
1043        if ignore_case {
1044            name.eq_ignore_ascii_case(wanted)
1045        } else {
1046            name == wanted
1047        }
1048    };
1049
1050    use gix_object::Find as _;
1051
1052    let basename_of = |entry: &crate::git::index::Tracked| -> Vec<u8> {
1053        entry
1054            .path
1055            .rsplit(|&byte| byte == b'/')
1056            .next()
1057            .unwrap_or(&entry.path)
1058            .to_vec()
1059    };
1060
1061    let mut candidates: Vec<&crate::git::index::Tracked> = entries
1062        .iter()
1063        .filter(|entry| entry.holds_content() && named(&basename_of(entry)))
1064        .collect();
1065    // One copy per folded path. With `core.ignorecase` the index can hold two
1066    // spellings at once — a tree made on a case-sensitive filesystem, checked
1067    // out here — while git's own lookup finds a single entry. The byte-exact
1068    // name is the one git probes for, so it goes first and wins; reading both
1069    // could hand the resolver a line in the copy git ignores, and on the
1070    // filter that is a false refusal.
1071    candidates.sort_by_key(|entry| basename_of(entry) != wanted);
1072    let mut seen: Vec<Vec<u8>> = Vec::new();
1073
1074    let mut objects = None;
1075    let mut buffer = Vec::new();
1076    let mut found = Vec::new();
1077    for entry in candidates {
1078        if ignore_case {
1079            let folded = entry.path.to_ascii_lowercase();
1080            if seen.contains(&folded) {
1081                continue;
1082            }
1083            seen.push(folded);
1084        }
1085
1086        // The working-tree probe, by name, exactly as `collect_attribute_files`
1087        // probes: a file that is there — under any spelling the filesystem
1088        // resolves — is the one git reads, and the staged copy stays out of it.
1089        let absolute = work_tree.join(crate::git::repo::working_tree_path(&entry.path));
1090        if fs::symlink_metadata(&absolute).is_ok_and(|metadata| metadata.is_file()) {
1091            continue;
1092        }
1093
1094        // The object store is opened only when a fallback actually exists, so a
1095        // repository whose attribute files are all on disk never pays for it.
1096        if objects.is_none() {
1097            objects = Some(crate::git::history::objects(common_dir, hash).ok());
1098        }
1099        let Some(Some(store)) = objects.as_ref() else {
1100            return Vec::new();
1101        };
1102        let Ok(id) = gix_hash::oid::try_from_bytes(&entry.id) else {
1103            continue;
1104        };
1105        if let Ok(Some(data)) = store.try_find(id, &mut buffer) {
1106            found.push(StagedAttributes {
1107                path: absolute,
1108                contents: data.data.to_vec(),
1109            });
1110        }
1111    }
1112    found
1113}
1114
1115/// What git resolves the `filter` attribute to for one path.
1116///
1117/// The spelling of each variant is `git check-attr filter`'s own, so a report can
1118/// quote the answer a user would get from git and the two cannot drift.
1119#[derive(Debug, Clone, PartialEq, Eq)]
1120pub enum FilterAttribute {
1121    /// `filter=git-xcrypt` — git runs this tool for the path.
1122    Ours,
1123    /// `filter=<something else>`, `filter=lfs` being the ordinary case.
1124    Foreign(String),
1125    /// `filter` with no value. Git has no driver to run.
1126    Set,
1127    /// `-filter`. Explicitly off.
1128    Unset,
1129    /// No line reaches this path at all.
1130    Unspecified,
1131}
1132
1133impl FilterAttribute {
1134    /// Whether git would run *this* tool for the path.
1135    #[must_use]
1136    pub fn is_ours(&self) -> bool {
1137        matches!(self, Self::Ours)
1138    }
1139
1140    /// The answer as `git check-attr filter` prints it.
1141    #[must_use]
1142    pub fn as_check_attr(&self) -> &str {
1143        match self {
1144            Self::Ours => DRIVER,
1145            Self::Foreign(value) => value,
1146            Self::Set => "set",
1147            Self::Unset => "unset",
1148            Self::Unspecified => "unspecified",
1149        }
1150    }
1151}
1152
1153impl std::fmt::Display for FilterAttribute {
1154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1155        f.write_str(self.as_check_attr())
1156    }
1157}
1158
1159/// Where an attribute value came from, in terms a reader can act on.
1160///
1161/// A verdict of "git converts your ciphertext" is unactionable without this: the
1162/// stack has four levels and one of them, `$GIT_DIR/info/attributes`, is not
1163/// versioned and cannot be seen in a pull request at all.
1164#[derive(Debug, Clone, PartialEq, Eq)]
1165pub struct Culprit {
1166    /// The attributes file the line sits in, when there is one.
1167    pub source: Option<PathBuf>,
1168    /// The line number within it, as git counts them.
1169    pub line: usize,
1170    /// The pattern that matched.
1171    pub pattern: String,
1172    /// The assignment, spelled the way a `.gitattributes` line spells it.
1173    pub assignment: String,
1174}
1175
1176impl std::fmt::Display for Culprit {
1177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1178        match &self.source {
1179            // Forward slashes so the message reads the same on all three
1180            // platforms, and so a reader can paste the path back into git.
1181            Some(source) => write!(
1182                f,
1183                "{}:{}: {} {}",
1184                git_spelling(source),
1185                self.line,
1186                self.pattern,
1187                self.assignment
1188            ),
1189            None => write!(f, "{} {}", self.pattern, self.assignment),
1190        }
1191    }
1192}
1193
1194/// Whether git would run **its own** end-of-line conversion over stored bytes.
1195///
1196/// The distinction this type exists for is measured, not reasoned: git's
1197/// `convert_attrs` maps the `text` attribute onto a `crlf_action`, and only the
1198/// `CRLF_AUTO*` actions consult binary detection. Our magic starts with a NUL
1199/// byte, so every action that does consult it leaves the ciphertext alone; the
1200/// ones that do not convert it unconditionally, and a converted ciphertext fails
1201/// its authentication tag forever.
1202#[derive(Debug, Clone, PartialEq, Eq)]
1203pub enum EolConversion {
1204    /// Git leaves the bytes alone.
1205    Off,
1206    /// Git converts them, because of this assignment.
1207    On(Culprit),
1208}
1209
1210/// The `eol=` value git resolved, in the only two spellings that decide anything.
1211///
1212/// Kept beside [`EolConversion`] instead of folded into it because the two answer
1213/// different questions. [`EolConversion`] is the check-**in** verdict and does not
1214/// depend on configuration at all: `text` strips `CR` on the way into the object
1215/// database whatever `core.autocrlf` says. The check-**out** direction does depend
1216/// on it, and on this attribute — `text eol=lf` converts on the way in and writes
1217/// the stored bytes out untouched.
1218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1219pub enum DeclaredEol {
1220    /// No `eol=`, or a value git does not recognise: the configuration decides.
1221    Unspecified,
1222    /// `eol=lf`.
1223    Lf,
1224    /// `eol=crlf`.
1225    Crlf,
1226}
1227
1228/// What git resolves for one path, on both axes the managed section sets.
1229#[derive(Debug, Clone, PartialEq, Eq)]
1230pub struct Resolution {
1231    /// Whether git would run this tool for the path.
1232    pub filter: FilterAttribute,
1233    /// Whether git would convert the path's line endings itself.
1234    pub conversion: EolConversion,
1235    /// The `eol=` the path resolved to, which only the check-out side reads.
1236    pub eol: DeclaredEol,
1237}
1238
1239impl Resolution {
1240    /// The line that makes git expand `LF` to `CRLF` on the way **out** of the
1241    /// object database, if any.
1242    ///
1243    /// Not the same question as [`Self::conversion`], and the difference is
1244    /// measured rather than reasoned. On git 2.55, with `core.autocrlf=true` and
1245    /// a blob full of lone `LF`:
1246    ///
1247    /// | line                | check-in            | check-out          |
1248    /// | ------------------- | ------------------- | ------------------ |
1249    /// | `p text`            | strips `CR`         | **expands**        |
1250    /// | `p text eol=lf`     | strips `CR`         | untouched          |
1251    /// | `p text eol=crlf`   | strips `CR`         | **expands**        |
1252    /// | `p eol=crlf`        | strips `CR`         | **expands**        |
1253    /// | `p -text`, `binary` | untouched           | untouched          |
1254    /// | `p text=auto`       | untouched (the NUL) | untouched          |
1255    ///
1256    /// Reusing the check-in verdict here would claim the second row damages a
1257    /// checkout, which it does not — and on that row the bytes handed to the
1258    /// authentication tag really are the stored ones, so a failing tag means the
1259    /// file, not the configuration.
1260    #[must_use]
1261    pub fn expands_on_checkout(
1262        &self,
1263        autocrlf: Option<&str>,
1264        core_eol: Option<&str>,
1265    ) -> Option<&Culprit> {
1266        let EolConversion::On(culprit) = &self.conversion else {
1267            return None;
1268        };
1269        let writes_crlf = match self.eol {
1270            DeclaredEol::Crlf => true,
1271            DeclaredEol::Lf => false,
1272            DeclaredEol::Unspecified => crate::rules::eol::git_writes_crlf(autocrlf, core_eol),
1273        };
1274        writes_crlf.then_some(culprit)
1275    }
1276}
1277
1278/// One resolved attribute: its state, and where the state came from.
1279type Resolved = (gix_attributes::State, Culprit);
1280
1281/// Spells an assignment the way a `.gitattributes` line spells it.
1282fn spell_assignment(assignment: gix_attributes::AssignmentRef<'_>) -> String {
1283    use gix_attributes::StateRef;
1284    let name = assignment.name.as_str();
1285    match assignment.state {
1286        StateRef::Set => name.to_string(),
1287        StateRef::Unset => format!("-{name}"),
1288        StateRef::Unspecified => format!("!{name}"),
1289        StateRef::Value(value) => format!("{name}={}", value.as_bstr()),
1290    }
1291}
1292
1293/// One axis' contribution to git's `crlf_action`, before `eol` is consulted.
1294///
1295/// The mapping is `git_path_check_crlf`, and one function serves two
1296/// attributes on purpose: git consults `text` first and, when it says nothing,
1297/// falls back to the pre-1.7.2 `crlf` attribute — which it still honours.
1298/// Measured on git 2.55 rather than read from the sources, on a file whose
1299/// leading NUL would satisfy any binary detection: `set` and the value `input`
1300/// convert unconditionally, `unset` is binary and skips `eol` entirely (the
1301/// `-crlf eol=lf` row was measured untouched, like `-text`), the value `auto`
1302/// keeps binary detection, and any other value is as if the attribute were not
1303/// on the line at all — inert alone, promoted by a bare `eol=` exactly as
1304/// `unspecified` is.
1305enum CrlfAction<'a> {
1306    /// `text` / `crlf`: converts on the way in, direction from `eol` and the
1307    /// configuration on the way out.
1308    Convert(&'a Culprit),
1309    /// `text=input` / `crlf=input`: converts on the way in, writes `LF` on the
1310    /// way out whatever the configuration says — measured, a blob of lone `LF`
1311    /// under `text=input` checked out untouched at `core.autocrlf=true`.
1312    ConvertAsInput(&'a Culprit),
1313    /// `-text` / `-crlf`: no conversion, and `eol` is skipped entirely.
1314    Binary,
1315    /// `text=auto` / `crlf=auto`: binary detection, which our leading NUL answers.
1316    Auto,
1317}
1318
1319/// What one of the two text axes says, or `None` when it says nothing.
1320fn crlf_action(resolved: Option<&Resolved>) -> Option<CrlfAction<'_>> {
1321    use gix_attributes::State;
1322    match resolved {
1323        Some((State::Set, culprit)) => Some(CrlfAction::Convert(culprit)),
1324        Some((State::Unset, _)) => Some(CrlfAction::Binary),
1325        Some((State::Value(value), culprit)) => match value.as_ref().as_bstr() {
1326            value if value == "auto" => Some(CrlfAction::Auto),
1327            value if value == "input" => Some(CrlfAction::ConvertAsInput(culprit)),
1328            _ => None,
1329        },
1330        Some((State::Unspecified, _)) | None => None,
1331    }
1332}
1333
1334/// Whether the resolved `text`, `crlf` and `eol` make git convert stored bytes.
1335///
1336/// The whole table, measured on git 2.55 — the 2 MB rows by a byte-for-byte
1337/// round trip through `git add`, `git commit`, `rm` and `git checkout`, the
1338/// remaining rows on a NUL-led file with `CRLF` pairs and `core.autocrlf=false`,
1339/// judged by the stored blob's size:
1340///
1341/// | `text`        | `crlf`       | `eol`      | result                               |
1342/// | ------------- | ------------ | ---------- | ------------------------------------ |
1343/// | `unset`       | any          | any        | untouched — `-text` beats both       |
1344/// | `auto`        | any          | any        | untouched — detection sees the NUL   |
1345/// | `set`         | —            | any        | **converted, file lost at checkout** |
1346/// | `input`       | —            | any        | **converted** — no binary detection  |
1347/// | says nothing  | `set`/`input`| any        | **converted** — the legacy fallback  |
1348/// | says nothing  | `unset`      | any        | untouched — `-crlf` beats `eol` too  |
1349/// | says nothing  | `auto`       | any        | untouched — detection sees the NUL   |
1350/// | says nothing  | says nothing | unset      | untouched, at every `core.autocrlf`  |
1351/// | says nothing  | says nothing | `lf`/`crlf`| **converted, file lost at checkout** |
1352///
1353/// "Says nothing" covers `unspecified` *and* any value other than `auto` and
1354/// `input` — `text=junk` was measured inert alone and fatal with a bare
1355/// `eol=crlf` beside it, exactly like `unspecified`. The promotion row is
1356/// git's own rule, in `convert_attrs`: an `eol` attribute promotes an undefined
1357/// `crlf_action` straight to `CRLF_TEXT_INPUT`/`CRLF_TEXT_CRLF`, and only the
1358/// `CRLF_AUTO*` actions consult binary detection.
1359///
1360/// The safe rows are as load-bearing as the dangerous ones: a gate that fires on
1361/// `text=auto` or on an ordinary `core.autocrlf=true` teaches a user to ignore
1362/// it, and an ignored gate protects nothing.
1363fn converts(
1364    text: Option<&Resolved>,
1365    crlf: Option<&Resolved>,
1366    eol: Option<&Resolved>,
1367) -> EolConversion {
1368    use gix_attributes::State;
1369
1370    match crlf_action(text).or_else(|| crlf_action(crlf)) {
1371        Some(CrlfAction::Convert(culprit) | CrlfAction::ConvertAsInput(culprit)) => {
1372            return EolConversion::On(culprit.clone());
1373        }
1374        Some(CrlfAction::Binary | CrlfAction::Auto) => return EolConversion::Off,
1375        None => {}
1376    }
1377
1378    // Neither axis says anything. A bare `eol=` is enough on its own.
1379    match eol {
1380        Some((State::Value(value), culprit)) => {
1381            let value = value.as_ref().as_bstr();
1382            if value == "lf" || value == "crlf" {
1383                EolConversion::On(culprit.clone())
1384            } else {
1385                EolConversion::Off
1386            }
1387        }
1388        _ => EolConversion::Off,
1389    }
1390}
1391
1392/// Answers "would git run our filter for this path, and would git convert its
1393/// line endings", the way git answers both.
1394///
1395/// **Resolving rather than naming, since 2026-08-04.** The previous build listed
1396/// every attribute source carrying a `filter` line and left the reader to run
1397/// `git check-attr`. That was the last route to a green report on a repository
1398/// that stores plaintext: a line below the managed section, or a
1399/// `.gitattributes` in a subdirectory, silently outranks the catch-all, and a
1400/// note does not fail a CI gate. Naming also cannot tell an ordinary
1401/// `*.psd filter=lfs` from a line that reaches a secret, so it either cried wolf
1402/// or said nothing useful.
1403///
1404/// **`text` joined `filter` on 2026-08-04**, for the same reason and at the same
1405/// severity. The managed section writes `-text` on every encrypted path, and a
1406/// line below it saying `secrets/** text` puts the conversion back — measured on
1407/// git 2.55, with `sync` freshly run so nothing else in this command had a
1408/// complaint: 34 `CR` bytes eaten out of a 2 MB ciphertext, `git add` and
1409/// `git commit` both exit 0, and the checkout fails the authentication tag and
1410/// leaves no file at all. `status` printed `VERDICT: no findings.` over it. An
1411/// unresolved `filter` costs a plaintext secret; this costs the file outright,
1412/// and both answer the same question with "your declaration is not enforced".
1413///
1414/// The stack reproduced here is git's, in git's precedence order — lowest first,
1415/// because [`gix_attributes::Search`] matches its lists in reverse:
1416///
1417/// 1. the built-in `[attr]binary` macro;
1418/// 2. `core.attributesFile`, the global file;
1419/// 3. the working tree's `.gitattributes`, root first and each directory after
1420///    it, so the file closest to the path wins;
1421/// 4. `$GIT_DIR/info/attributes`, which outranks everything.
1422///
1423/// Macros (`[attr]name …`) are honoured only where git honours them: the root
1424/// file, the global file and `info/attributes`. A `[attr]` line in a
1425/// subdirectory is not a macro definition to git and is not one here.
1426///
1427/// A source that cannot be read is skipped, exactly as git skips it.
1428///
1429/// **Discovery is lazy since 2026-08-07; assembly is not.** The tree's
1430/// `.gitattributes` are probed only in the directories on the ancestor chain of
1431/// each path [`Self::resolve`] is asked about — git reads the file from nowhere
1432/// else, so everything beyond the ancestors was inert for the answer and cost a
1433/// `read_dir` per directory and a `file_type()` per entry. Measured on a tree
1434/// with 5281 directories and 480 000 ignored files (a build directory): `git
1435/// add` of one declared file took 220 ms instead of 10 ms, scaling linearly
1436/// with the number of directory entries — the only measured cost in the product
1437/// that grew with *untracked* files. When a probe finds a file the whole
1438/// [`gix_attributes::Search`] is rebuilt from scratch by [`Self::assemble`],
1439/// the same code that built it at construction, so precedence is decided by one
1440/// sort in one place and lazy discovery cannot reorder anything: patterns from
1441/// `<dir>/.gitattributes` reach only paths under `<dir>`, which makes the order
1442/// of discovery across disjoint branches irrelevant, and the order *within* a
1443/// chain is re-sorted on every rebuild. Rebuilds are bounded by the number of
1444/// attribute files on the queried chains — typically zero to two — never by the
1445/// number of directories.
1446pub struct AttributeResolver {
1447    search: gix_attributes::Search,
1448    outcome: gix_attributes::search::Outcome,
1449    case: gix_glob::pattern::Case,
1450    /// The working tree the ancestor probes are anchored to.
1451    work_tree: PathBuf,
1452    /// `$GIT_COMMON_DIR/info/attributes`, re-added last on every rebuild.
1453    info: PathBuf,
1454    /// `core.attributesFile`, re-added first on every rebuild.
1455    global: Option<PathBuf>,
1456    /// The index copies of deleted attribute files, all of them, kept from
1457    /// construction: the index was already read to find them and the patterns
1458    /// in each reach only paths under its own directory, so carrying one for a
1459    /// chain never queried is inert.
1460    staged: Vec<StagedAttributes>,
1461    /// Every on-disk `.gitattributes` a probe has found so far.
1462    on_disk: Vec<PathBuf>,
1463    /// Repository-relative directories already probed, `b""` being the root.
1464    /// Keyed by the bytes as given: under `core.ignorecase` a second spelling
1465    /// of the same directory costs at most a second probe, which the
1466    /// filesystem resolves exactly as it resolved the first.
1467    probed: std::collections::HashSet<Vec<u8>>,
1468}
1469
1470impl AttributeResolver {
1471    /// Loads every attribute source git would consult under `work_tree`.
1472    ///
1473    /// `global` is `core.attributesFile`; `ignore_case` is `core.ignorecase`,
1474    /// which git applies to attribute matching as well as to path lookup.
1475    ///
1476    /// `common_dir`, **not this checkout's git directory**: `info/` is on git's
1477    /// common list, so every linked worktree reads the *same*
1478    /// `info/attributes`. Measured on git 2.55 with one linked worktree and
1479    /// `secrets/** -filter` in the main `.git/info/attributes`: `git check-attr
1480    /// filter` answers `unset` in both checkouts, and a file dropped in
1481    /// `.git/worktrees/side/info/attributes` is ignored in both. Reading the
1482    /// worktree's own directory therefore got it wrong twice over — it missed
1483    /// the source that decides and would have read one git never consults.
1484    ///
1485    /// `staged` is what [`staged_fallbacks`] found: the `.gitattributes` files
1486    /// git reads from the **index** because their working-tree file is gone.
1487    /// They take part in the ordinary precedence, by the directory they would
1488    /// occupy — git treats the fallback copy exactly as it would treat the
1489    /// file.
1490    #[must_use]
1491    pub fn new(
1492        work_tree: &Path,
1493        common_dir: &Path,
1494        global: Option<&Path>,
1495        ignore_case: bool,
1496        staged: Vec<StagedAttributes>,
1497    ) -> Self {
1498        let info = common_dir.join("info").join("attributes");
1499        // No walk: the tree's files enter through the probes in [`Self::resolve`].
1500        let (search, outcome) = Self::assemble(work_tree, global, &staged, &[], &info);
1501        Self {
1502            search,
1503            outcome,
1504            case: if ignore_case {
1505                gix_glob::pattern::Case::Fold
1506            } else {
1507                gix_glob::pattern::Case::Sensitive
1508            },
1509            work_tree: work_tree.to_path_buf(),
1510            info,
1511            global: global.map(Path::to_path_buf),
1512            staged,
1513            on_disk: Vec::new(),
1514            probed: std::collections::HashSet::new(),
1515        }
1516    }
1517
1518    /// Builds the whole search from what is known, in git's precedence order.
1519    ///
1520    /// The one place precedence is decided — construction and every
1521    /// rebuild-on-discovery go through it, so the two cannot answer
1522    /// differently. That is the entire safety argument for lazy discovery:
1523    /// nothing is ever *inserted into* an existing search, where the order of
1524    /// discovery could leak into the order of matching.
1525    fn assemble(
1526        work_tree: &Path,
1527        global: Option<&Path>,
1528        staged: &[StagedAttributes],
1529        on_disk: &[PathBuf],
1530        info: &Path,
1531    ) -> (gix_attributes::Search, gix_attributes::search::Outcome) {
1532        let mut collection = gix_attributes::search::MetadataCollection::default();
1533        let mut buf: Vec<u8> = Vec::new();
1534        let mut search = gix_attributes::Search::new_globals(
1535            global
1536                .map(Path::to_path_buf)
1537                .into_iter()
1538                .collect::<Vec<_>>(),
1539            &mut buf,
1540            &mut collection,
1541        )
1542        .unwrap_or_default();
1543
1544        // The staged copies join the on-disk files in one list, so the sort
1545        // below is the only thing deciding precedence for both.
1546        let mut tree_sources: Vec<(&Path, Option<&[u8]>)> = on_disk
1547            .iter()
1548            .map(|path| (path.as_path(), None))
1549            .chain(
1550                staged
1551                    .iter()
1552                    .map(|fallback| (fallback.path.as_path(), Some(&fallback.contents[..]))),
1553            )
1554            .collect();
1555        // Shallowest first: git gives the file closest to the path the higher
1556        // precedence, and `Search` matches its lists last-added-first. Depth
1557        // before name, because a plain string sort does not order `a/x/.g`
1558        // against `a/.g` by depth in every alphabet.
1559        tree_sources.sort_by_key(|(path, _)| (path.components().count(), path.to_path_buf()));
1560
1561        for (source, contents) in tree_sources {
1562            // Macros only where git takes them: the root file. Anywhere deeper a
1563            // `[attr]` line is an ordinary pattern to git.
1564            let is_root = source.parent() == Some(work_tree);
1565            match contents {
1566                None => {
1567                    let _ = search.add_patterns_file(
1568                        source.to_path_buf(),
1569                        true,
1570                        Some(work_tree),
1571                        &mut buf,
1572                        &mut collection,
1573                        is_root,
1574                    );
1575                }
1576                Some(contents) => search.add_patterns_buffer(
1577                    contents,
1578                    source.to_path_buf(),
1579                    Some(work_tree),
1580                    &mut collection,
1581                    is_root,
1582                ),
1583            }
1584        }
1585
1586        // Last, so it outranks every file in the working tree — which is exactly
1587        // what makes it the source an audit is least likely to look at.
1588        let _ = search.add_patterns_file(
1589            info.to_path_buf(),
1590            true,
1591            None,
1592            &mut buf,
1593            &mut collection,
1594            true,
1595        );
1596
1597        let mut outcome = gix_attributes::search::Outcome::default();
1598        // Order matters: `iter_selected` yields one item per name, in this
1599        // order, with a placeholder where nothing matched.
1600        // `crlf` is the pre-1.7.2 spelling of `text`, and git still consults it
1601        // whenever `text` says nothing — leaving it out is how a `secrets/**
1602        // crlf` line converted a ciphertext with no gate firing anywhere.
1603        outcome.initialize_with_selection(&collection, ["filter", "text", "eol", "crlf"]);
1604
1605        (search, outcome)
1606    }
1607
1608    /// Probes `.gitattributes` in every not-yet-probed ancestor of the path.
1609    ///
1610    /// The probe is per file and by name — `symlink_metadata(...).is_file()`,
1611    /// a symlinked file excluded — exactly the probe the eager walk used, so on
1612    /// APFS and NTFS a file *stored* as `.GITATTRIBUTES` is still found the way
1613    /// git finds it. Two differences from the walk, both in git's direction:
1614    ///
1615    /// * **Symlinked directories** are no longer excluded: git itself opens
1616    ///   `<dir>/.gitattributes` through whatever path it was asked about, and
1617    ///   the paths git asks about do not run through directory symlinks
1618    ///   (content under one is not tracked as paths under it), so the
1619    ///   difference is theoretical.
1620    /// * **Directory spelling**: the walk found `secrets/.gitattributes` in a
1621    ///   listing and, under `Case::Fold`, matched it against `SECRETS/db.env`
1622    ///   even on a case-sensitive filesystem; the probe asks for the queried
1623    ///   path's own spelling and finds nothing there — exactly as git, which
1624    ///   builds its stack from the spelling it was asked about. Do not "fix"
1625    ///   this back toward the walk.
1626    fn probe_ancestors(&mut self, relative_path: &[u8]) {
1627        // This is the one place the path's bytes become a *filesystem* path
1628        // rather than matcher input, so it inherits — and here enforces — the
1629        // resolver's assumption that paths are repository-relative and
1630        // normalised, as git's `pathname=` and index entries are. A leading
1631        // slash would make `Path::join` replace the base outright and probe
1632        // outside the working tree; refusing to probe merely reproduces the
1633        // pre-discovery behaviour for a path no real git produces.
1634        if relative_path.first() == Some(&b'/') {
1635            return;
1636        }
1637        let mut discovered = false;
1638        let root: &[u8] = b"";
1639        let ancestors = std::iter::once(root).chain(
1640            relative_path
1641                .iter()
1642                .enumerate()
1643                .filter(|&(_, &byte)| byte == b'/')
1644                .map(|(at, _)| &relative_path[..at]),
1645        );
1646        for directory in ancestors {
1647            if self.probed.contains(directory) {
1648                continue;
1649            }
1650            self.probed.insert(directory.to_vec());
1651            let file = self
1652                .work_tree
1653                .join(crate::git::repo::working_tree_path(directory))
1654                .join(ATTRIBUTES_FILE);
1655            // Never followed: a symbolic link out of the working tree would
1656            // walk somewhere that is not this repository.
1657            if fs::symlink_metadata(&file).is_ok_and(|metadata| metadata.is_file()) {
1658                self.on_disk.push(file);
1659                discovered = true;
1660            }
1661        }
1662        if discovered {
1663            let (search, outcome) = Self::assemble(
1664                &self.work_tree,
1665                self.global.as_deref(),
1666                &self.staged,
1667                &self.on_disk,
1668                &self.info,
1669            );
1670            self.search = search;
1671            self.outcome = outcome;
1672        }
1673    }
1674
1675    /// What git resolves for a repository-relative path, on both axes.
1676    pub fn resolve(&mut self, relative_path: &[u8]) -> Resolution {
1677        use gix_attributes::State;
1678
1679        self.probe_ancestors(relative_path);
1680        self.outcome.reset();
1681        self.search.pattern_matching_relative_path(
1682            bstr::BStr::new(relative_path),
1683            self.case,
1684            Some(false),
1685            &mut self.outcome,
1686        );
1687
1688        // Collected first: every `Match` borrows the outcome, and the decision
1689        // below has to outlive that borrow.
1690        let mut found = self.outcome.iter_selected().map(|matched| {
1691            (
1692                matched.assignment.state.to_owned(),
1693                Culprit {
1694                    source: matched.location.source.map(Path::to_path_buf),
1695                    line: matched.location.sequence_number,
1696                    pattern: matched.pattern.to_string(),
1697                    assignment: spell_assignment(matched.assignment),
1698                },
1699            )
1700        });
1701        let filter = found.next();
1702        let text = found.next();
1703        let eol = found.next();
1704        let crlf = found.next();
1705
1706        // `text=input` and `crlf=input` fix the check-out direction to `LF`
1707        // whatever the configuration says — measured on git 2.55, a blob of
1708        // lone `LF` under `text=input` checked out untouched at
1709        // `core.autocrlf=true`. An explicit `eol=` still wins, so the override
1710        // applies only where the attribute said nothing.
1711        let checked_in_as_input = matches!(
1712            crlf_action(text.as_ref()).or_else(|| crlf_action(crlf.as_ref())),
1713            Some(CrlfAction::ConvertAsInput(_))
1714        );
1715
1716        Resolution {
1717            filter: filter.map_or(FilterAttribute::Unspecified, |(state, _)| match state {
1718                State::Value(value) => {
1719                    let value = value.as_ref().as_bstr().to_string();
1720                    if value == DRIVER {
1721                        FilterAttribute::Ours
1722                    } else {
1723                        FilterAttribute::Foreign(value)
1724                    }
1725                }
1726                State::Set => FilterAttribute::Set,
1727                State::Unset => FilterAttribute::Unset,
1728                State::Unspecified => FilterAttribute::Unspecified,
1729            }),
1730            conversion: converts(text.as_ref(), crlf.as_ref(), eol.as_ref()),
1731            eol: {
1732                let declared = match eol.as_ref().map(|(state, _)| state) {
1733                    Some(State::Value(value)) => match value.as_ref().as_bstr() {
1734                        value if value == "lf" => DeclaredEol::Lf,
1735                        value if value == "crlf" => DeclaredEol::Crlf,
1736                        // `eol=native` and anything else git does not recognise:
1737                        // `git_path_check_eol` answers `EOL_UNSET` for them, so
1738                        // the configuration decides, exactly as with no `eol=`
1739                        // at all.
1740                        _ => DeclaredEol::Unspecified,
1741                    },
1742                    _ => DeclaredEol::Unspecified,
1743                };
1744                match declared {
1745                    DeclaredEol::Unspecified if checked_in_as_input => DeclaredEol::Lf,
1746                    declared => declared,
1747                }
1748            },
1749        }
1750    }
1751}
1752
1753// There is deliberately no `sources()` accessor. There was one while discovery
1754// was eager — it listed every attributes file in the tree, and the `status`
1755// note about foreign `filter` lines was its one consumer. Lazy discovery would
1756// have narrowed it to the files on resolved chains, which is exactly the list
1757// that note must not be built from, so the note reads
1758// [`attribute_files_under`] instead and the accessor lost its last caller —
1759// removed 2026-08-07, the way this project removes code without an owner.
1760
1761/// The configuration keys `init` writes, for `status` to check for completeness.
1762///
1763/// A clone that never ran `init` or `unlock` carries the catch-all attribute
1764/// through history but not `.git/config`, and git treats an undefined filter as
1765/// no filter at all — content passes through in the clear. `diff.git-xcrypt.*`
1766/// is absent on purpose even now that it exists: a missing diff driver costs a
1767/// readable `git diff` and nothing more, and `lock` removes it deliberately, so
1768/// listing it here would make every locked repository report itself broken.
1769#[must_use]
1770pub fn driver_keys() -> [String; 2] {
1771    [
1772        format!("filter.{DRIVER}.process"),
1773        format!("filter.{DRIVER}.required"),
1774    ]
1775}
1776
1777#[cfg(test)]
1778mod tests {
1779    use super::*;
1780
1781    /// Parses a `.git-xcrypt` body and renders the cosmetic lines from it.
1782    fn lines(config: &str) -> Vec<String> {
1783        render_lines(
1784            &Config::parse(config).expect("the test configuration must parse"),
1785            Rendering::PerPattern { fold_case: true },
1786        )
1787    }
1788
1789    #[test]
1790    fn a_directory_pattern_covers_the_subtree_at_any_depth() {
1791        // Two mistakes are possible here and both were made once. A trailing
1792        // slash matches nothing in `.gitattributes`, so the subtree needs
1793        // `/**`; and `secrets/**` carries a slash, which anchors it to the root,
1794        // while `.gitignore`'s `secrets/` floats. The filter encrypts
1795        // `app/secrets/x`, so the line has to reach it. Since 2026-08-05 every
1796        // ASCII letter is spelled as the class matching either of its cases,
1797        // because selection folds unconditionally — see `fold_case`.
1798        assert_eq!(
1799            lines("secrets/\n"),
1800            ["**/[sS][eE][cC][rR][eE][tT][sS]/** filter=git-xcrypt -text diff=git-xcrypt"]
1801        );
1802    }
1803
1804    /// What [`fold_case`] makes of every construct a pattern can hold.
1805    ///
1806    /// A table rather than a scenario, because the awkward rows are the ones no
1807    /// realistic `.git-xcrypt` contains and every one of them is a silent
1808    /// failure: a class turned inside out, an escape eaten, a POSIX name folded
1809    /// into nonsense. Whether the *result* is what git matches is settled by
1810    /// `tests/attributes.rs`, against a real `git check-attr`.
1811    #[test]
1812    fn folding_leaves_every_other_construct_meaning_what_it_meant() {
1813        let rows: &[(&str, &str, &str)] = &[
1814            ("a plain name", "secrets", "[sS][eE][cC][rR][eE][tT][sS]"),
1815            ("digits and punctuation", "a1-_.b", "[aA]1-_.[bB]"),
1816            ("wildcards are untouched", "*.e?v", "*.[eE]?[vV]"),
1817            ("a glob escape on a letter", "\\a", "[aA]"),
1818            ("a glob escape on a metacharacter", "\\*x", "\\*[xX]"),
1819            ("a character class gains its counterpart", "[ab]", "[abAB]"),
1820            ("a range gains its counterpart range", "[a-z]", "[a-zA-Z]"),
1821            (
1822                "a mixed class keeps its non-letters",
1823                "[a-z_0-9]",
1824                "[a-z_0-9A-Z]",
1825            ),
1826            ("a negated class folds too", "[!ab]", "[!abAB]"),
1827            ("…including the other spelling of it", "[^a]", "[^aA]"),
1828            ("a `]` first is a member", "[]a]", "[]aA]"),
1829            // The counterparts land *before* the trailing dash: `[a-A]` is a
1830            // reversed range to git's wildmatch and matches none of `a`, `A`,
1831            // `-` — measured with `git check-attr` at `core.ignorecase=false`,
1832            // while the filter's folded match selects all three.
1833            ("a `-` last is a member", "[a-]", "[aA-]"),
1834            ("a `-` after a range is a member", "[a-z-]", "[a-zA-Z-]"),
1835            ("an escaped `-` last stays put", "[a\\-]", "[a\\-A]"),
1836            (
1837                "a POSIX class is named, not spelled",
1838                "[[:alpha:]]",
1839                "[[:alpha:]]",
1840            ),
1841            (
1842                "…and its neighbours still fold",
1843                "[[:digit:]x]",
1844                "[[:digit:]xX]",
1845            ),
1846            // Selection folds these two like any letter: `gix-glob` lowercases
1847            // the candidate first and lets `[:upper:]` accept a lowercase
1848            // letter under `Case::Fold`, so each class selects every ASCII
1849            // letter. A verbatim copy was measured answering `unspecified` for
1850            // `xdir/a.env` under `**/[[:upper:]]dir/**` at
1851            // `core.ignorecase=false`, while the filter encrypted it — the
1852            // narrower-than-the-filter direction that costs the file.
1853            (
1854                "the upper class gains the lower",
1855                "[[:upper:]]",
1856                "[[:upper:][:lower:]]",
1857            ),
1858            (
1859                "the lower class gains the upper",
1860                "[[:lower:]]",
1861                "[[:lower:][:upper:]]",
1862            ),
1863            (
1864                "…negated too, so it keeps refusing every letter",
1865                "[![:upper:]]",
1866                "[![:upper:][:lower:]]",
1867            ),
1868            ("an escape inside a class", "[\\a\\]]", "[\\a\\]\\A]"),
1869            ("an unterminated class is a literal", "[ab", "[[aA][bB]"),
1870            (
1871                "nothing outside ASCII is touched",
1872                "\u{142}\u{105}ka",
1873                "\u{142}\u{105}[kK][aA]",
1874            ),
1875        ];
1876
1877        for (label, pattern, expected) in rows {
1878            assert_eq!(
1879                fold_case(pattern),
1880                *expected,
1881                "{label}: `{pattern}` folded wrongly"
1882            );
1883        }
1884    }
1885
1886    #[test]
1887    fn a_macro_opening_is_still_recognised_before_anything_is_folded() {
1888        // `guard` tests the *unfolded* opening, so the order of the two is what
1889        // keeps a pattern starting with `[attr]` out of git's macro branch —
1890        // where the line is not a pattern at all and the `-text` it carries is
1891        // simply absent. Folding first would spell it `[attrATTR]`, which no
1892        // longer opens with the six characters git looks for, and the prefix
1893        // would never be added.
1894        assert_eq!(
1895            lines("[attr]odd.env\n"),
1896            [
1897                "**/[attrATTR][oO][dD][dD].[eE][nN][vV] filter=git-xcrypt -text diff=git-xcrypt",
1898                "**/[attrATTR][oO][dD][dD].[eE][nN][vV]/** filter=git-xcrypt -text diff=git-xcrypt",
1899            ]
1900        );
1901    }
1902
1903    /// One attributes source: where it lives, relative to the repository root.
1904    struct Source<'a> {
1905        path: &'static str,
1906        body: &'a str,
1907    }
1908
1909    /// Sets up a repository from `sources`, then asserts our answers for `path`
1910    /// are character for character what `git check-attr` says.
1911    ///
1912    /// All three attributes the managed section sets, not just `filter`. The
1913    /// stack is one stack, and a precedence bug found through `filter` alone
1914    /// would have been just as free to hide behind `text` — which is the more
1915    /// expensive of the two to get wrong.
1916    ///
1917    /// Comparative rather than expectation-based on purpose: git is the only
1918    /// authority on its own attribute stack, and every earlier review of this
1919    /// area found a place where our reading of the documentation and git's
1920    /// behaviour parted company.
1921    fn agrees_with_git(sources: &[Source<'_>], path: &str, global: Option<&str>) {
1922        use std::process::Command;
1923
1924        let dir = tempfile::TempDir::new().expect("temporary directory");
1925        let root = dir.path();
1926        assert!(
1927            Command::new("git")
1928                .args(["init", "-q"])
1929                .current_dir(root)
1930                .status()
1931                .expect("git must be on PATH")
1932                .success(),
1933            "git init failed"
1934        );
1935
1936        for source in sources {
1937            let target = root.join(source.path);
1938            fs::create_dir_all(target.parent().expect("a parent")).expect("directories");
1939            fs::write(&target, source.body).expect("writing an attributes file");
1940        }
1941
1942        let global_path = global.map(|body| {
1943            let path = root.join("global-attributes");
1944            fs::write(&path, body).expect("writing the global attributes file");
1945            assert!(
1946                Command::new("git")
1947                    .args(["config", "core.attributesFile"])
1948                    .arg(&path)
1949                    .current_dir(root)
1950                    .status()
1951                    .expect("git")
1952                    .success()
1953            );
1954            path
1955        });
1956
1957        // The file has to exist for git to consult its directory's rules, and
1958        // `check-attr` without `--cached` reads the working tree.
1959        let target = root.join(path);
1960        fs::create_dir_all(target.parent().expect("a parent")).expect("directories");
1961        fs::write(&target, b"content\n").expect("writing the subject file");
1962
1963        let ask = |attribute: &str| -> String {
1964            let output = Command::new("git")
1965                .args(["check-attr", attribute, "--", path])
1966                .current_dir(root)
1967                .output()
1968                .expect("git check-attr");
1969            String::from_utf8(output.stdout)
1970                .expect("check-attr prints text")
1971                .rsplit(": ")
1972                .next()
1973                .expect("check-attr always prints a value")
1974                .trim()
1975                .to_string()
1976        };
1977
1978        let mut resolver = AttributeResolver::new(
1979            root,
1980            &root.join(".git"),
1981            global_path.as_deref(),
1982            false,
1983            Vec::new(),
1984        );
1985        let ours = resolver.resolve(path.as_bytes());
1986
1987        assert_eq!(
1988            ours.filter.as_check_attr(),
1989            ask("filter"),
1990            "git and git-xcrypt disagree about `filter` for {path}"
1991        );
1992
1993        // The conversion verdict rebuilt from git's own two answers. This
1994        // proves the *resolution* — precedence, macros, the global file — not
1995        // the table in `converts`, which is settled against git's behaviour by
1996        // the round trips in `tests/status_command.rs`.
1997        let (text, eol, crlf) = (ask("text"), ask("eol"), ask("crlf"));
1998        // `git_path_check_crlf`, as the measured table in `converts` records it:
1999        // `set` and `input` convert, `unset` and `auto` do not and stop the
2000        // question, anything else defers — to the legacy `crlf` attribute
2001        // first, then to the bare-`eol=` promotion.
2002        let action = |value: &str| match value {
2003            "set" | "input" => Some(true),
2004            "unset" | "auto" => Some(false),
2005            _ => None,
2006        };
2007        let converts = action(&text)
2008            .or_else(|| action(&crlf))
2009            .unwrap_or(matches!(eol.as_str(), "lf" | "crlf"));
2010        assert_eq!(
2011            matches!(ours.conversion, EolConversion::On(_)),
2012            converts,
2013            "git says text={text} eol={eol} crlf={crlf} for {path}, and \
2014             git-xcrypt read the stack differently"
2015        );
2016    }
2017
2018    #[test]
2019    fn the_filter_attribute_is_resolved_exactly_as_git_resolves_it() {
2020        // Ten shapes, every one of them a way a repository can end up not being
2021        // filtered while the catch-all line sits there looking correct.
2022        let catch_all = "# >>> git-xcrypt >>>\n* filter=git-xcrypt\n# <<< git-xcrypt <<<\n";
2023
2024        // The healthy case, so a disagreement here would be caught too.
2025        agrees_with_git(
2026            &[Source {
2027                path: ".gitattributes",
2028                body: catch_all,
2029            }],
2030            "secrets/db.env",
2031            None,
2032        );
2033
2034        // A line below the managed section. Git takes the last match.
2035        agrees_with_git(
2036            &[Source {
2037                path: ".gitattributes",
2038                body: &format!("{catch_all}secrets/** -filter\n"),
2039            }],
2040            "secrets/db.env",
2041            None,
2042        );
2043
2044        // `text=input` converts without consulting binary detection — the one
2045        // value besides `auto` that `git_path_check_crlf` recognises. It used
2046        // to be read as `text=auto` here, and the gate stayed silent while git
2047        // ate the `CR` bytes out of the ciphertext.
2048        agrees_with_git(
2049            &[Source {
2050                path: ".gitattributes",
2051                body: &format!("{catch_all}secrets/** -text\nsecrets/** text=input\n"),
2052            }],
2053            "secrets/db.env",
2054            None,
2055        );
2056
2057        // The pre-1.7.2 `crlf` attribute, which git still honours whenever
2058        // `text` says nothing. `text=junk` says nothing, so the fallback is
2059        // what decides here.
2060        agrees_with_git(
2061            &[Source {
2062                path: ".gitattributes",
2063                body: &format!("{catch_all}secrets/** text=junk\nsecrets/** crlf\n"),
2064            }],
2065            "secrets/db.env",
2066            None,
2067        );
2068
2069        // And the safe side of both: `-crlf` beats a bare `eol=` exactly as
2070        // `-text` does, so a gate firing here would be one shape too wide.
2071        agrees_with_git(
2072            &[Source {
2073                path: ".gitattributes",
2074                body: &format!("{catch_all}secrets/** -crlf\nsecrets/** eol=lf\n"),
2075            }],
2076            "secrets/db.env",
2077            None,
2078        );
2079
2080        // A `.gitattributes` in the directory of the path outranks the root.
2081        agrees_with_git(
2082            &[
2083                Source {
2084                    path: ".gitattributes",
2085                    body: catch_all,
2086                },
2087                Source {
2088                    path: "secrets/.gitattributes",
2089                    body: "* -filter\n",
2090                },
2091            ],
2092            "secrets/db.env",
2093            None,
2094        );
2095
2096        // `$GIT_DIR/info/attributes` outranks everything in the working tree.
2097        agrees_with_git(
2098            &[
2099                Source {
2100                    path: ".gitattributes",
2101                    body: catch_all,
2102                },
2103                Source {
2104                    path: ".git/info/attributes",
2105                    body: "secrets/** -filter\n",
2106                },
2107            ],
2108            "secrets/db.env",
2109            None,
2110        );
2111
2112        // …and it can also put the filter back, which is the direction a build
2113        // that merely looked for foreign lines would have got wrong.
2114        agrees_with_git(
2115            &[
2116                Source {
2117                    path: ".gitattributes",
2118                    body: catch_all,
2119                },
2120                Source {
2121                    path: "secrets/.gitattributes",
2122                    body: "* -filter\n",
2123                },
2124                Source {
2125                    path: ".git/info/attributes",
2126                    body: "secrets/** filter=git-xcrypt\n",
2127                },
2128            ],
2129            "secrets/db.env",
2130            None,
2131        );
2132
2133        // The spelling the renderer emits for a POSIX class — the pair
2134        // `[[:upper:][:lower:]]` — resolved through gix the way git resolves
2135        // it. Discriminating on purpose: the class line must *beat* the `text`
2136        // above it, so a resolver that failed to match the bracket would
2137        // answer "converts" where git answers "untouched".
2138        agrees_with_git(
2139            &[Source {
2140                path: ".gitattributes",
2141                body: &format!("{catch_all}* text\n**/[[:upper:][:lower:]][dD][iI][rR]/** -text\n"),
2142            }],
2143            "xdir/db.env",
2144            None,
2145        );
2146
2147        // An ordinary LFS line on paths no pattern of ours reaches.
2148        agrees_with_git(
2149            &[Source {
2150                path: ".gitattributes",
2151                body: &format!("{catch_all}*.psd filter=lfs\n"),
2152            }],
2153            "secrets/db.env",
2154            None,
2155        );
2156
2157        // …and the same line where it *does* reach.
2158        agrees_with_git(
2159            &[Source {
2160                path: ".gitattributes",
2161                body: &format!("{catch_all}*.env filter=lfs\n"),
2162            }],
2163            "secrets/db.env",
2164            None,
2165        );
2166
2167        // A macro, which is the indirection a reader is least likely to follow.
2168        agrees_with_git(
2169            &[Source {
2170                path: ".gitattributes",
2171                body: &format!("[attr]plain -filter\n{catch_all}secrets/** plain\n"),
2172            }],
2173            "secrets/db.env",
2174            None,
2175        );
2176
2177        // `!filter` — unspecified rather than unset, and git tells them apart.
2178        agrees_with_git(
2179            &[Source {
2180                path: ".gitattributes",
2181                body: &format!("{catch_all}secrets/** !filter\n"),
2182            }],
2183            "secrets/db.env",
2184            None,
2185        );
2186
2187        // The global file is the *lowest* precedence, so the repository wins.
2188        agrees_with_git(
2189            &[Source {
2190                path: ".gitattributes",
2191                body: catch_all,
2192            }],
2193            "secrets/db.env",
2194            Some("* -filter\n"),
2195        );
2196
2197        // …and it decides when nothing in the repository speaks.
2198        agrees_with_git(&[], "secrets/db.env", Some("* filter=git-xcrypt\n"));
2199    }
2200
2201    /// The one dimension only lazy discovery can get wrong: the order paths are
2202    /// resolved in. Every `.gitattributes` here enters the search at a moment
2203    /// decided by which path was asked about first, so a resolver that let the
2204    /// discovery order leak into the matching order would answer differently
2205    /// per permutation — and differently from git, which has no such order at
2206    /// all.
2207    ///
2208    /// Guards the rebuild-on-discovery design (see [`AttributeResolver`]):
2209    /// mutating [`AttributeResolver::probe_ancestors`] to skip directories
2210    /// below the root turns every `a/…` answer into the root's and goes red
2211    /// against git.
2212    #[test]
2213    fn the_order_paths_are_resolved_in_never_changes_an_answer() {
2214        use std::process::Command;
2215
2216        let sources = [
2217            Source {
2218                path: ".gitattributes",
2219                body: "* filter=git-xcrypt\n*.env text\n",
2220            },
2221            Source {
2222                path: "a/.gitattributes",
2223                body: "*.env -text\n",
2224            },
2225            Source {
2226                path: "a/b/.gitattributes",
2227                body: "*.env text\n",
2228            },
2229            Source {
2230                path: ".git/info/attributes",
2231                body: "a/b/deep.env -text\n",
2232            },
2233        ];
2234        // `notes.txt` is the guard for the global file surviving a rebuild:
2235        // its `eol` answer comes from the global file alone, so a rebuild that
2236        // dropped `self.global` would change it — every other global entry
2237        // here is outranked by the repository and would not notice the loss.
2238        let paths = [
2239            "a/b/deep.env",
2240            "top.env",
2241            "a/mid.env",
2242            "a/c/side.env",
2243            "notes.txt",
2244        ];
2245        // Deepest first, shallowest first, and a sibling chain in between: the
2246        // three ways a chain can be discovered relative to its neighbours.
2247        let permutations: [[usize; 5]; 3] = [[0, 1, 2, 3, 4], [4, 1, 2, 3, 0], [3, 0, 4, 2, 1]];
2248
2249        let dir = tempfile::TempDir::new().expect("temporary directory");
2250        let root = dir.path();
2251        assert!(
2252            Command::new("git")
2253                .args(["init", "-q"])
2254                .current_dir(root)
2255                .status()
2256                .expect("git must be on PATH")
2257                .success(),
2258            "git init failed"
2259        );
2260        for source in &sources {
2261            let target = root.join(source.path);
2262            fs::create_dir_all(target.parent().expect("a parent")).expect("directories");
2263            fs::write(&target, source.body).expect("writing an attributes file");
2264        }
2265        let global = root.join("global-attributes");
2266        fs::write(&global, "*.env -filter\n*.txt eol=crlf\n")
2267            .expect("writing the global attributes file");
2268        for path in paths {
2269            let target = root.join(path);
2270            fs::create_dir_all(target.parent().expect("a parent")).expect("directories");
2271            fs::write(&target, b"content\n").expect("writing a subject file");
2272        }
2273
2274        // Git's answer per path, asked the way `agrees_with_git` asks — git
2275        // resolves from a stack with no discovery order, so it is the fixed
2276        // point every permutation must land on.
2277        assert!(
2278            Command::new("git")
2279                .args(["config", "core.attributesFile"])
2280                .arg(&global)
2281                .current_dir(root)
2282                .status()
2283                .expect("git")
2284                .success()
2285        );
2286        let ask = |attribute: &str, path: &str| -> String {
2287            let output = Command::new("git")
2288                .args(["check-attr", attribute, "--", path])
2289                .current_dir(root)
2290                .output()
2291                .expect("git check-attr");
2292            String::from_utf8(output.stdout)
2293                .expect("check-attr prints text")
2294                .rsplit(": ")
2295                .next()
2296                .expect("check-attr always prints a value")
2297                .trim()
2298                .to_string()
2299        };
2300
2301        for ignore_case in [false, true] {
2302            assert!(
2303                Command::new("git")
2304                    .args([
2305                        "config",
2306                        "core.ignorecase",
2307                        if ignore_case { "true" } else { "false" }
2308                    ])
2309                    .current_dir(root)
2310                    .status()
2311                    .expect("git")
2312                    .success()
2313            );
2314            for permutation in permutations {
2315                let mut resolver = AttributeResolver::new(
2316                    root,
2317                    &root.join(".git"),
2318                    Some(&global),
2319                    ignore_case,
2320                    Vec::new(),
2321                );
2322                for at in permutation {
2323                    let path = paths[at];
2324                    let ours = resolver.resolve(path.as_bytes());
2325                    assert_eq!(
2326                        ours.filter.as_check_attr(),
2327                        ask("filter", path),
2328                        "`filter` for {path} depends on the discovery order \
2329                         {permutation:?} (core.ignorecase={ignore_case})"
2330                    );
2331                    // The same reconstruction `agrees_with_git` uses: `text`
2332                    // first, the legacy `crlf` as its fallback, and a bare
2333                    // `eol=` promoting conversion when both say nothing —
2334                    // `notes.txt` is exactly that last shape.
2335                    let (text, eol_answer, crlf) =
2336                        (ask("text", path), ask("eol", path), ask("crlf", path));
2337                    let action = |value: &str| match value {
2338                        "set" | "input" => Some(true),
2339                        "unset" | "auto" => Some(false),
2340                        _ => None,
2341                    };
2342                    let converts = action(&text)
2343                        .or_else(|| action(&crlf))
2344                        .unwrap_or(matches!(eol_answer.as_str(), "lf" | "crlf"));
2345                    assert_eq!(
2346                        matches!(ours.conversion, EolConversion::On(_)),
2347                        converts,
2348                        "conversion for {path} depends on the discovery order \
2349                         {permutation:?} (core.ignorecase={ignore_case})"
2350                    );
2351                    // Exact only because no source here says `text=input` —
2352                    // that value makes the resolver promote an unspecified
2353                    // `eol` to `lf` where `git check-attr eol` still answers
2354                    // `unspecified`.
2355                    let eol = match ours.eol {
2356                        DeclaredEol::Lf => "lf",
2357                        DeclaredEol::Crlf => "crlf",
2358                        DeclaredEol::Unspecified => "unspecified",
2359                    };
2360                    assert_eq!(
2361                        eol, eol_answer,
2362                        "`eol` for {path} depends on the discovery order \
2363                         {permutation:?} (core.ignorecase={ignore_case})"
2364                    );
2365                }
2366            }
2367        }
2368    }
2369
2370    /// The shape that makes the re-sort in [`AttributeResolver::assemble`]
2371    /// load-bearing. On-disk probing always meets an ancestor before its
2372    /// descendant, so tree files alone happen to arrive pre-sorted whatever
2373    /// the resolve order — a staged fallback does not: it is known from
2374    /// construction, joins the list before any on-disk discovery, and only the
2375    /// sort puts it back between its neighbours. Drop the sort and the staged
2376    /// `a/` copy outranks the on-disk `a/b/` file for paths under `a/b/`.
2377    ///
2378    /// The expectations are git's check-in semantics — the staged copy of a
2379    /// deleted `.gitattributes` decides, measured in `tests/attributes.rs::
2380    /// a_dangerous_line_kept_only_in_the_index_still_refuses_after_the_file_is_deleted`
2381    /// — hard-coded here because `git check-attr` without `--cached` reads
2382    /// only the working tree.
2383    #[test]
2384    fn a_staged_fallback_keeps_its_place_whatever_the_discovery_order() {
2385        use std::process::Command;
2386
2387        let dir = tempfile::TempDir::new().expect("temporary directory");
2388        let root = dir.path();
2389        assert!(
2390            Command::new("git")
2391                .args(["init", "-q"])
2392                .current_dir(root)
2393                .status()
2394                .expect("git must be on PATH")
2395                .success(),
2396            "git init failed"
2397        );
2398        fs::write(root.join(".gitattributes"), "*.env text\n").expect("root attributes");
2399        fs::create_dir_all(root.join("a/b")).expect("directories");
2400        fs::write(root.join("a/b/.gitattributes"), "*.env text\n").expect("deep attributes");
2401        // `a/.gitattributes` exists only as the index copy of a deleted file.
2402        let staged = vec![StagedAttributes {
2403            path: root.join("a/.gitattributes"),
2404            contents: b"*.env -text\n".to_vec(),
2405        }];
2406
2407        // Deep path first and shallow path first: the staged copy's position
2408        // must survive both.
2409        for order in [["a/b/deep.env", "a/mid.env"], ["a/mid.env", "a/b/deep.env"]] {
2410            let mut resolver =
2411                AttributeResolver::new(root, &root.join(".git"), None, false, staged.clone());
2412            for path in order {
2413                let ours = resolver.resolve(path.as_bytes());
2414                let expected_conversion = match path {
2415                    // The on-disk `a/b/` file is closest and says `text`.
2416                    "a/b/deep.env" => true,
2417                    // The staged `a/` copy is closest and says `-text`.
2418                    "a/mid.env" => false,
2419                    _ => unreachable!(),
2420                };
2421                assert_eq!(
2422                    matches!(ours.conversion, EolConversion::On(_)),
2423                    expected_conversion,
2424                    "the staged fallback lost its place for {path} when paths \
2425                     were resolved in the order {order:?}"
2426                );
2427            }
2428        }
2429    }
2430}