Skip to main content

sley_worktree/
filter.rs

1//! Content filtering on the blob<->worktree boundary: CRLF/encoding/ident/process filters, clean/smudge, and `ls-files --eol` info.
2//!
3//! Split out of `lib.rs` in the wave-47 mechanical refactor: a pure code move
4//! (no function body changed); all items are re-exported from `lib.rs`.
5use super::*;
6use crate::attributes::*;
7use crate::ignore::*;
8use crate::index::*;
9use crate::index_io::*;
10use crate::types_admin::*;
11
12// ---------------------------------------------------------------------------
13// Content filtering on the blob <-> worktree boundary
14//
15// Git runs two kinds of conversion when content crosses between the worktree
16// and the object database:
17//
18//   * the line-ending / `core.autocrlf` conversion (driven by the `text`,
19//     `eol` attributes and the `core.autocrlf` / `core.eol` config), and
20//   * the long-running `filter.<name>.clean` / `.smudge` driver filters
21//     (selected by the `filter=<name>` attribute and configured commands).
22//
23// "clean" runs on the way *into* the object store (worktree -> blob), e.g. on
24// `git add` / `git hash-object -w`. "smudge" runs on the way *out* (blob ->
25// worktree), e.g. on checkout / restore. The driver filter, when present,
26// wraps the EOL conversion: on clean git first runs the configured `clean`
27// command and then applies CRLF->LF normalization; on smudge git first applies
28// LF->CRLF and then runs the `smudge` command.
29// ---------------------------------------------------------------------------
30
31/// The line-ending conversion that applies to a path, derived from its
32/// attributes and the repository config.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub(crate) enum EolConversion {
35    /// No conversion: binary content, or text with `core.autocrlf=false` and no
36    /// `eol`/`text=auto` request to add carriage returns.
37    None,
38    /// Normalize to LF on clean; no carriage returns on smudge (`eol=lf`, or
39    /// `core.autocrlf=input`).
40    Lf,
41    /// Normalize to LF on clean; emit CRLF on smudge (`eol=crlf`, or
42    /// `core.autocrlf=true`).
43    Crlf,
44}
45
46/// How git should decide whether a path is text for the purpose of EOL
47/// conversion.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub(crate) enum TextDecision {
50    /// `-text` / `binary`: never convert.
51    Binary,
52    /// `text` is set explicitly: always treat as text.
53    Text,
54    /// `text=auto` (or implied by `core.autocrlf`): treat as text unless the
55    /// content looks binary.
56    Auto,
57    /// No opinion from attributes or config: leave content untouched.
58    Unspecified,
59}
60
61/// The fully resolved set of conversions that apply to a single path.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub(crate) struct ContentFilterPlan {
64    pub(crate) text: TextDecision,
65    /// The conversion to apply when `text` resolves to "this is text".
66    pub(crate) eol: EolConversion,
67    /// Whether `$Id$` keyword collapse/expansion applies to this path.
68    pub(crate) ident: bool,
69    /// `filter.<name>` driver, if assigned via attributes and configured.
70    pub(crate) driver: Option<FilterDriver>,
71    /// `working-tree-encoding` attribute: the worktree charset to decode from
72    /// on checkin / encode to on checkout.
73    pub(crate) encoding: WtEncoding,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub(crate) struct FilterDriver {
78    name: Vec<u8>,
79    process: Option<String>,
80    clean: Option<String>,
81    smudge: Option<String>,
82    required: bool,
83}
84
85/// The resolved `working-tree-encoding` attribute (convert.c
86/// `git_path_check_encoding`): an unset / empty / `UTF-8` value means no
87/// conversion; `working-tree-encoding` (true) / `working-tree-encoding=false`
88/// are rejected; any other value names a charset the worktree file is stored in.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub(crate) enum WtEncoding {
91    /// No reencoding (unset, empty, or the default UTF-8).
92    None,
93    /// `working-tree-encoding` set as a boolean — `true`/`false` are invalid.
94    Invalid,
95    /// A named encoding (the original attribute value, preserved for messages).
96    Named(Vec<u8>),
97}
98
99impl WtEncoding {
100    fn from_attr(state: Option<&AttributeState>) -> WtEncoding {
101        match state {
102            // Unset (`-working-tree-encoding`) or no attribute: nothing to do.
103            None | Some(AttributeState::Unset) => WtEncoding::None,
104            // `working-tree-encoding` with no value is the boolean true.
105            Some(AttributeState::Set) => WtEncoding::Invalid,
106            Some(AttributeState::Value(value)) => {
107                // An empty value (`working-tree-encoding=`) or UTF-8 (the
108                // in-repo default) needs no conversion.
109                if value.is_empty() || encoding_name_is_utf8(value) {
110                    WtEncoding::None
111                } else {
112                    WtEncoding::Named(value.clone())
113                }
114            }
115        }
116    }
117}
118
119/// Whether `name` denotes UTF-8 (`same_encoding(name, "UTF-8")`): the leading
120/// `UTF` prefix and an optional `-` are skipped, then the remainder compared
121/// case-insensitively against `8`.
122pub(crate) fn encoding_name_is_utf8(name: &[u8]) -> bool {
123    utf_suffix(name).is_some_and(|suffix| suffix == "8")
124}
125
126/// Strip a leading case-insensitive `UTF` and optional `-`, returning the
127/// uppercased remainder (e.g. `utf-16le` → `16LE`, `UTF-16LE-BOM` →
128/// `16LE-BOM`). `None` when `name` is not a UTF family encoding or not UTF-8.
129pub(crate) fn utf_suffix(name: &[u8]) -> Option<String> {
130    let upper: String = std::str::from_utf8(name).ok()?.to_ascii_uppercase();
131    let rest = upper.strip_prefix("UTF")?;
132    Some(rest.strip_prefix('-').unwrap_or(rest).to_string())
133}
134
135#[derive(Clone, Copy)]
136pub(crate) enum BomProblem {
137    Prohibited,
138    Required,
139}
140
141/// The byte order mark validation from convert.c `validate_encoding`: an
142/// explicit-endianness UTF encoding must not start with a BOM, while a
143/// byte-order-agnostic one (`UTF-16` / `UTF-32`) must.
144pub(crate) fn utf_bom_problem(suffix: &str, data: &[u8]) -> Option<BomProblem> {
145    let has16 = data.starts_with(&[0xFF, 0xFE]) || data.starts_with(&[0xFE, 0xFF]);
146    let has32 = data.starts_with(&[0xFF, 0xFE, 0, 0]) || data.starts_with(&[0, 0, 0xFE, 0xFF]);
147    match suffix {
148        "16LE" | "16BE" => has16.then_some(BomProblem::Prohibited),
149        "32LE" | "32BE" => has32.then_some(BomProblem::Prohibited),
150        "16" => (!has16).then_some(BomProblem::Required),
151        "32" => (!has32).then_some(BomProblem::Required),
152        _ => None,
153    }
154}
155
156/// The byte order used by the platform iconv for byte-order-agnostic
157/// `UTF-16` / `UTF-32` when no BOM says otherwise. Git delegates these names to
158/// iconv; glibc follows host endian, while macOS emits big-endian with a BOM.
159#[cfg(target_os = "macos")]
160pub(crate) const ICONV_UTF_DEFAULT_LE: bool = false;
161#[cfg(not(target_os = "macos"))]
162pub(crate) const ICONV_UTF_DEFAULT_LE: bool = cfg!(target_endian = "little");
163
164/// Decode worktree-encoded bytes to UTF-8 (encode_to_git's reencode), or `None`
165/// when the encoding is unsupported or the bytes are not valid in it.
166pub(crate) fn decode_to_utf8(suffix: &str, data: &[u8]) -> Option<Vec<u8>> {
167    match suffix {
168        "16LE" => decode_utf16(data, true),
169        "16BE" => decode_utf16(data, false),
170        "16" | "16LE-BOM" | "16BE-BOM" => {
171            let (le, body) = strip_utf16_bom(data);
172            decode_utf16(body, le)
173        }
174        "32LE" => decode_utf32(data, true),
175        "32BE" => decode_utf32(data, false),
176        "32" | "32LE-BOM" | "32BE-BOM" => {
177            let (le, body) = strip_utf32_bom(data);
178            decode_utf32(body, le)
179        }
180        _ => None,
181    }
182}
183
184/// Encode UTF-8 bytes to the worktree encoding (encode_to_worktree's reencode),
185/// or `None` when the encoding is unsupported or the input is not valid UTF-8.
186pub(crate) fn encode_from_utf8(suffix: &str, utf8: &[u8]) -> Option<Vec<u8>> {
187    match suffix {
188        "16LE" => encode_utf16(utf8, true, false),
189        "16BE" => encode_utf16(utf8, false, false),
190        "16LE-BOM" => encode_utf16(utf8, true, true),
191        "16BE-BOM" => encode_utf16(utf8, false, true),
192        "16" => encode_utf16(utf8, ICONV_UTF_DEFAULT_LE, true),
193        "32LE" => encode_utf32(utf8, true, false),
194        "32BE" => encode_utf32(utf8, false, false),
195        "32LE-BOM" => encode_utf32(utf8, true, true),
196        "32BE-BOM" => encode_utf32(utf8, false, true),
197        "32" => encode_utf32(utf8, ICONV_UTF_DEFAULT_LE, true),
198        _ => None,
199    }
200}
201
202pub(crate) fn decode_named_encoding_to_utf8(name: &[u8], data: &[u8]) -> Option<Vec<u8>> {
203    if let Some(suffix) = utf_suffix(name) {
204        return decode_to_utf8(&suffix, data);
205    }
206    let encoding = encoding_rs::Encoding::for_label(name)?;
207    let (decoded, _, had_errors) = encoding.decode(data);
208    (!had_errors).then(|| decoded.into_owned().into_bytes())
209}
210
211pub(crate) fn encode_utf8_to_named_encoding(name: &[u8], utf8: &[u8]) -> Option<Vec<u8>> {
212    if let Some(suffix) = utf_suffix(name) {
213        return encode_from_utf8(&suffix, utf8);
214    }
215    let text = std::str::from_utf8(utf8).ok()?;
216    let encoding = encoding_rs::Encoding::for_label(name)?;
217    let (encoded, _, had_errors) = encoding.encode(text);
218    (!had_errors).then(|| encoded.into_owned())
219}
220
221pub(crate) fn should_check_roundtrip_encoding(config: &GitConfig, name: &[u8]) -> bool {
222    match config.get("core", None, "checkRoundtripEncoding") {
223        Some(value) => value
224            .as_bytes()
225            .split(|byte| *byte == b',' || byte.is_ascii_whitespace())
226            .filter(|part| !part.is_empty())
227            .any(|part| same_encoding_name(part, name)),
228        None => same_encoding_name(b"SHIFT-JIS", name),
229    }
230}
231
232pub(crate) fn same_encoding_name(left: &[u8], right: &[u8]) -> bool {
233    match (utf_suffix(left), utf_suffix(right)) {
234        (Some(left), Some(right)) => left == right,
235        _ => left.eq_ignore_ascii_case(right),
236    }
237}
238
239pub(crate) fn trace_roundtrip_encoding_check(name: &[u8]) {
240    if std::env::var_os("GIT_TRACE_WORKING_TREE_ENCODING").is_none()
241        && std::env::var_os("GIT_TRACE").is_none()
242    {
243        return;
244    }
245    eprintln!(
246        "Checking roundtrip encoding for {}",
247        String::from_utf8_lossy(name)
248    );
249}
250
251pub(crate) fn strip_utf16_bom(data: &[u8]) -> (bool, &[u8]) {
252    if data.starts_with(&[0xFF, 0xFE]) {
253        (true, &data[2..])
254    } else if data.starts_with(&[0xFE, 0xFF]) {
255        (false, &data[2..])
256    } else {
257        (ICONV_UTF_DEFAULT_LE, data)
258    }
259}
260
261pub(crate) fn strip_utf32_bom(data: &[u8]) -> (bool, &[u8]) {
262    if data.starts_with(&[0xFF, 0xFE, 0, 0]) {
263        (true, &data[4..])
264    } else if data.starts_with(&[0, 0, 0xFE, 0xFF]) {
265        (false, &data[4..])
266    } else {
267        (ICONV_UTF_DEFAULT_LE, data)
268    }
269}
270
271pub(crate) fn decode_utf16(data: &[u8], le: bool) -> Option<Vec<u8>> {
272    if !data.len().is_multiple_of(2) {
273        return None;
274    }
275    let units = data.chunks_exact(2).map(|chunk| {
276        let pair = [chunk[0], chunk[1]];
277        if le {
278            u16::from_le_bytes(pair)
279        } else {
280            u16::from_be_bytes(pair)
281        }
282    });
283    let mut out = String::new();
284    for unit in char::decode_utf16(units) {
285        out.push(unit.ok()?);
286    }
287    Some(out.into_bytes())
288}
289
290pub(crate) fn decode_utf32(data: &[u8], le: bool) -> Option<Vec<u8>> {
291    if !data.len().is_multiple_of(4) {
292        return None;
293    }
294    let mut out = String::new();
295    for chunk in data.chunks_exact(4) {
296        let quad = [chunk[0], chunk[1], chunk[2], chunk[3]];
297        let cp = if le {
298            u32::from_le_bytes(quad)
299        } else {
300            u32::from_be_bytes(quad)
301        };
302        out.push(char::from_u32(cp)?);
303    }
304    Some(out.into_bytes())
305}
306
307pub(crate) fn encode_utf16(utf8: &[u8], le: bool, bom: bool) -> Option<Vec<u8>> {
308    let text = std::str::from_utf8(utf8).ok()?;
309    let mut out = Vec::with_capacity(utf8.len() * 2 + 2);
310    if bom {
311        out.extend_from_slice(if le { &[0xFF, 0xFE] } else { &[0xFE, 0xFF] });
312    }
313    for unit in text.encode_utf16() {
314        out.extend_from_slice(&if le {
315            unit.to_le_bytes()
316        } else {
317            unit.to_be_bytes()
318        });
319    }
320    Some(out)
321}
322
323pub(crate) fn encode_utf32(utf8: &[u8], le: bool, bom: bool) -> Option<Vec<u8>> {
324    let text = std::str::from_utf8(utf8).ok()?;
325    let mut out = Vec::with_capacity(utf8.len() * 4 + 4);
326    if bom {
327        out.extend_from_slice(if le {
328            &[0xFF, 0xFE, 0, 0]
329        } else {
330            &[0, 0, 0xFE, 0xFF]
331        });
332    }
333    for ch in text.chars() {
334        let cp = ch as u32;
335        out.extend_from_slice(&if le {
336            cp.to_le_bytes()
337        } else {
338            cp.to_be_bytes()
339        });
340    }
341    Some(out)
342}
343
344/// Reject a `working-tree-encoding` boolean (`true`/`false`) before any
345/// conversion runs — `git_path_check_encoding` dies on it regardless of
346/// direction.
347pub(crate) fn check_wt_encoding_valid(encoding: &WtEncoding) -> Result<()> {
348    if matches!(encoding, WtEncoding::Invalid) {
349        eprintln!("fatal: true/false are no valid working-tree-encodings");
350        return Err(GitError::Exit(128));
351    }
352    Ok(())
353}
354
355pub(crate) fn clean_encoding_needs_stat_match_validation(
356    config: &GitConfig,
357    attributes: &[AttributeCheck],
358) -> bool {
359    let plan = ContentFilterPlan::resolve(config, attributes);
360    matches!(plan.encoding, WtEncoding::Invalid | WtEncoding::Named(_))
361}
362
363pub(crate) fn validate_clean_encoding_for_stat_match(
364    config: &GitConfig,
365    attributes: &[AttributeCheck],
366    path: &[u8],
367    content: &[u8],
368) -> Result<()> {
369    let plan = ContentFilterPlan::resolve(config, attributes);
370    check_wt_encoding_valid(&plan.encoding)?;
371    let _ = encode_to_git(config, &plan.encoding, path, Cow::Borrowed(content), false)?;
372    Ok(())
373}
374
375/// encode_to_git: decode worktree-encoded `data` to UTF-8 for storage in the
376/// object database. Runs the BOM validation first (fatal when writing an
377/// object). Returns the borrowed input unchanged when there is no encoding.
378pub(crate) fn encode_to_git<'a>(
379    config: &GitConfig,
380    encoding: &WtEncoding,
381    path: &[u8],
382    data: Cow<'a, [u8]>,
383    write_object: bool,
384) -> Result<Cow<'a, [u8]>> {
385    let name = match encoding {
386        WtEncoding::None => return Ok(data),
387        WtEncoding::Invalid => return check_wt_encoding_valid(encoding).map(|()| data),
388        WtEncoding::Named(name) => name,
389    };
390    if data.is_empty() {
391        return Ok(data);
392    }
393    let display = String::from_utf8_lossy(path);
394    let enc = String::from_utf8_lossy(name);
395    if let Some(suffix) = utf_suffix(name)
396        && let Some(problem) = utf_bom_problem(&suffix, &data)
397    {
398        let number = &suffix[..2.min(suffix.len())];
399        match problem {
400            BomProblem::Prohibited => {
401                eprintln!(
402                    "hint: The file '{display}' contains a byte order mark (BOM). \
403Please use UTF-{number} as working-tree-encoding."
404                );
405                report_encode_failure(
406                    write_object,
407                    &format!("BOM is prohibited in '{display}' if encoded as {enc}"),
408                )?;
409                return Ok(data);
410            }
411            BomProblem::Required => {
412                eprintln!(
413                    "hint: The file '{display}' is missing a byte order mark (BOM). \
414Please use UTF-{number}BE or UTF-{number}LE (depending on the byte order) as \
415working-tree-encoding."
416                );
417                report_encode_failure(
418                    write_object,
419                    &format!("BOM is required in '{display}' if encoded as {enc}"),
420                )?;
421                return Ok(data);
422            }
423        }
424    }
425    match decode_named_encoding_to_utf8(name, &data) {
426        Some(utf8) => {
427            if should_check_roundtrip_encoding(config, name) {
428                trace_roundtrip_encoding_check(name);
429                if encode_utf8_to_named_encoding(name, &utf8).as_deref() != Some(data.as_ref()) {
430                    report_encode_failure(
431                        write_object,
432                        &format!("encoding round trip failed for '{display}' from {enc} to UTF-8"),
433                    )?;
434                    return Ok(data);
435                }
436            }
437            Ok(Cow::Owned(utf8))
438        }
439        None => {
440            report_encode_failure(
441                write_object,
442                &format!("failed to encode '{display}' from {enc} to UTF-8"),
443            )?;
444            Ok(data)
445        }
446    }
447}
448
449/// encode_to_worktree: reencode UTF-8 `data` to the worktree encoding on
450/// checkout. A failure is reported (never fatal) and the content left as-is,
451/// matching convert.c `encode_to_worktree`.
452pub(crate) fn encode_to_worktree<'a>(
453    encoding: &WtEncoding,
454    path: &[u8],
455    data: Cow<'a, [u8]>,
456) -> Result<Cow<'a, [u8]>> {
457    let name = match encoding {
458        WtEncoding::None => return Ok(data),
459        WtEncoding::Invalid => return check_wt_encoding_valid(encoding).map(|()| data),
460        WtEncoding::Named(name) => name,
461    };
462    if data.is_empty() {
463        return Ok(data);
464    }
465    match encode_utf8_to_named_encoding(name, &data) {
466        Some(encoded) => Ok(Cow::Owned(encoded)),
467        None => {
468            let display = String::from_utf8_lossy(path);
469            let enc = String::from_utf8_lossy(name);
470            eprintln!("error: failed to encode '{display}' from UTF-8 to {enc}");
471            Ok(data)
472        }
473    }
474}
475
476/// Emit a clean-side encoding failure: fatal (`die`) when writing an object,
477/// otherwise an `error:` diagnostic that lets the caller keep the content as-is.
478pub(crate) fn report_encode_failure(write_object: bool, message: &str) -> Result<()> {
479    if write_object {
480        eprintln!("fatal: {message}");
481        Err(GitError::Exit(128))
482    } else {
483        eprintln!("error: {message}");
484        Ok(())
485    }
486}
487
488/// Decode one crlf-family attribute (`text` or its legacy alias `crlf`) into a
489/// text decision, plus whether the value form forced an EOL direction.
490///
491/// Mirrors git's `git_path_check_crlf` (convert.c): a *set* attribute is text,
492/// an *unset* one is binary, `=auto` is auto, `=input` forces LF while still
493/// counting as text, and any other value is "undefined" — i.e. no opinion, so
494/// the caller falls through to the next source (the `crlf` alias, then config).
495pub(crate) fn decode_crlf_family_attribute(
496    state: Option<&AttributeState>,
497) -> (TextDecision, EolConversion) {
498    match state {
499        Some(AttributeState::Set) => (TextDecision::Text, EolConversion::None),
500        Some(AttributeState::Unset) => (TextDecision::Binary, EolConversion::None),
501        Some(AttributeState::Value(value)) if value == b"auto" => {
502            (TextDecision::Auto, EolConversion::None)
503        }
504        // `crlf=input` / `text=input`: text content normalized to LF (no CR on
505        // smudge), exactly like `core.autocrlf=input`.
506        Some(AttributeState::Value(value)) if value == b"input" => {
507            (TextDecision::Text, EolConversion::Lf)
508        }
509        // `=<other>` is CRLF_UNDEFINED in git for the `crlf` alias: no opinion.
510        _ => (TextDecision::Unspecified, EolConversion::None),
511    }
512}
513
514impl ContentFilterPlan {
515    /// Build the plan for `path` from the parsed attributes and repo config.
516    fn resolve(config: &GitConfig, checks: &[AttributeCheck]) -> Self {
517        let text_attr = checks.iter().find(|check| check.attribute == b"text");
518        let crlf_attr = checks.iter().find(|check| check.attribute == b"crlf");
519        let ident_attr = checks.iter().find(|check| check.attribute == b"ident");
520        let eol_attr = checks.iter().find(|check| check.attribute == b"eol");
521        let filter_attr = checks.iter().find(|check| check.attribute == b"filter");
522        let encoding_attr = checks
523            .iter()
524            .find(|check| check.attribute == b"working-tree-encoding");
525        let encoding = WtEncoding::from_attr(encoding_attr.and_then(|check| check.state.as_ref()));
526
527        // Resolve the eol attribute first; `eol=crlf|lf` also forces text.
528        let eol_value = eol_attr.and_then(|check| match &check.state {
529            Some(AttributeState::Value(value)) => Some(value.clone()),
530            _ => None,
531        });
532
533        // The `text` attribute decides first; only when it is unspecified does
534        // git consult the legacy `crlf` alias (convert.c `convert_attrs`).
535        let mut forced_eol = EolConversion::None;
536        let mut text = match text_attr.map(|check| &check.state) {
537            Some(Some(AttributeState::Set)) => TextDecision::Text,
538            Some(Some(AttributeState::Unset)) => TextDecision::Binary,
539            Some(Some(AttributeState::Value(value))) if value == b"auto" => TextDecision::Auto,
540            Some(Some(AttributeState::Value(value))) if value == b"input" => {
541                forced_eol = EolConversion::Lf;
542                TextDecision::Text
543            }
544            // `text=<other>` is treated by git as a set text attribute.
545            Some(Some(AttributeState::Value(_))) => TextDecision::Text,
546            // `!text` (unspecified) or no text attribute: fall through to `crlf`.
547            _ => {
548                let (decision, eol) =
549                    decode_crlf_family_attribute(crlf_attr.and_then(|check| check.state.as_ref()));
550                forced_eol = eol;
551                decision
552            }
553        };
554
555        // A concrete `eol` attribute implies the path is text even when `text`
556        // was left unspecified (git: `eol` without `text` is treated as
557        // `text=auto`-ish; upstream forces conversion). We honour eol only when
558        // text is not explicitly binary.
559        let eol = match (&text, eol_value.as_deref()) {
560            (TextDecision::Binary, _) => EolConversion::None,
561            (_, Some(b"crlf")) => {
562                if text == TextDecision::Unspecified {
563                    text = TextDecision::Text;
564                }
565                EolConversion::Crlf
566            }
567            (_, Some(b"lf")) => {
568                if text == TextDecision::Unspecified {
569                    text = TextDecision::Text;
570                }
571                EolConversion::Lf
572            }
573            // No explicit `eol` attribute, but `text=input`/`crlf=input` already
574            // forced the LF direction (git's CRLF_TEXT_INPUT). Honour it over the
575            // config-derived default.
576            _ if forced_eol == EolConversion::Lf => EolConversion::Lf,
577            // No eol attribute: derive direction from config.
578            _ => eol_from_config(config),
579        };
580
581        // When the path is text but neither `eol` nor `core.autocrlf`/`core.eol`
582        // asked for carriage returns, we still normalize to LF on clean. That is
583        // modelled by `EolConversion::Lf` (clean strips CR, smudge adds none).
584        let eol = match (&text, eol) {
585            (TextDecision::Text | TextDecision::Auto, EolConversion::None) => EolConversion::Lf,
586            (_, eol) => eol,
587        };
588
589        // If config does not enable autocrlf and there is no eol/text opinion,
590        // there is genuinely nothing to do.
591        let text = match (text, eol_attr.is_some()) {
592            (TextDecision::Unspecified, _) => {
593                // Without any text/eol attribute, only `core.autocrlf` can make a
594                // path eligible, and then it behaves like `text=auto`.
595                if autocrlf_enabled(config) {
596                    TextDecision::Auto
597                } else {
598                    TextDecision::Unspecified
599                }
600            }
601            (text, _) => text,
602        };
603
604        let driver = resolve_filter_driver(config, filter_attr);
605        let ident = matches!(
606            ident_attr.and_then(|check| check.state.as_ref()),
607            Some(AttributeState::Set)
608        );
609
610        ContentFilterPlan {
611            text,
612            eol,
613            ident,
614            driver,
615            encoding,
616        }
617    }
618
619    /// Whether EOL conversion should run for the given content.
620    fn convert_eol(&self, content: &[u8]) -> bool {
621        match self.text {
622            TextDecision::Binary | TextDecision::Unspecified => false,
623            TextDecision::Text => self.eol != EolConversion::None,
624            // `text=auto`: only when the blob does not look binary.
625            TextDecision::Auto => self.eol != EolConversion::None && !looks_binary(content),
626        }
627    }
628
629    /// The smudge-side LF->CRLF safety check, mirroring convert.c
630    /// `will_convert_lf_to_crlf`. Returns false (no conversion) when:
631    ///   * there is no naked LF to convert, or
632    ///   * the action is `text=auto`-derived (the "new safer autocrlf") AND the
633    ///     content already contains a lone CR or a CRLF pair, or looks binary.
634    ///
635    /// An explicit `text`/`eol=crlf` (non-auto) path always converts naked LFs.
636    pub(crate) fn will_convert_lf_to_crlf(&self, content: &[u8]) -> bool {
637        self.will_convert_lf_to_crlf_stats(&gather_convert_stats(content))
638    }
639
640    /// Stats-based variant of [`will_convert_lf_to_crlf`], mirroring convert.c
641    /// `will_convert_lf_to_crlf(struct text_stat *, ...)`. Used by the safecrlf
642    /// round-trip simulation, which mutates a copy of the stats rather than
643    /// re-scanning the buffer.
644    fn will_convert_lf_to_crlf_stats(&self, stats: &ConvertStats) -> bool {
645        // `output_eol(crlf_action) != EOL_CRLF` short-circuits in git.
646        if self.eol != EolConversion::Crlf {
647            return false;
648        }
649        // No naked LF? Nothing to convert.
650        if stats.lonelf == 0 {
651            return false;
652        }
653        if self.text == TextDecision::Auto {
654            // Any CR or CRLF already present: leave it untouched (irreversible).
655            if stats.lonecr > 0 || stats.crlf > 0 {
656                return false;
657            }
658            if convert_is_binary(stats) {
659                return false;
660            }
661        }
662        true
663    }
664
665    /// Whether this path is a candidate for the `core.safecrlf` round-trip check
666    /// at all: git only warns for non-`CRLF_BINARY` actions. `Binary` and
667    /// `Unspecified` (with autocrlf off) correspond to git's `CRLF_BINARY`.
668    fn safecrlf_applies(&self) -> bool {
669        matches!(self.text, TextDecision::Text | TextDecision::Auto)
670    }
671
672    /// Emit git's `core.safecrlf` round-trip warning for `path`, mirroring the
673    /// stderr side-effect of convert.c `crlf_to_git` (the `CONV_EOL_RNDTRP_*`
674    /// branch). `old_stats` are the stats of the *pre-conversion* worktree
675    /// content (already gathered by the caller so the buffer is scanned once);
676    /// `index_has_crlf` is whether the path's current index blob already has a
677    /// CRLF (git's `has_crlf_in_index`, used only for the auto-crlf decision).
678    ///
679    /// This never inspects or alters the bytes written to the object store; it is
680    /// purely the additive warning git prints alongside `git add`/`commit`.
681    /// Returns `Err` only under `core.safecrlf=true` when the round-trip is
682    /// irreversible (git `die`s).
683    fn check_safe_crlf_stats(
684        &self,
685        old_stats: &ConvertStats,
686        index_has_crlf: bool,
687        flags: ConvFlags,
688        path: &[u8],
689    ) -> Result<()> {
690        if flags == ConvFlags::Off || !self.safecrlf_applies() {
691            return Ok(());
692        }
693
694        // Replicate `crlf_to_git`'s `convert_crlf_into_lf` decision (the clean
695        // direction). It starts as "there is a CRLF to collapse"; auto paths
696        // suppress conversion for binary content or content whose index blob
697        // already carries a CRLF (the "new safer autocrlf").
698        let mut convert_crlf_into_lf = old_stats.crlf > 0;
699        if self.text == TextDecision::Auto {
700            if convert_is_binary(old_stats) {
701                // git returns 0 here: no conversion *and* no warning.
702                return Ok(());
703            }
704            if index_has_crlf {
705                convert_crlf_into_lf = false;
706            }
707        }
708
709        // Simulate the round-trip on a copy of the stats.
710        let mut new_stats = old_stats.clone();
711        // Simulate "git add" (clean: CRLF -> LF).
712        if convert_crlf_into_lf {
713            new_stats.lonelf += new_stats.crlf;
714            new_stats.crlf = 0;
715        }
716        // Simulate "git checkout" (smudge: LF -> CRLF).
717        if self.will_convert_lf_to_crlf_stats(&new_stats) {
718            new_stats.crlf += new_stats.lonelf;
719            new_stats.lonelf = 0;
720        }
721        check_safe_crlf(old_stats, &new_stats, flags, path)
722    }
723}
724
725/// Derive the smudge-direction line ending from `core.autocrlf` / `core.eol`.
726pub(crate) fn eol_from_config(config: &GitConfig) -> EolConversion {
727    if let Some(value) = config.get("core", None, "autocrlf") {
728        match value.to_ascii_lowercase().as_str() {
729            "input" => return EolConversion::Lf,
730            "true" | "yes" | "on" | "1" => return EolConversion::Crlf,
731            _ => {}
732        }
733    }
734    if config.get_bool("core", None, "autocrlf") == Some(true) {
735        return EolConversion::Crlf;
736    }
737    match config
738        .get("core", None, "eol")
739        .map(|v| v.to_ascii_lowercase())
740    {
741        Some(ref v) if v == "crlf" => EolConversion::Crlf,
742        Some(ref v) if v == "lf" => EolConversion::Lf,
743        _ => EolConversion::None,
744    }
745}
746
747/// Whether `core.autocrlf` is set to anything that enables conversion
748/// (`true` or `input`).
749pub(crate) fn autocrlf_enabled(config: &GitConfig) -> bool {
750    if let Some(value) = config.get("core", None, "autocrlf")
751        && value.eq_ignore_ascii_case("input")
752    {
753        return true;
754    }
755    config.get_bool("core", None, "autocrlf") == Some(true)
756}
757
758/// Resolve the `filter=<name>` attribute against `filter.<name>.*` config.
759pub(crate) fn resolve_filter_driver(
760    config: &GitConfig,
761    filter_attr: Option<&AttributeCheck>,
762) -> Option<FilterDriver> {
763    let name = match filter_attr.map(|check| &check.state) {
764        Some(Some(AttributeState::Value(value))) => value.clone(),
765        // `filter` set/unset without a value selects no driver.
766        _ => return None,
767    };
768    let subsection = String::from_utf8_lossy(&name).into_owned();
769    let process = filter_config_value(config, &subsection, "process").filter(|cmd| !cmd.is_empty());
770    let clean = filter_config_value(config, &subsection, "clean").filter(|cmd| !cmd.is_empty());
771    let smudge = filter_config_value(config, &subsection, "smudge").filter(|cmd| !cmd.is_empty());
772    let required = filter_config_bool(config, &subsection, "required").unwrap_or(false);
773    // A filter with neither command and not required is a no-op.
774    if process.is_none() && clean.is_none() && smudge.is_none() && !required {
775        return None;
776    }
777    Some(FilterDriver {
778        name,
779        process,
780        clean,
781        smudge,
782        required,
783    })
784}
785
786pub(crate) fn filter_config_value(
787    config: &GitConfig,
788    subsection: &str,
789    key: &str,
790) -> Option<String> {
791    config
792        .get("filter", Some(subsection), key)
793        .map(str::to_owned)
794        .or_else(|| global_filter_config_value(subsection, key))
795}
796
797pub(crate) fn filter_config_bool(config: &GitConfig, subsection: &str, key: &str) -> Option<bool> {
798    config
799        .get_bool("filter", Some(subsection), key)
800        .or_else(|| {
801            global_filter_config_value(subsection, key)
802                .as_deref()
803                .and_then(sley_config::parse_config_bool)
804        })
805}
806
807pub(crate) fn global_filter_config_value(subsection: &str, key: &str) -> Option<String> {
808    for (path, _) in sley_config::default_config_layer_paths().into_iter().rev() {
809        let Ok(config) = GitConfig::read(path) else {
810            continue;
811        };
812        if let Some(value) = config.get("filter", Some(subsection), key) {
813            return Some(value.to_owned());
814        }
815    }
816    None
817}
818
819/// Heuristic mirroring git's `buffer_is_binary`: content is treated as binary
820/// when a NUL byte appears within the first 8000 bytes.
821pub(crate) fn looks_binary(content: &[u8]) -> bool {
822    const FIRST_FEW_BYTES: usize = 8000;
823    let window = &content[..content.len().min(FIRST_FEW_BYTES)];
824    window.contains(&0)
825}
826
827/// Strip carriage returns that immediately precede a line feed (CRLF -> LF).
828/// A lone CR (old-Mac line ending) is left untouched, matching git, which only
829/// collapses CRLF pairs.
830pub(crate) fn convert_crlf_to_lf_cow(content: Cow<'_, [u8]>) -> Cow<'_, [u8]> {
831    if !content.windows(2).any(|window| window == b"\r\n") {
832        return content;
833    }
834    let mut out = Vec::with_capacity(content.len());
835    let mut index = 0;
836    while index < content.len() {
837        let byte = content[index];
838        if byte == b'\r' && content.get(index + 1) == Some(&b'\n') {
839            // Drop the CR; the LF is emitted on the next iteration.
840            index += 1;
841            continue;
842        }
843        out.push(byte);
844        index += 1;
845    }
846    Cow::Owned(out)
847}
848
849/// Convert lone LF bytes to CRLF (LF -> CRLF). An LF already preceded by a CR
850/// is left as-is so content is not double-converted, matching git.
851pub(crate) fn convert_lf_to_crlf(content: &[u8]) -> Vec<u8> {
852    let mut out = Vec::with_capacity(content.len() + content.len() / 16);
853    let mut prev = 0u8;
854    for &byte in content {
855        if byte == b'\n' && prev != b'\r' {
856            out.push(b'\r');
857        }
858        out.push(byte);
859        prev = byte;
860    }
861    out
862}
863
864/// Collapse git `$Id: ... $` keywords to `$Id$` on the clean path.
865pub(crate) fn ident_to_git_cow(content: Cow<'_, [u8]>) -> Cow<'_, [u8]> {
866    let input = content.as_ref();
867    if !has_git_ident(input) {
868        return content;
869    }
870    let mut out = Vec::with_capacity(input.len());
871    let mut pos = 0;
872    while let Some(relative) = input[pos..].iter().position(|byte| *byte == b'$') {
873        let dollar = pos + relative;
874        out.extend_from_slice(&input[pos..=dollar]);
875        pos = dollar + 1;
876        if input.len().saturating_sub(pos) > 3 && input[pos..].starts_with(b"Id:") {
877            let search = &input[pos + 3..];
878            let Some(end_relative) = search.iter().position(|byte| *byte == b'$') else {
879                break;
880            };
881            let end = pos + 3 + end_relative;
882            if input[pos + 3..end].contains(&b'\n') {
883                continue;
884            }
885            out.extend_from_slice(b"Id$");
886            pos = end + 1;
887        }
888    }
889    out.extend_from_slice(&input[pos..]);
890    Cow::Owned(out)
891}
892
893/// Expand `$Id$` and git-style `$Id: <hex> $` keywords using the blob id of the
894/// unexpanded content, matching convert.c's ident_to_worktree.
895pub(crate) fn ident_to_worktree_cow(
896    format: ObjectFormat,
897    content: Cow<'_, [u8]>,
898) -> Result<Cow<'_, [u8]>> {
899    let input = content.as_ref();
900    if !has_git_ident(input) {
901        return Ok(content);
902    }
903    let oid = EncodedObject::new(ObjectType::Blob, input.to_vec()).object_id(format)?;
904    let replacement = format!("Id: {} $", oid.to_hex());
905    let mut out = Vec::with_capacity(input.len() + replacement.len());
906    let mut pos = 0;
907    while let Some(relative) = input[pos..].iter().position(|byte| *byte == b'$') {
908        let dollar = pos + relative;
909        out.extend_from_slice(&input[pos..=dollar]);
910        pos = dollar + 1;
911        if input.len().saturating_sub(pos) < 3 || !input[pos..].starts_with(b"Id") {
912            continue;
913        }
914        match input.get(pos + 2) {
915            Some(b'$') => {
916                pos += 3;
917            }
918            Some(b':') => {
919                let search = &input[pos + 3..];
920                let Some(end_relative) = search.iter().position(|byte| *byte == b'$') else {
921                    break;
922                };
923                let end = pos + 3 + end_relative;
924                if input[pos + 3..end].contains(&b'\n') || is_foreign_ident(&input[pos + 3..end]) {
925                    continue;
926                }
927                pos = end + 1;
928            }
929            _ => continue,
930        }
931        out.extend_from_slice(replacement.as_bytes());
932    }
933    out.extend_from_slice(&input[pos..]);
934    Ok(Cow::Owned(out))
935}
936
937pub(crate) fn has_git_ident(content: &[u8]) -> bool {
938    let mut pos = 0;
939    while let Some(relative) = content[pos..].iter().position(|byte| *byte == b'$') {
940        let start = pos + relative + 1;
941        if content.len().saturating_sub(start) < 3 {
942            break;
943        }
944        if !content[start..].starts_with(b"Id") {
945            pos = start;
946            continue;
947        }
948        match content.get(start + 2) {
949            Some(b'$') => return true,
950            Some(b':') => {
951                let search = &content[start + 3..];
952                let Some(end_relative) = search.iter().position(|byte| *byte == b'$') else {
953                    break;
954                };
955                let end = start + 3 + end_relative;
956                if !content[start + 3..end].contains(&b'\n') {
957                    return true;
958                }
959                pos = end + 1;
960            }
961            _ => pos = start,
962        }
963    }
964    false
965}
966
967pub(crate) fn is_foreign_ident(expansion: &[u8]) -> bool {
968    if expansion.len() <= 1 {
969        return false;
970    }
971    expansion[1..expansion.len().saturating_sub(1)].contains(&b' ')
972}
973
974/// Run a configured `clean`/`smudge` command as a subprocess, feeding `content`
975/// on stdin and returning its stdout. Errors carry enough context for the
976/// caller to decide whether the failure is fatal (required filter) or should be
977/// silently ignored (optional filter passthrough).
978pub(crate) fn run_filter_command(command: &str, path: &[u8], content: &[u8]) -> Result<Vec<u8>> {
979    // Git expands `%f` in the filter command to the path of the file being
980    // filtered (quoted). We perform the same substitution.
981    let display_path = String::from_utf8_lossy(path);
982    let expanded = command.replace("%f", &shell_quote(&display_path));
983    // Run through the platform shell so pipelines / arguments in the configured
984    // command behave the same way git's `run_command`-with-shell does.
985    let (shell, flag) = if cfg!(windows) {
986        ("cmd", "/C")
987    } else {
988        ("/bin/sh", "-c")
989    };
990    let mut child = Command::new(shell)
991        .arg(flag)
992        .arg(&expanded)
993        .stdin(Stdio::piped())
994        .stdout(Stdio::piped())
995        .stderr(Stdio::piped())
996        .spawn()
997        .map_err(|err| GitError::Command(format!("failed to spawn filter `{command}`: {err}")))?;
998    // Write the content to the child's stdin on a separate thread so we never
999    // deadlock against a filter that streams output before consuming all input.
1000    let mut stdin = child
1001        .stdin
1002        .take()
1003        .ok_or_else(|| GitError::Command(format!("filter `{command}` stdin unavailable")))?;
1004    let payload = content.to_vec();
1005    let writer = std::thread::spawn(move || {
1006        let _ = stdin.write_all(&payload);
1007        // Dropping `stdin` here closes the pipe so the child sees EOF.
1008    });
1009    let output = child
1010        .wait_with_output()
1011        .map_err(|err| GitError::Command(format!("filter `{command}` failed: {err}")))?;
1012    // Join the writer; its own errors (e.g. broken pipe) are non-fatal because
1013    // the child's exit status is the authoritative signal.
1014    let _ = writer.join();
1015    if !output.status.success() {
1016        let stderr = String::from_utf8_lossy(&output.stderr);
1017        return Err(GitError::Command(format!(
1018            "filter `{command}` exited with {}: {}",
1019            output.status,
1020            stderr.trim()
1021        )));
1022    }
1023    Ok(output.stdout)
1024}
1025
1026pub(crate) const PROCESS_CAP_CLEAN: u8 = 1;
1027pub(crate) const PROCESS_CAP_SMUDGE: u8 = 1 << 1;
1028pub(crate) const PROCESS_CAP_DELAY: u8 = 1 << 2;
1029pub(crate) const PKT_DATA_MAX: usize = 65_516;
1030
1031pub(crate) static PROCESS_FILTERS: OnceLock<Mutex<HashMap<String, ProcessFilter>>> =
1032    OnceLock::new();
1033pub(crate) static PROCESS_FILTER_ABORTED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
1034/// Extra protocol metadata sent on smudge requests when the caller knows the
1035/// tree-ish/ref context of the materialization.
1036pub type ProcessFilterMetadata = Vec<(String, String)>;
1037pub(crate) static PROCESS_FILTER_METADATA: OnceLock<Mutex<Option<ProcessFilterMetadata>>> =
1038    OnceLock::new();
1039pub(crate) static PROCESS_FILTER_CWD: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
1040
1041/// Restores the previous process-filter metadata when dropped.
1042pub struct ProcessFilterMetadataGuard {
1043    previous: Option<ProcessFilterMetadata>,
1044}
1045
1046impl Drop for ProcessFilterMetadataGuard {
1047    fn drop(&mut self) {
1048        if let Ok(mut guard) = PROCESS_FILTER_METADATA
1049            .get_or_init(|| Mutex::new(None))
1050            .lock()
1051        {
1052            *guard = self.previous.take();
1053        }
1054    }
1055}
1056
1057pub fn set_process_filter_metadata(
1058    metadata: Option<ProcessFilterMetadata>,
1059) -> ProcessFilterMetadataGuard {
1060    let mutex = PROCESS_FILTER_METADATA.get_or_init(|| Mutex::new(None));
1061    let previous = mutex
1062        .lock()
1063        .map(|mut guard| std::mem::replace(&mut *guard, metadata))
1064        .unwrap_or(None);
1065    ProcessFilterMetadataGuard { previous }
1066}
1067
1068/// Restores the previous process-filter working directory when dropped.
1069pub struct ProcessFilterCwdGuard {
1070    previous: Option<PathBuf>,
1071}
1072
1073impl Drop for ProcessFilterCwdGuard {
1074    fn drop(&mut self) {
1075        if let Ok(mut guard) = PROCESS_FILTER_CWD.get_or_init(|| Mutex::new(None)).lock() {
1076            *guard = self.previous.take();
1077        }
1078    }
1079}
1080
1081pub fn set_process_filter_cwd(cwd: Option<PathBuf>) -> ProcessFilterCwdGuard {
1082    let mutex = PROCESS_FILTER_CWD.get_or_init(|| Mutex::new(None));
1083    let previous = mutex
1084        .lock()
1085        .map(|mut guard| std::mem::replace(&mut *guard, cwd))
1086        .unwrap_or(None);
1087    ProcessFilterCwdGuard { previous }
1088}
1089
1090pub(crate) fn current_process_filter_metadata() -> Option<ProcessFilterMetadata> {
1091    PROCESS_FILTER_METADATA
1092        .get_or_init(|| Mutex::new(None))
1093        .lock()
1094        .ok()
1095        .and_then(|guard| guard.clone())
1096}
1097
1098pub(crate) fn current_process_filter_cwd() -> Option<PathBuf> {
1099    PROCESS_FILTER_CWD
1100        .get_or_init(|| Mutex::new(None))
1101        .lock()
1102        .ok()
1103        .and_then(|guard| guard.clone())
1104}
1105
1106pub(crate) struct ProcessFilter {
1107    child: Child,
1108    stdin: Option<ChildStdin>,
1109    stdout: ChildStdout,
1110    capabilities: u8,
1111    closed: bool,
1112}
1113
1114pub(crate) enum ProcessFilterOutcome {
1115    Filtered(Vec<u8>),
1116    Unsupported,
1117    Status(String),
1118}
1119
1120pub(crate) enum DriverFilterResult<'a> {
1121    Content(Cow<'a, [u8]>),
1122    Delayed { process: String },
1123}
1124
1125pub(crate) enum SmudgeFilterResult<'a> {
1126    Content(Cow<'a, [u8]>),
1127    Delayed { process: String },
1128}
1129
1130pub(crate) struct ProcessFilterFailure {
1131    pub(crate) message: String,
1132    pub(crate) protocol: bool,
1133}
1134
1135impl ProcessFilterFailure {
1136    fn protocol(message: impl Into<String>) -> Self {
1137        Self {
1138            message: message.into(),
1139            protocol: true,
1140        }
1141    }
1142}
1143
1144pub(crate) fn run_process_filter(
1145    command: &str,
1146    direction: &str,
1147    path: &[u8],
1148    content: &[u8],
1149    blob: Option<ObjectId>,
1150    can_delay: bool,
1151) -> std::result::Result<ProcessFilterOutcome, ProcessFilterFailure> {
1152    if PROCESS_FILTER_ABORTED
1153        .get_or_init(|| Mutex::new(HashSet::new()))
1154        .lock()
1155        .map(|aborted| aborted.contains(command))
1156        .unwrap_or(false)
1157    {
1158        return Ok(ProcessFilterOutcome::Status("abort".to_string()));
1159    }
1160    let filters = PROCESS_FILTERS.get_or_init(|| Mutex::new(HashMap::new()));
1161    let mut filters = filters
1162        .lock()
1163        .map_err(|_| ProcessFilterFailure::protocol("process filter cache poisoned"))?;
1164    if !filters.contains_key(command) {
1165        let filter = ProcessFilter::start(command)?;
1166        filters.insert(command.to_string(), filter);
1167    }
1168    let Some(filter) = filters.get_mut(command) else {
1169        return Err(ProcessFilterFailure::protocol(
1170            "process filter missing after insert",
1171        ));
1172    };
1173    let result = filter.apply(direction, path, content, blob, can_delay);
1174    if matches!(result, Ok(ProcessFilterOutcome::Status(ref status)) if status == "abort") {
1175        if let Some(mut filter) = filters.remove(command) {
1176            filter.finish_gracefully();
1177        }
1178        if let Ok(mut aborted) = PROCESS_FILTER_ABORTED
1179            .get_or_init(|| Mutex::new(HashSet::new()))
1180            .lock()
1181        {
1182            aborted.insert(command.to_string());
1183        }
1184    }
1185    if result.as_ref().is_err_and(|err| err.protocol) {
1186        filters.remove(command);
1187    }
1188    result
1189}
1190
1191pub(crate) fn list_available_process_filter_blobs(
1192    command: &str,
1193) -> std::result::Result<Vec<Vec<u8>>, ProcessFilterFailure> {
1194    let filters = PROCESS_FILTERS.get_or_init(|| Mutex::new(HashMap::new()));
1195    let mut filters = filters
1196        .lock()
1197        .map_err(|_| ProcessFilterFailure::protocol("process filter cache poisoned"))?;
1198    let Some(filter) = filters.get_mut(command) else {
1199        return Err(ProcessFilterFailure::protocol(format!(
1200            "external filter '{command}' is not available anymore although not all paths have been filtered"
1201        )));
1202    };
1203    let result = filter.list_available_blobs();
1204    if result.as_ref().is_err_and(|err| err.protocol) {
1205        filters.remove(command);
1206    }
1207    result
1208}
1209
1210impl ProcessFilter {
1211    fn start(command: &str) -> std::result::Result<Self, ProcessFilterFailure> {
1212        let (shell, flag) = if cfg!(windows) {
1213            ("cmd", "/C")
1214        } else {
1215            ("/bin/sh", "-c")
1216        };
1217        let mut process = Command::new(shell);
1218        process
1219            .arg(flag)
1220            .arg(command)
1221            .stdin(Stdio::piped())
1222            .stdout(Stdio::piped())
1223            .stderr(Stdio::inherit());
1224        if let Some(cwd) = current_process_filter_cwd() {
1225            process.current_dir(cwd);
1226        }
1227        let mut child = process.spawn().map_err(|err| {
1228            ProcessFilterFailure::protocol(format!(
1229                "cannot fork to run subprocess '{command}': {err}"
1230            ))
1231        })?;
1232        let mut stdin = child
1233            .stdin
1234            .take()
1235            .ok_or_else(|| ProcessFilterFailure::protocol("process filter stdin unavailable"))?;
1236        let mut stdout = child
1237            .stdout
1238            .take()
1239            .ok_or_else(|| ProcessFilterFailure::protocol("process filter stdout unavailable"))?;
1240
1241        write_pkt_text(&mut stdin, "git-filter-client\n")?;
1242        write_pkt_text(&mut stdin, "version=2\n")?;
1243        write_flush(&mut stdin)?;
1244
1245        let line = read_pkt_text(&mut stdout)?.ok_or_else(|| {
1246            ProcessFilterFailure::protocol(
1247                "Unexpected line '<flush packet>', expected git-filter-server",
1248            )
1249        })?;
1250        if line != "git-filter-server" {
1251            return Err(ProcessFilterFailure::protocol(format!(
1252                "Unexpected line '{line}', expected git-filter-server"
1253            )));
1254        }
1255        let line = read_pkt_text(&mut stdout)?.ok_or_else(|| {
1256            ProcessFilterFailure::protocol("Unexpected line '<flush packet>', expected version")
1257        })?;
1258        if line != "version=2" {
1259            return Err(ProcessFilterFailure::protocol(format!(
1260                "Unexpected line '{line}', expected version"
1261            )));
1262        }
1263        if let Some(line) = read_pkt_text(&mut stdout)? {
1264            return Err(ProcessFilterFailure::protocol(format!(
1265                "Unexpected line '{line}', expected flush"
1266            )));
1267        }
1268
1269        write_pkt_text(&mut stdin, "capability=clean\n")?;
1270        write_pkt_text(&mut stdin, "capability=smudge\n")?;
1271        write_pkt_text(&mut stdin, "capability=delay\n")?;
1272        write_flush(&mut stdin)?;
1273
1274        let mut capabilities = 0;
1275        while let Some(line) = read_pkt_text(&mut stdout)? {
1276            match line.as_str() {
1277                "capability=clean" => capabilities |= PROCESS_CAP_CLEAN,
1278                "capability=smudge" => capabilities |= PROCESS_CAP_SMUDGE,
1279                "capability=delay" => capabilities |= PROCESS_CAP_DELAY,
1280                _ => {}
1281            }
1282        }
1283
1284        Ok(Self {
1285            child,
1286            stdin: Some(stdin),
1287            stdout,
1288            capabilities,
1289            closed: false,
1290        })
1291    }
1292
1293    fn apply(
1294        &mut self,
1295        direction: &str,
1296        path: &[u8],
1297        content: &[u8],
1298        blob: Option<ObjectId>,
1299        can_delay: bool,
1300    ) -> std::result::Result<ProcessFilterOutcome, ProcessFilterFailure> {
1301        let wanted = match direction {
1302            "clean" => PROCESS_CAP_CLEAN,
1303            "smudge" => PROCESS_CAP_SMUDGE,
1304            _ => 0,
1305        };
1306        if self.capabilities & wanted == 0 {
1307            return Ok(ProcessFilterOutcome::Unsupported);
1308        }
1309
1310        let can_delay =
1311            can_delay && direction == "smudge" && self.capabilities & PROCESS_CAP_DELAY != 0;
1312
1313        {
1314            let stdin = self
1315                .stdin
1316                .as_mut()
1317                .ok_or_else(|| ProcessFilterFailure::protocol("process filter stdin closed"))?;
1318            write_pkt_text(stdin, &format!("command={direction}\n"))?;
1319            write_pkt_text(
1320                stdin,
1321                &format!("pathname={}\n", String::from_utf8_lossy(path)),
1322            )?;
1323            if direction == "smudge"
1324                && let Some(blob) = blob
1325            {
1326                if let Some(metadata) = current_process_filter_metadata() {
1327                    for (key, value) in metadata {
1328                        write_pkt_text(stdin, &format!("{key}={value}\n"))?;
1329                    }
1330                }
1331                write_pkt_text(stdin, &format!("blob={}\n", blob.to_hex()))?;
1332            }
1333            if can_delay {
1334                write_pkt_text(stdin, "can-delay=1\n")?;
1335            }
1336            write_flush(stdin)?;
1337            write_pkt_content(stdin, content)?;
1338            write_flush(stdin)?;
1339        }
1340
1341        let mut status = read_process_status(&mut self.stdout)?.unwrap_or_default();
1342        match status.as_str() {
1343            "success" => {}
1344            "error" | "abort" | "delayed" => return Ok(ProcessFilterOutcome::Status(status)),
1345            other => {
1346                return Err(ProcessFilterFailure::protocol(format!(
1347                    "external filter returned unsupported status '{other}'"
1348                )));
1349            }
1350        }
1351
1352        let output = read_pkt_content(&mut self.stdout)?;
1353        if let Some(next) = read_process_status(&mut self.stdout)? {
1354            status = next;
1355        }
1356        match status.as_str() {
1357            "" | "success" => Ok(ProcessFilterOutcome::Filtered(output)),
1358            "error" | "abort" | "delayed" => Ok(ProcessFilterOutcome::Status(status)),
1359            other => Err(ProcessFilterFailure::protocol(format!(
1360                "external filter returned unsupported status '{other}'"
1361            ))),
1362        }
1363    }
1364
1365    fn list_available_blobs(&mut self) -> std::result::Result<Vec<Vec<u8>>, ProcessFilterFailure> {
1366        if self.capabilities & PROCESS_CAP_DELAY == 0 {
1367            return Ok(Vec::new());
1368        }
1369        {
1370            let stdin = self
1371                .stdin
1372                .as_mut()
1373                .ok_or_else(|| ProcessFilterFailure::protocol("process filter stdin closed"))?;
1374            write_pkt_text(stdin, "command=list_available_blobs\n")?;
1375            write_flush(stdin)?;
1376        }
1377
1378        let mut paths = Vec::new();
1379        while let Some(line) = read_pkt_text(&mut self.stdout)? {
1380            if let Some(path) = line.strip_prefix("pathname=") {
1381                paths.push(path.as_bytes().to_vec());
1382            }
1383        }
1384        let status = read_process_status(&mut self.stdout)?.unwrap_or_default();
1385        match status.as_str() {
1386            "" | "success" => Ok(paths),
1387            other => Err(ProcessFilterFailure::protocol(format!(
1388                "external filter returned unsupported status '{other}'"
1389            ))),
1390        }
1391    }
1392
1393    fn finish_gracefully(&mut self) {
1394        self.stdin.take();
1395        for _ in 0..10 {
1396            match self.child.try_wait() {
1397                Ok(Some(_)) => {
1398                    self.closed = true;
1399                    return;
1400                }
1401                Ok(None) => std::thread::sleep(std::time::Duration::from_millis(10)),
1402                Err(_) => {
1403                    self.closed = true;
1404                    return;
1405                }
1406            }
1407        }
1408        let _ = self.child.kill();
1409        let _ = self.child.wait();
1410        self.closed = true;
1411    }
1412}
1413
1414impl Drop for ProcessFilter {
1415    fn drop(&mut self) {
1416        if self.closed {
1417            return;
1418        }
1419        if let Some(stdin) = self.stdin.as_mut() {
1420            let _ = stdin.flush();
1421        }
1422        let _ = self.child.kill();
1423        let _ = self.child.wait();
1424    }
1425}
1426
1427pub(crate) fn write_pkt_text(
1428    writer: &mut ChildStdin,
1429    text: &str,
1430) -> std::result::Result<(), ProcessFilterFailure> {
1431    write_pkt_data(writer, text.as_bytes())
1432}
1433
1434pub(crate) fn write_pkt_content(
1435    writer: &mut ChildStdin,
1436    content: &[u8],
1437) -> std::result::Result<(), ProcessFilterFailure> {
1438    for chunk in content.chunks(PKT_DATA_MAX) {
1439        write_pkt_data(writer, chunk)?;
1440    }
1441    Ok(())
1442}
1443
1444pub(crate) fn write_pkt_data(
1445    writer: &mut ChildStdin,
1446    data: &[u8],
1447) -> std::result::Result<(), ProcessFilterFailure> {
1448    let len = data.len() + 4;
1449    write!(writer, "{len:04x}")
1450        .and_then(|_| writer.write_all(data))
1451        .map_err(|err| {
1452            ProcessFilterFailure::protocol(format!("process filter write failed: {err}"))
1453        })
1454}
1455
1456pub(crate) fn write_flush(
1457    writer: &mut ChildStdin,
1458) -> std::result::Result<(), ProcessFilterFailure> {
1459    writer
1460        .write_all(b"0000")
1461        .and_then(|_| writer.flush())
1462        .map_err(|err| {
1463            ProcessFilterFailure::protocol(format!("process filter write failed: {err}"))
1464        })
1465}
1466
1467pub(crate) fn read_pkt_text(
1468    reader: &mut ChildStdout,
1469) -> std::result::Result<Option<String>, ProcessFilterFailure> {
1470    let Some(mut data) = read_pkt_data(reader)? else {
1471        return Ok(None);
1472    };
1473    if data.last() == Some(&b'\n') {
1474        data.pop();
1475    }
1476    Ok(Some(String::from_utf8_lossy(&data).into_owned()))
1477}
1478
1479pub(crate) fn read_pkt_content(
1480    reader: &mut ChildStdout,
1481) -> std::result::Result<Vec<u8>, ProcessFilterFailure> {
1482    let mut out = Vec::new();
1483    while let Some(data) = read_pkt_data(reader)? {
1484        out.extend_from_slice(&data);
1485    }
1486    Ok(out)
1487}
1488
1489pub(crate) fn read_pkt_data(
1490    reader: &mut ChildStdout,
1491) -> std::result::Result<Option<Vec<u8>>, ProcessFilterFailure> {
1492    let mut header = [0u8; 4];
1493    reader.read_exact(&mut header).map_err(|err| {
1494        ProcessFilterFailure::protocol(format!("process filter read failed: {err}"))
1495    })?;
1496    let header = std::str::from_utf8(&header)
1497        .map_err(|err| ProcessFilterFailure::protocol(format!("invalid pkt-line header: {err}")))?;
1498    let len = usize::from_str_radix(header, 16)
1499        .map_err(|err| ProcessFilterFailure::protocol(format!("invalid pkt-line length: {err}")))?;
1500    if len == 0 {
1501        return Ok(None);
1502    }
1503    if len < 4 {
1504        return Err(ProcessFilterFailure::protocol(format!(
1505            "invalid pkt-line length {len}"
1506        )));
1507    }
1508    let mut data = vec![0; len - 4];
1509    reader.read_exact(&mut data).map_err(|err| {
1510        ProcessFilterFailure::protocol(format!("process filter read failed: {err}"))
1511    })?;
1512    Ok(Some(data))
1513}
1514
1515pub(crate) fn read_process_status(
1516    reader: &mut ChildStdout,
1517) -> std::result::Result<Option<String>, ProcessFilterFailure> {
1518    let mut status = None;
1519    while let Some(line) = read_pkt_text(reader)? {
1520        if let Some(value) = line.strip_prefix("status=") {
1521            status = Some(value.to_string());
1522        }
1523    }
1524    Ok(status)
1525}
1526
1527/// Minimal POSIX single-quote escaping for substituting `%f` into a shell
1528/// command (used only for the path passed to driver filters).
1529pub(crate) fn shell_quote(value: &str) -> String {
1530    let mut out = String::with_capacity(value.len() + 2);
1531    out.push('\'');
1532    for ch in value.chars() {
1533        if ch == '\'' {
1534            out.push_str("'\\''");
1535        } else {
1536            out.push(ch);
1537        }
1538    }
1539    out.push('\'');
1540    out
1541}
1542
1543/// Apply the *clean* conversion to `content` for `path` (worktree -> blob):
1544/// first the configured `filter.<name>.clean` driver (if any), then CRLF->LF
1545/// normalization when EOL conversion applies.
1546///
1547/// `config` is the repository config (`GitConfig`) and `path` is the
1548/// repository-relative path of the file (forward-slash separated, e.g.
1549/// `src/main.rs`). When no filter or EOL conversion applies the input is
1550/// returned unchanged.
1551///
1552/// A *required* driver (`filter.<name>.required=true`) whose `clean` command is
1553/// missing or fails produces a [`GitError::Command`]; a non-required driver
1554/// failure (or absence of a `clean` command) passes the content through
1555/// unfiltered, matching git.
1556pub fn apply_clean_filter(
1557    worktree_root: impl AsRef<Path>,
1558    git_dir: impl AsRef<Path>,
1559    config: &GitConfig,
1560    path: &[u8],
1561    content: &[u8],
1562) -> Result<Vec<u8>> {
1563    // On clean the worktree file exists, so the live `.gitattributes` chain is
1564    // authoritative. `git_dir` is accepted for symmetry with the smudge entry
1565    // point (which falls back to the index) and for future use.
1566    let _ = git_dir.as_ref();
1567    let checks = filter_attribute_checks(worktree_root.as_ref(), path)?;
1568    apply_clean_filter_with_attributes(config, &checks, path, content)
1569}
1570
1571/// A reusable handle that captures the worktree's `.gitattributes` chain once so
1572/// repeated clean-filter calls (e.g. `hash-object --stdin-paths` hashing many
1573/// paths in one process) don't re-walk the worktree and re-read every
1574/// `.gitattributes`/global config per path.
1575///
1576/// Build it once with [`WorktreeAttributes::from_worktree_root`], then call
1577/// [`WorktreeAttributes::apply_clean_filter`] per path. This mirrors
1578/// [`apply_clean_filter`] exactly except the expensive attribute-source scan is
1579/// amortized across calls.
1580pub struct WorktreeAttributes {
1581    matcher: AttributeMatcher,
1582}
1583
1584impl WorktreeAttributes {
1585    /// Read the worktree's attribute sources once (global/`core.attributesFile`,
1586    /// every in-tree `.gitattributes`, and `$GIT_DIR/info/attributes`).
1587    pub fn from_worktree_root(worktree_root: impl AsRef<Path>) -> Result<Self> {
1588        Ok(Self {
1589            matcher: AttributeMatcher::from_worktree_root(worktree_root.as_ref())?,
1590        })
1591    }
1592
1593    /// Apply the clean conversion to `content` for `path`, reusing the cached
1594    /// attribute chain. Behaviourally identical to [`apply_clean_filter`].
1595    pub fn apply_clean_filter(
1596        &self,
1597        config: &GitConfig,
1598        path: &[u8],
1599        content: &[u8],
1600    ) -> Result<Vec<u8>> {
1601        let checks = self
1602            .matcher
1603            .attributes_for_path(path, &filter_attribute_names(), false);
1604        apply_clean_filter_with_attributes(config, &checks, path, content)
1605    }
1606}
1607
1608/// A reusable handle that captures a *tree's* `.gitattributes` chain once so
1609/// repeated smudge-filter calls (e.g. `git archive` streaming every blob in a
1610/// tree) resolve attributes from the tree being processed rather than the live
1611/// worktree.
1612///
1613/// This is the attribute direction `git archive` uses: upstream unpacks the
1614/// archived tree into a scratch index and sets `GIT_ATTR_INDEX`, so the
1615/// `.gitattributes` that govern conversion come from the *archived tree* (plus
1616/// the global/`core.attributesFile` chain and `$GIT_DIR/info/attributes`), not
1617/// from whatever happens to be checked out. `--worktree-attributes` callers
1618/// should use [`WorktreeAttributes`] instead.
1619///
1620/// Build it once with [`TreeAttributes::from_tree`], then call
1621/// [`TreeAttributes::apply_smudge_filter`] per blob. Behaviourally this mirrors
1622/// [`apply_smudge_filter`] except the attribute source is the supplied tree and
1623/// the expensive source scan is amortized across calls.
1624pub struct TreeAttributes {
1625    matcher: AttributeMatcher,
1626}
1627
1628impl TreeAttributes {
1629    /// Read the attribute sources for `tree_oid` once: the global /
1630    /// `core.attributesFile` chain, every `.gitattributes` blob found while
1631    /// walking `tree_oid`, and `$GIT_DIR/info/attributes`.
1632    ///
1633    /// `attr_root` locates the global config (`read_configured_attributes`);
1634    /// pass the worktree root for a non-bare repo, or the git dir for a bare
1635    /// one. `git_dir` locates `info/attributes` directly (so this works for bare
1636    /// repos, where there is no nested `.git`). No worktree `.gitattributes`
1637    /// files are read — use [`WorktreeAttributes`] for the
1638    /// `--worktree-attributes` direction.
1639    pub fn from_tree(
1640        attr_root: impl AsRef<Path>,
1641        git_dir: impl AsRef<Path>,
1642        db: &FileObjectDatabase,
1643        format: ObjectFormat,
1644        tree_oid: &ObjectId,
1645    ) -> Result<Self> {
1646        let attr_root = attr_root.as_ref();
1647        let git_dir = git_dir.as_ref();
1648        let mut matcher = AttributeMatcher::default();
1649        matcher.configure_case_sensitivity(git_dir);
1650        if !matcher.read_configured_attributes(attr_root, git_dir) {
1651            matcher.read_default_global_attributes();
1652        }
1653        collect_attribute_patterns_from_tree(db, format, tree_oid, Vec::new(), &mut matcher)?;
1654        read_attribute_patterns(
1655            git_dir.join("info").join("attributes"),
1656            &mut matcher,
1657            &[],
1658            b"info/attributes",
1659            false,
1660        );
1661        Ok(Self { matcher })
1662    }
1663
1664    /// Apply the smudge conversion (blob -> worktree: EOL `LF`->`CRLF` plus any
1665    /// configured `filter.<name>.smudge` driver) to `content` for `path`,
1666    /// reusing the cached attribute chain. Behaviourally identical to
1667    /// [`apply_smudge_filter`] except attributes come from the tree this handle
1668    /// was built from.
1669    pub fn apply_smudge_filter(
1670        &self,
1671        config: &GitConfig,
1672        path: &[u8],
1673        content: &[u8],
1674    ) -> Result<Vec<u8>> {
1675        let checks = self
1676            .matcher
1677            .attributes_for_path(path, &filter_attribute_names(), false);
1678        apply_smudge_filter_with_attributes(config, &checks, path, content)
1679    }
1680
1681    pub fn attributes_for_path(&self, path: &[u8], requested: &[Vec<u8>]) -> Vec<AttributeCheck> {
1682        self.matcher.attributes_for_path(path, requested, false)
1683    }
1684
1685    /// True when `path` has the `export-subst` attribute set (git's
1686    /// `check_attr_export_subst`), meaning `git archive` should run
1687    /// `$Format:…$` keyword substitution on its content.
1688    pub fn export_subst_for_path(&self, path: &[u8]) -> bool {
1689        self.attribute_is_set(path, b"export-subst")
1690    }
1691
1692    /// True when `path` has the `export-ignore` attribute set (git's
1693    /// `check_attr_export_ignore`), meaning `git archive` should omit the path
1694    /// (and, for a directory, its whole subtree) from the archive.
1695    pub fn export_ignore_for_path(&self, path: &[u8]) -> bool {
1696        self.attribute_is_set(path, b"export-ignore")
1697    }
1698
1699    fn attribute_is_set(&self, path: &[u8], attribute: &[u8]) -> bool {
1700        let requested = [attribute.to_vec()];
1701        let checks = self.matcher.attributes_for_path(path, &requested, false);
1702        matches!(
1703            checks.first().and_then(|check| check.state.as_ref()),
1704            Some(AttributeState::Set)
1705        )
1706    }
1707
1708    /// The `diff` attribute state for `path` (`Set` for `diff`, `Unset` for
1709    /// `-diff`, `Value(name)` for `diff=<name>`, `None` when unspecified). Used
1710    /// by `git archive`'s zip backend to classify text vs. binary via the
1711    /// path's userdiff driver.
1712    pub fn diff_attribute_for_path(&self, path: &[u8]) -> Option<AttributeState> {
1713        let requested = [b"diff".to_vec()];
1714        let checks = self.matcher.attributes_for_path(path, &requested, false);
1715        checks.into_iter().next().and_then(|check| check.state)
1716    }
1717}
1718
1719/// Like [`apply_clean_filter`] but takes already-resolved attribute checks,
1720/// letting callers that have computed attributes once reuse them.
1721pub fn apply_clean_filter_with_attributes(
1722    config: &GitConfig,
1723    attributes: &[AttributeCheck],
1724    path: &[u8],
1725    content: &[u8],
1726) -> Result<Vec<u8>> {
1727    Ok(apply_clean_filter_with_attributes_cow(config, attributes, path, content)?.into_owned())
1728}
1729
1730/// Borrow-first variant of [`apply_clean_filter_with_attributes`].
1731///
1732/// When no filter or EOL conversion changes the content, the returned value
1733/// borrows `content`; callers that can consume a [`Cow`] avoid allocating for
1734/// the common pass-through case.
1735pub fn apply_clean_filter_with_attributes_cow<'a>(
1736    config: &GitConfig,
1737    attributes: &[AttributeCheck],
1738    path: &[u8],
1739    content: &'a [u8],
1740) -> Result<Cow<'a, [u8]>> {
1741    apply_clean_filter_with_attributes_cow_safecrlf(
1742        config,
1743        attributes,
1744        path,
1745        content,
1746        ConvFlags::Off,
1747        SafeCrlfIndexBlob::None,
1748    )
1749}
1750
1751/// How the safecrlf check should learn whether this path's *current index blob*
1752/// already contains a CRLF (git's `has_crlf_in_index`). Only consulted on the
1753/// `text=auto` / `core.autocrlf` path.
1754pub enum SafeCrlfIndexBlob<'a> {
1755    /// No index blob is available (the staging caller has none, or safecrlf is
1756    /// off) — treated as "no CRLF in index".
1757    None,
1758    /// The path's current index blob, read on demand from this object database
1759    /// only when the auto-crlf decision actually needs it.
1760    Lookup {
1761        odb: &'a FileObjectDatabase,
1762        oid: ObjectId,
1763    },
1764}
1765
1766impl SafeCrlfIndexBlob<'_> {
1767    fn has_crlf(&self) -> bool {
1768        match self {
1769            SafeCrlfIndexBlob::None => false,
1770            SafeCrlfIndexBlob::Lookup { odb, oid } => has_crlf_in_index(odb, oid),
1771        }
1772    }
1773}
1774
1775/// [`apply_clean_filter_with_attributes_cow`] plus git's additive `core.safecrlf`
1776/// round-trip warning (convert.c `crlf_to_git`).
1777///
1778/// The conversion result is byte-for-byte identical to the plain variant;
1779/// `flags`/`index_blob` only drive the stderr warning git prints when a
1780/// CRLF<->LF round-trip would not be reversible. The warning is computed on the
1781/// *post-driver, pre-EOL-conversion* content, matching git's ordering in
1782/// `convert_to_git` (apply_filter -> crlf_to_git).
1783pub fn apply_clean_filter_with_attributes_cow_safecrlf<'a>(
1784    config: &GitConfig,
1785    attributes: &[AttributeCheck],
1786    path: &[u8],
1787    content: &'a [u8],
1788    flags: ConvFlags,
1789    index_blob: SafeCrlfIndexBlob<'_>,
1790) -> Result<Cow<'a, [u8]>> {
1791    // Non-object-writing callers (diff/status comparison): an encoding failure
1792    // is reported but not fatal.
1793    apply_clean_filter_cow_inner(config, attributes, path, content, flags, index_blob, false)
1794}
1795
1796/// Clean conversion core. `write_object` is set on the paths that hash content
1797/// into the object database (add / hash-object): there, an invalid
1798/// `working-tree-encoding` (bad BOM, undecodable bytes) is fatal, mirroring
1799/// convert.c's `CONV_WRITE_OBJECT` die.
1800pub(crate) fn apply_clean_filter_cow_inner<'a>(
1801    config: &GitConfig,
1802    attributes: &[AttributeCheck],
1803    path: &[u8],
1804    content: &'a [u8],
1805    flags: ConvFlags,
1806    index_blob: SafeCrlfIndexBlob<'_>,
1807    write_object: bool,
1808) -> Result<Cow<'a, [u8]>> {
1809    let plan = ContentFilterPlan::resolve(config, attributes);
1810    check_wt_encoding_valid(&plan.encoding)?;
1811    let mut data = Cow::Borrowed(content);
1812    if let Some(driver) = &plan.driver {
1813        data = run_driver(driver, driver.clean.as_deref(), "clean", None, path, data)?;
1814    }
1815    // encode_to_git runs before the EOL pass (convert.c order: filter →
1816    // encode_to_git → crlf_to_git): the worktree charset is decoded to UTF-8 so
1817    // the line-ending stats and conversion below see real LF/CRLF bytes.
1818    data = encode_to_git(config, &plan.encoding, path, data, write_object)?;
1819    // The safecrlf check scans the (post-driver) buffer once for line-ending
1820    // stats. Gate it tightly so the extra scan never runs on the dominant
1821    // pass-through paths: only when safecrlf is enabled, the path is a real
1822    // conversion candidate (not `CRLF_BINARY`), and the buffer is non-empty.
1823    if flags != ConvFlags::Off && !data.is_empty() && plan.safecrlf_applies() {
1824        let old_stats = gather_convert_stats(&data);
1825        plan.check_safe_crlf_stats(&old_stats, index_blob.has_crlf(), flags, path)?;
1826    }
1827    if plan.convert_eol(&data) {
1828        data = convert_crlf_to_lf_cow(data);
1829    }
1830    if plan.ident {
1831        data = ident_to_git_cow(data);
1832    }
1833    Ok(data)
1834}
1835
1836/// Apply the *smudge* conversion to `content` for `path` (blob -> worktree):
1837/// first LF->CRLF when EOL conversion applies, then the configured
1838/// `filter.<name>.smudge` driver (if any).
1839///
1840/// Semantics mirror [`apply_clean_filter`]: a required driver with a missing or
1841/// failing `smudge` command errors, while a non-required one passes the content
1842/// through.
1843pub fn apply_smudge_filter(
1844    worktree_root: impl AsRef<Path>,
1845    git_dir: impl AsRef<Path>,
1846    format: ObjectFormat,
1847    config: &GitConfig,
1848    path: &[u8],
1849    content: &[u8],
1850) -> Result<Vec<u8>> {
1851    // On smudge (checkout) the worktree file may not exist yet, so resolve the
1852    // attributes from the `.gitattributes` recorded in the index.
1853    let checks =
1854        smudge_attribute_checks_from_index(worktree_root.as_ref(), git_dir.as_ref(), format, path)?;
1855    Ok(
1856        apply_smudge_filter_with_attributes_cow_format(config, &checks, path, content, format)?
1857            .into_owned(),
1858    )
1859}
1860
1861/// Like [`apply_smudge_filter`] but takes already-resolved attribute checks.
1862pub fn apply_smudge_filter_with_attributes(
1863    config: &GitConfig,
1864    attributes: &[AttributeCheck],
1865    path: &[u8],
1866    content: &[u8],
1867) -> Result<Vec<u8>> {
1868    Ok(apply_smudge_filter_with_attributes_cow(config, attributes, path, content)?.into_owned())
1869}
1870
1871/// Borrow-first variant of [`apply_smudge_filter_with_attributes`].
1872///
1873/// When no filter or EOL conversion changes the content, the returned value
1874/// borrows `content`; callers that can consume a [`Cow`] avoid allocating for
1875/// the common pass-through case.
1876pub fn apply_smudge_filter_with_attributes_cow<'a>(
1877    config: &GitConfig,
1878    attributes: &[AttributeCheck],
1879    path: &[u8],
1880    content: &'a [u8],
1881) -> Result<Cow<'a, [u8]>> {
1882    apply_smudge_filter_with_attributes_cow_format(
1883        config,
1884        attributes,
1885        path,
1886        content,
1887        ObjectFormat::Sha1,
1888    )
1889}
1890
1891pub(crate) fn apply_smudge_filter_with_attributes_cow_format<'a>(
1892    config: &GitConfig,
1893    attributes: &[AttributeCheck],
1894    path: &[u8],
1895    content: &'a [u8],
1896    format: ObjectFormat,
1897) -> Result<Cow<'a, [u8]>> {
1898    match apply_smudge_filter_with_attributes_maybe_delayed(
1899        config, attributes, path, content, format, false,
1900    )? {
1901        SmudgeFilterResult::Content(data) => Ok(data),
1902        SmudgeFilterResult::Delayed { .. } => unreachable!("delay was not enabled"),
1903    }
1904}
1905
1906pub(crate) fn apply_smudge_filter_with_attributes_maybe_delayed<'a>(
1907    config: &GitConfig,
1908    attributes: &[AttributeCheck],
1909    path: &[u8],
1910    content: &'a [u8],
1911    format: ObjectFormat,
1912    allow_delay: bool,
1913) -> Result<SmudgeFilterResult<'a>> {
1914    let plan = ContentFilterPlan::resolve(config, attributes);
1915    check_wt_encoding_valid(&plan.encoding)?;
1916    let process_blob = if plan
1917        .driver
1918        .as_ref()
1919        .and_then(|driver| driver.process.as_ref())
1920        .is_some()
1921    {
1922        Some(EncodedObject::new(ObjectType::Blob, content.to_vec()).object_id(format)?)
1923    } else {
1924        None
1925    };
1926    let mut data = Cow::Borrowed(content);
1927    if plan.ident {
1928        data = ident_to_worktree_cow(format, data)?;
1929    }
1930    if plan.eol == EolConversion::Crlf
1931        && plan.convert_eol(&data)
1932        && plan.will_convert_lf_to_crlf(&data)
1933    {
1934        data = Cow::Owned(convert_lf_to_crlf(&data));
1935    }
1936    // encode_to_worktree runs after the EOL pass (convert.c order:
1937    // crlf_to_worktree → encode_to_worktree → smudge filter): the UTF-8 blob is
1938    // line-ending-converted, then reencoded into the worktree charset.
1939    data = encode_to_worktree(&plan.encoding, path, data)?;
1940    if let Some(driver) = &plan.driver {
1941        return match run_driver_maybe_delayed(
1942            driver,
1943            driver.smudge.as_deref(),
1944            "smudge",
1945            Some(format),
1946            process_blob,
1947            path,
1948            data,
1949            allow_delay,
1950        )? {
1951            DriverFilterResult::Content(data) => Ok(SmudgeFilterResult::Content(data)),
1952            DriverFilterResult::Delayed { process } => Ok(SmudgeFilterResult::Delayed { process }),
1953        };
1954    }
1955    Ok(SmudgeFilterResult::Content(data))
1956}
1957
1958/// Execute one direction of a driver filter, honouring the `required` flag.
1959pub(crate) fn run_driver<'a>(
1960    driver: &FilterDriver,
1961    command: Option<&str>,
1962    direction: &str,
1963    format: Option<ObjectFormat>,
1964    path: &[u8],
1965    content: Cow<'a, [u8]>,
1966) -> Result<Cow<'a, [u8]>> {
1967    match run_driver_maybe_delayed(
1968        driver, command, direction, format, None, path, content, false,
1969    )? {
1970        DriverFilterResult::Content(data) => Ok(data),
1971        DriverFilterResult::Delayed { .. } => unreachable!("delay was not enabled"),
1972    }
1973}
1974
1975pub(crate) fn run_driver_maybe_delayed<'a>(
1976    driver: &FilterDriver,
1977    command: Option<&str>,
1978    direction: &str,
1979    format: Option<ObjectFormat>,
1980    process_blob: Option<ObjectId>,
1981    path: &[u8],
1982    content: Cow<'a, [u8]>,
1983    allow_delay: bool,
1984) -> Result<DriverFilterResult<'a>> {
1985    if let Some(process) = &driver.process {
1986        let blob = if direction == "smudge" {
1987            match (process_blob, format) {
1988                (Some(blob), _) => Some(blob),
1989                (None, Some(format)) => {
1990                    Some(EncodedObject::new(ObjectType::Blob, content.to_vec()).object_id(format)?)
1991                }
1992                (None, None) => None,
1993            }
1994        } else {
1995            None
1996        };
1997        match run_process_filter(process, direction, path, &content, blob, allow_delay) {
1998            Ok(ProcessFilterOutcome::Filtered(output)) => {
1999                return Ok(DriverFilterResult::Content(Cow::Owned(output)));
2000            }
2001            Ok(ProcessFilterOutcome::Unsupported) => {}
2002            Ok(ProcessFilterOutcome::Status(status)) => {
2003                if allow_delay && status == "delayed" {
2004                    return Ok(DriverFilterResult::Delayed {
2005                        process: process.clone(),
2006                    });
2007                }
2008                if driver.required {
2009                    return Err(GitError::Command(format!(
2010                        "external filter '{}' returned status {status}",
2011                        process
2012                    )));
2013                }
2014                return Ok(DriverFilterResult::Content(content));
2015            }
2016            Err(err) => {
2017                if err.protocol {
2018                    eprintln!("error: external filter '{}' failed", process);
2019                }
2020                if driver.required {
2021                    return Err(GitError::Command(err.message));
2022                }
2023                return Ok(DriverFilterResult::Content(content));
2024            }
2025        }
2026    }
2027    let Some(command) = command else {
2028        // No command in this direction. Required filters must error; optional
2029        // ones pass content through unchanged.
2030        if driver.required {
2031            let path = String::from_utf8_lossy(path);
2032            let name = String::from_utf8_lossy(&driver.name);
2033            if direction == "clean" {
2034                eprintln!("fatal: {path}: clean filter '{name}' failed");
2035            } else {
2036                eprintln!("fatal: {path}: smudge filter {name} failed");
2037            }
2038            return Err(GitError::Exit(128));
2039        }
2040        return Ok(DriverFilterResult::Content(content));
2041    };
2042    match run_filter_command(command, path, &content) {
2043        Ok(output) => Ok(DriverFilterResult::Content(Cow::Owned(output))),
2044        Err(err) => {
2045            if driver.required {
2046                Err(err)
2047            } else {
2048                // Non-required filter failure: fall back to the unfiltered
2049                // content, matching git's behaviour.
2050                Ok(DriverFilterResult::Content(content))
2051            }
2052        }
2053    }
2054}
2055
2056/// Compute the attributes relevant to content filtering (`text`, `eol`,
2057/// `filter`) for `path` from the worktree `.gitattributes` chain.
2058pub(crate) fn filter_attribute_checks(
2059    worktree_root: &Path,
2060    path: &[u8],
2061) -> Result<Vec<AttributeCheck>> {
2062    let requested = filter_attribute_names();
2063    let mut matcher = AttributeMatcher::default();
2064    let git_dir = worktree_root.join(".git");
2065    matcher.configure_case_sensitivity(&git_dir);
2066    if !matcher.read_configured_attributes(worktree_root, &git_dir) {
2067        matcher.read_default_global_attributes();
2068    }
2069    read_dir_attribute_patterns_for_base(worktree_root, &[], &mut matcher)?;
2070    let mut prefix = Vec::new();
2071    let mut parts = path.split(|byte| *byte == b'/').peekable();
2072    while let Some(part) = parts.next() {
2073        if parts.peek().is_none() {
2074            break;
2075        }
2076        if !prefix.is_empty() {
2077            prefix.push(b'/');
2078        }
2079        prefix.extend_from_slice(part);
2080        let dir = worktree_root.join(repo_path_to_os_path(&prefix)?);
2081        read_dir_attribute_patterns_for_base(&dir, &prefix, &mut matcher)?;
2082    }
2083    read_attribute_patterns(
2084        worktree_root.join(".git").join("info").join("attributes"),
2085        &mut matcher,
2086        &[],
2087        b".git/info/attributes",
2088        false,
2089    );
2090    Ok(matcher.attributes_for_path(path, &requested, false))
2091}
2092
2093/// Compute filtering attributes for a checkout (blob -> worktree).
2094///
2095/// `git checkout -- <pathspec>` / `git restore` materialize through git's
2096/// **default** attr direction, which is `GIT_ATTR_CHECKIN` (attr.c: the static
2097/// `direction` is zero-initialized and `builtin/checkout.c` never overrides it
2098/// for the pathspec path). Under that direction `read_attr` reads each
2099/// `.gitattributes` frame from the **worktree file first**, falling back to the
2100/// staged blob only when no worktree file exists at that directory level
2101/// (sparse-checkout). This is the precedence the smudge filter must use:
2102/// t0027 commits an *empty* root `.gitattributes`, then overwrites the worktree
2103/// copy with `*.txt text eol=crlf` *without re-staging* — and git's checkout
2104/// still honours the worktree copy. Reading the index alone (or index-first)
2105/// made checkout under-convert line endings, because the staged blob was empty.
2106pub(crate) fn smudge_attribute_checks_from_index(
2107    worktree_root: &Path,
2108    git_dir: &Path,
2109    format: ObjectFormat,
2110    path: &[u8],
2111) -> Result<Vec<AttributeCheck>> {
2112    let requested = filter_attribute_names();
2113    let mut matcher = AttributeMatcher::default();
2114    matcher.configure_case_sensitivity(git_dir);
2115    if !matcher.read_configured_attributes(worktree_root, git_dir) {
2116        matcher.read_default_global_attributes();
2117    }
2118
2119    // Build the set of `.gitattributes` blobs the index carries, keyed by the
2120    // directory they govern, so each ancestry frame can prefer the staged copy.
2121    let index_attributes = index_gitattributes_by_base(git_dir, format)?;
2122
2123    // Walk root -> ... -> the file's parent directory, folding each frame's
2124    // `.gitattributes` in shallow-to-deep order so deeper directories win.
2125    fold_checkout_attribute_frame(worktree_root, &[], &index_attributes, &mut matcher)?;
2126    let mut prefix = Vec::new();
2127    let mut parts = path.split(|byte| *byte == b'/').peekable();
2128    while let Some(part) = parts.next() {
2129        if parts.peek().is_none() {
2130            break;
2131        }
2132        if !prefix.is_empty() {
2133            prefix.push(b'/');
2134        }
2135        prefix.extend_from_slice(part);
2136        let dir = worktree_root.join(repo_path_to_os_path(&prefix)?);
2137        fold_checkout_attribute_frame(&dir, &prefix, &index_attributes, &mut matcher)?;
2138    }
2139
2140    read_attribute_patterns(
2141        worktree_root.join(".git").join("info").join("attributes"),
2142        &mut matcher,
2143        &[],
2144        b".git/info/attributes",
2145        false,
2146    );
2147    Ok(matcher.attributes_for_path(path, &requested, false))
2148}
2149
2150/// Fold the `.gitattributes` governing directory `base` (whose on-disk location
2151/// is `dir`) into `matcher`, preferring the worktree file and falling back to
2152/// the staged blob. Mirrors one attr-stack frame under `GIT_ATTR_CHECKIN`
2153/// (git's default direction, used by `checkout -- <pathspec>` / `restore`).
2154pub(crate) fn fold_checkout_attribute_frame(
2155    dir: &Path,
2156    base: &[u8],
2157    index_attributes: &BTreeMap<Vec<u8>, Vec<u8>>,
2158    matcher: &mut AttributeMatcher,
2159) -> Result<()> {
2160    let worktree_file = dir.join(".gitattributes");
2161    let source = attribute_source_for_base(base);
2162    if let Ok(contents) = fs::read(&worktree_file) {
2163        // A worktree `.gitattributes` exists at this level: it wins outright
2164        // (git only consults the index when the worktree file is absent).
2165        read_attribute_patterns_from_bytes(&contents, matcher, base, &source);
2166    } else if let Some(contents) = index_attributes.get(base) {
2167        read_attribute_patterns_from_bytes(contents, matcher, base, &source);
2168    }
2169    Ok(())
2170}
2171
2172/// Read every staged `.gitattributes` blob, keyed by the repo-relative directory
2173/// it governs (`""` for the worktree root). Stage-0 blob entries only.
2174pub(crate) fn index_gitattributes_by_base(
2175    git_dir: &Path,
2176    format: ObjectFormat,
2177) -> Result<BTreeMap<Vec<u8>, Vec<u8>>> {
2178    let mut map = BTreeMap::new();
2179    let index_path = repository_index_path(git_dir);
2180    if !index_path.exists() {
2181        return Ok(map);
2182    }
2183    let db = FileObjectDatabase::from_git_dir(git_dir, format);
2184    let entries = Index::parse(&fs::read(index_path)?, format)?.entries;
2185    for entry in entries {
2186        let is_attributes_file =
2187            entry.path == b".gitattributes" || entry.path.as_bytes().ends_with(b"/.gitattributes");
2188        if index_entry_stage(&entry) != 0
2189            || tree_entry_object_type(entry.mode) != ObjectType::Blob
2190            || !is_attributes_file
2191        {
2192            continue;
2193        }
2194        let base = match entry.path.as_bytes().strip_suffix(b".gitattributes") {
2195            Some(b"") => Vec::new(),
2196            Some(parent) => parent.strip_suffix(b"/").unwrap_or(parent).to_vec(),
2197            None => continue,
2198        };
2199        let object = db
2200            .read_object(&entry.oid)
2201            .map_err(|err| expect_missing_object_kind(err, entry.oid, MissingObjectKind::Blob))?;
2202        if object.object_type == ObjectType::Blob {
2203            map.insert(base, object.body.clone());
2204        }
2205    }
2206    Ok(map)
2207}
2208
2209pub(crate) fn filter_attribute_names() -> Vec<Vec<u8>> {
2210    // `crlf` is git's legacy alias for `text` (convert.c registers both); it is
2211    // consulted as a fallback when `text` is unspecified, so we must resolve it.
2212    vec![
2213        b"text".to_vec(),
2214        b"crlf".to_vec(),
2215        b"ident".to_vec(),
2216        b"eol".to_vec(),
2217        b"filter".to_vec(),
2218        b"working-tree-encoding".to_vec(),
2219    ]
2220}
2221
2222// ---------------------------------------------------------------------------
2223// `ls-files --eol` line-ending information
2224//
2225// Git's `git ls-files --eol` prints, for each path, three fields:
2226//   i/<stat>  — line-ending statistics of the *index* blob content
2227//   w/<stat>  — line-ending statistics of the *worktree* file content
2228//   attr/<a>  — the resolved crlf/eol attribute action (attributes only, no
2229//               config) — `get_convert_attr_ascii` in convert.c
2230// The two stat fields mirror `gather_convert_stats_ascii`; the attr field
2231// mirrors `convert_attrs` up to `ca->attr_action` (i.e. *before* the config
2232// derived `text` -> input/crlf substitution and the `core.autocrlf` fallback).
2233// ---------------------------------------------------------------------------
2234
2235/// Line-ending statistics of a byte buffer, mirroring convert.c `gather_stats`.
2236#[derive(Clone)]
2237pub(crate) struct ConvertStats {
2238    nul: u32,
2239    lonecr: u32,
2240    lonelf: u32,
2241    crlf: u32,
2242    printable: u32,
2243    nonprintable: u32,
2244}
2245
2246pub(crate) fn gather_convert_stats(buf: &[u8]) -> ConvertStats {
2247    let mut stats = ConvertStats {
2248        nul: 0,
2249        lonecr: 0,
2250        lonelf: 0,
2251        crlf: 0,
2252        printable: 0,
2253        nonprintable: 0,
2254    };
2255    let mut i = 0;
2256    while i < buf.len() {
2257        let c = buf[i];
2258        if c == b'\r' {
2259            if buf.get(i + 1) == Some(&b'\n') {
2260                stats.crlf += 1;
2261                i += 1;
2262            } else {
2263                stats.lonecr += 1;
2264            }
2265            i += 1;
2266            continue;
2267        }
2268        if c == b'\n' {
2269            stats.lonelf += 1;
2270            i += 1;
2271            continue;
2272        }
2273        if c == 127 {
2274            // DEL
2275            stats.nonprintable += 1;
2276        } else if c < 32 {
2277            match c {
2278                // BS, HT, ESC and FF are printable.
2279                0x08 | 0x09 | 0x1b | 0x0c => stats.printable += 1,
2280                0 => {
2281                    stats.nul += 1;
2282                    stats.nonprintable += 1;
2283                }
2284                _ => stats.nonprintable += 1,
2285            }
2286        } else {
2287            stats.printable += 1;
2288        }
2289        i += 1;
2290    }
2291    // A trailing EOF (^Z, 0x1a) is not counted as non-printable.
2292    if buf.last() == Some(&0x1a) {
2293        stats.nonprintable = stats.nonprintable.saturating_sub(1);
2294    }
2295    stats
2296}
2297
2298/// Mirror of convert.c `has_crlf_in_index`: whether the blob currently recorded
2299/// in the index for this path is non-binary text containing a CRLF. Used only by
2300/// the auto-crlf safecrlf decision to keep an already-CRLF index blob from being
2301/// silently collapsed. A missing/unreadable blob (or a non-blob entry) counts as
2302/// "no CRLF", matching git's `read_blob_data_from_index` returning NULL.
2303pub(crate) fn has_crlf_in_index(odb: &FileObjectDatabase, oid: &ObjectId) -> bool {
2304    let Ok(object) = odb.read_object(oid) else {
2305        return false;
2306    };
2307    if object.object_type != ObjectType::Blob {
2308        return false;
2309    }
2310    let data = &object.body;
2311    // git short-circuits on the first '\r' via memchr before gathering stats.
2312    if !data.contains(&b'\r') {
2313        return false;
2314    }
2315    let stats = gather_convert_stats(data);
2316    !convert_is_binary(&stats) && stats.crlf > 0
2317}
2318
2319/// Mirror of convert.c `convert_is_binary`: a lone CR or NUL, or a high
2320/// non-printable ratio, marks the content as binary.
2321pub(crate) fn convert_is_binary(stats: &ConvertStats) -> bool {
2322    if stats.lonecr > 0 {
2323        return true;
2324    }
2325    if stats.nul > 0 {
2326        return true;
2327    }
2328    (stats.printable >> 7) < stats.nonprintable
2329}
2330
2331/// The `core.safecrlf` round-trip-warning mode, mirroring git's
2332/// `global_conv_flags_eol` (environment.c). git's *default* — when
2333/// `core.safecrlf` is unset — is [`ConvFlags::Warn`], so the warning fires even
2334/// without any explicit config.
2335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2336pub enum ConvFlags {
2337    /// `core.safecrlf=false`: never warn.
2338    Off,
2339    /// `core.safecrlf=warn` (and the unset default): emit a warning when a
2340    /// CRLF<->LF round-trip would not be reversible.
2341    Warn,
2342    /// `core.safecrlf=true`: die instead of warn.
2343    Die,
2344}
2345
2346impl ConvFlags {
2347    /// Resolve `core.safecrlf` from config, mirroring environment.c
2348    /// `git_default_core_config`: `warn` -> [`ConvFlags::Warn`], a boolean-true
2349    /// value -> [`ConvFlags::Die`], a boolean-false value -> [`ConvFlags::Off`].
2350    /// When the key is absent git leaves `global_conv_flags_eol` at its initial
2351    /// [`ConvFlags::Warn`], so unset also resolves to [`ConvFlags::Warn`].
2352    pub fn from_config(config: &GitConfig) -> Self {
2353        match config.get("core", None, "safecrlf") {
2354            Some(value) if value.eq_ignore_ascii_case("warn") => ConvFlags::Warn,
2355            Some(_) => {
2356                if config.get_bool("core", None, "safecrlf") == Some(true) {
2357                    ConvFlags::Die
2358                } else {
2359                    ConvFlags::Off
2360                }
2361            }
2362            None => ConvFlags::Warn,
2363        }
2364    }
2365}
2366
2367/// Mirror of convert.c `check_global_conv_flags_eol`: compare the pre-conversion
2368/// `old_stats` against the simulated round-trip `new_stats` and, when the
2369/// CRLF/LF content would not survive a clean+smudge cycle, warn (or die under
2370/// `core.safecrlf=true`).
2371///
2372/// Returns `Err(GitError::Exit(128))` when `flags` is [`ConvFlags::Die`] and the
2373/// round-trip is irreversible (git `die`s with exit 128 here); otherwise prints
2374/// the warning to stderr and returns `Ok(())`. This is a pure stderr-side
2375/// effect: it never changes the bytes written to the object store.
2376pub(crate) fn check_safe_crlf(
2377    old_stats: &ConvertStats,
2378    new_stats: &ConvertStats,
2379    flags: ConvFlags,
2380    path: &[u8],
2381) -> Result<()> {
2382    if flags == ConvFlags::Off {
2383        return Ok(());
2384    }
2385    let display = String::from_utf8_lossy(path);
2386    if old_stats.crlf > 0 && new_stats.crlf == 0 {
2387        // CRLFs would not be restored by checkout.
2388        match flags {
2389            ConvFlags::Die => {
2390                eprintln!("fatal: CRLF would be replaced by LF in {display}");
2391                return Err(GitError::Exit(128));
2392            }
2393            ConvFlags::Warn => {
2394                eprintln!(
2395                    "warning: in the working copy of '{display}', CRLF will be replaced by LF the next time Git touches it"
2396                );
2397            }
2398            ConvFlags::Off => unreachable!("handled above"),
2399        }
2400    } else if old_stats.lonelf > 0 && new_stats.lonelf == 0 {
2401        // CRLFs would be added by checkout.
2402        match flags {
2403            ConvFlags::Die => {
2404                eprintln!("fatal: LF would be replaced by CRLF in {display}");
2405                return Err(GitError::Exit(128));
2406            }
2407            ConvFlags::Warn => {
2408                eprintln!(
2409                    "warning: in the working copy of '{display}', LF will be replaced by CRLF the next time Git touches it"
2410                );
2411            }
2412            ConvFlags::Off => unreachable!("handled above"),
2413        }
2414    }
2415    Ok(())
2416}
2417
2418/// Compute the `i/` or `w/` stat string for `content`, mirroring
2419/// convert.c `gather_convert_stats_ascii`.
2420pub(crate) fn convert_stats_ascii(content: &[u8]) -> &'static str {
2421    if content.is_empty() {
2422        return "none";
2423    }
2424    let stats = gather_convert_stats(content);
2425    if convert_is_binary(&stats) {
2426        return "-text";
2427    }
2428    match (stats.lonelf > 0, stats.crlf > 0) {
2429        (true, false) => "lf",
2430        (false, true) => "crlf",
2431        (true, true) => "mixed",
2432        (false, false) => "none",
2433    }
2434}
2435
2436/// The resolved crlf/eol attribute action for a path, mirroring convert.c
2437/// `convert_attrs` up to `ca->attr_action` (attributes only, no config), and
2438/// `get_convert_attr_ascii` for the ascii spelling.
2439pub(crate) fn convert_attr_ascii(checks: &[AttributeCheck]) -> &'static str {
2440    fn state_of<'a>(checks: &'a [AttributeCheck], name: &[u8]) -> Option<&'a AttributeState> {
2441        checks
2442            .iter()
2443            .find(|check| check.attribute == name)
2444            .and_then(|check| check.state.as_ref())
2445    }
2446
2447    // git_path_check_crlf: ATTR_TRUE -> TEXT, ATTR_FALSE -> BINARY,
2448    // ATTR_UNSET -> (fall through), "input" -> TEXT_INPUT, "auto" -> AUTO,
2449    // anything else -> UNDEFINED.
2450    #[derive(Clone, Copy, PartialEq)]
2451    enum Action {
2452        Undefined,
2453        Binary,
2454        Text,
2455        TextInput,
2456        TextCrlf,
2457        Auto,
2458        AutoCrlf,
2459        AutoInput,
2460    }
2461    fn check_crlf(state: Option<&AttributeState>) -> Action {
2462        match state {
2463            Some(AttributeState::Set) => Action::Text,
2464            Some(AttributeState::Unset) => Action::Binary,
2465            Some(AttributeState::Value(value)) if value == b"input" => Action::TextInput,
2466            Some(AttributeState::Value(value)) if value == b"auto" => Action::Auto,
2467            // ATTR_UNSET / any other value -> CRLF_UNDEFINED.
2468            _ => Action::Undefined,
2469        }
2470    }
2471
2472    // Resolve from the `text` attribute, then fall back to the legacy `crlf`
2473    // alias only when `text` left the action undefined.
2474    let mut action = check_crlf(state_of(checks, b"text"));
2475    if action == Action::Undefined {
2476        action = check_crlf(state_of(checks, b"crlf"));
2477    }
2478
2479    if action != Action::Binary {
2480        // git_path_check_eol: only "lf"/"crlf" values matter.
2481        let eol = match state_of(checks, b"eol") {
2482            Some(AttributeState::Value(value)) if value == b"lf" => Some(false),
2483            Some(AttributeState::Value(value)) if value == b"crlf" => Some(true),
2484            _ => None,
2485        };
2486        action = match (action, eol) {
2487            (Action::Auto, Some(false)) => Action::AutoInput,
2488            (Action::Auto, Some(true)) => Action::AutoCrlf,
2489            (_, Some(false)) if action != Action::Auto => Action::TextInput,
2490            (_, Some(true)) if action != Action::Auto => Action::TextCrlf,
2491            _ => action,
2492        };
2493    }
2494
2495    match action {
2496        Action::Undefined => "",
2497        Action::Binary => "-text",
2498        Action::Text => "text",
2499        Action::TextInput => "text eol=lf",
2500        Action::TextCrlf => "text eol=crlf",
2501        Action::Auto => "text=auto",
2502        Action::AutoCrlf => "text=auto eol=crlf",
2503        Action::AutoInput => "text=auto eol=lf",
2504    }
2505}
2506
2507/// The three `ls-files --eol` fields for a single path.
2508pub struct EolInfo {
2509    /// Stat of the index blob (`i/...`); empty when there is no index blob.
2510    pub index: &'static str,
2511    /// Stat of the worktree file (`w/...`); empty when the file is absent.
2512    pub worktree: &'static str,
2513    /// Resolved crlf/eol attribute action (`attr/...`).
2514    pub attr: &'static str,
2515}
2516
2517impl EolInfo {
2518    /// Format as git's `ls-files --eol` prefix: `i/%-5s w/%-5s attr/%-17s\t`.
2519    pub fn format_prefix(&self) -> String {
2520        format!(
2521            "i/{:<5} w/{:<5} attr/{:<17}\t",
2522            self.index, self.worktree, self.attr
2523        )
2524    }
2525}
2526
2527/// Compute the `ls-files --eol` info for `path`.
2528///
2529/// `index_content` is the raw index blob bytes (None when the path has no
2530/// index entry or is not a regular file). The worktree file is read from
2531/// `worktree_root/path`; if it is absent or not a regular file the `w/` field
2532/// is empty. Attributes are resolved from the worktree `.gitattributes` chain
2533/// via `attr_checks`.
2534pub fn eol_info_for_path(
2535    worktree_root: impl AsRef<Path>,
2536    path: &[u8],
2537    index_content: Option<&[u8]>,
2538    attr_checks: &[AttributeCheck],
2539) -> EolInfo {
2540    let index = index_content.map(convert_stats_ascii).unwrap_or("");
2541
2542    let worktree_root = worktree_root.as_ref();
2543    let worktree = match repo_path_to_os_path(path) {
2544        Ok(rel) => {
2545            let absolute = worktree_root.join(rel);
2546            match fs::symlink_metadata(&absolute) {
2547                // git: only regular files get a `w/` stat (lstat + S_ISREG).
2548                Ok(meta) if meta.file_type().is_file() => match fs::read(&absolute) {
2549                    Ok(content) => convert_stats_ascii_owned(&content),
2550                    Err(_) => "",
2551                },
2552                _ => "",
2553            }
2554        }
2555        Err(_) => "",
2556    };
2557
2558    let attr = convert_attr_ascii(attr_checks);
2559
2560    EolInfo {
2561        index,
2562        worktree,
2563        attr,
2564    }
2565}
2566
2567/// `convert_stats_ascii` over an owned buffer; the result is a `'static` str so
2568/// the buffer can be dropped.
2569pub(crate) fn convert_stats_ascii_owned(content: &[u8]) -> &'static str {
2570    convert_stats_ascii(content)
2571}
2572
2573/// Resolve the crlf/eol/text/filter attributes for `path` from the worktree
2574/// `.gitattributes` chain (the set `ls-files --eol` needs for its `attr/`
2575/// field).
2576pub fn eol_attribute_checks(
2577    worktree_root: impl AsRef<Path>,
2578    path: &[u8],
2579) -> Result<Vec<AttributeCheck>> {
2580    filter_attribute_checks(worktree_root.as_ref(), path)
2581}