Skip to main content

grit_lib/
crlf.rs

1//! CRLF / EOL conversion and clean/smudge filter support.
2//!
3//! This module handles line-ending conversion when staging files (`git add`)
4//! and checking out files (`git checkout`, `read-tree -u`, `checkout-index`).
5//!
6//! Config knobs:
7//!   - `core.autocrlf` (true / input / false)
8//!   - `core.eol` (lf / crlf / native)
9//!   - `core.safecrlf` (true / warn / false)
10//!
11//! Gitattributes:
12//!   - `text` / `text=auto` / `-text` / `binary`
13//!   - `eol=lf` / `eol=crlf`
14//!   - `filter=<name>` (with `filter.<name>.clean` / `filter.<name>.smudge`)
15//!   - `ident` keyword expansion
16
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21use crate::attributes::{
22    attr_rule_matches as git_attr_rule_matches, collect_attrs_for_path,
23    parse_gitattributes_file_content, parse_gitattributes_file_content_with_base, AttrValue,
24    MacroTable, ParsedGitAttributes, MAX_ATTR_FILE_BYTES,
25};
26use crate::command_runner::{system_command_runner, CommandRunner, CommandSpec, CommandStdin};
27use crate::config::ConfigSet;
28use crate::error::{FilterError, FilterPhase};
29use crate::filter_process::{
30    apply_process_clean, apply_process_smudge, FilterProcessState, FilterSmudgeMeta,
31};
32use encoding_rs::UTF_8;
33
34/// Working-tree encoding conversion failure (Git `reencode_string_len` returning NULL).
35#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
36pub enum WorkTreeEncodingError {
37    /// The encoding label is not supported by `encoding_rs` / the commit encoding helpers.
38    #[error("unsupported encoding '{label}'")]
39    UnsupportedEncoding {
40        /// Config or attribute encoding name.
41        label: String,
42    },
43}
44
45/// Error from internal encoding helpers (not public convert_to_git surface).
46#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
47pub enum ConversionError {
48    /// Working-tree encoding could not be resolved or converted.
49    #[error(transparent)]
50    Encoding(WorkTreeEncodingError),
51    /// Filter, safecrlf, BOM validation, or other conversion failure (Git-compatible message).
52    #[error("{0}")]
53    Message(String),
54}
55
56impl From<WorkTreeEncodingError> for ConversionError {
57    fn from(value: WorkTreeEncodingError) -> Self {
58        Self::Encoding(value)
59    }
60}
61
62impl From<String> for ConversionError {
63    fn from(value: String) -> Self {
64        Self::Message(value)
65    }
66}
67
68impl From<&str> for ConversionError {
69    fn from(value: &str) -> Self {
70        Self::Message(value.to_owned())
71    }
72}
73
74fn filter_detail(rel_path: &str, detail: String) -> FilterError {
75    FilterError::NotFilteredProperly {
76        path: rel_path.to_owned(),
77        detail,
78    }
79}
80
81fn external_filter_failed(rel_path: &str, driver: &str, phase: FilterPhase) -> FilterError {
82    FilterError::ExternalFilterFailed {
83        driver: driver.to_owned(),
84        path: rel_path.to_owned(),
85        phase,
86    }
87}
88
89/// What `core.autocrlf` is set to.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum AutoCrlf {
92    /// `True` variant.
93    True,
94    /// `Input` variant.
95    Input,
96    /// `False` variant.
97    False,
98}
99
100/// What `core.eol` is set to.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum CoreEol {
103    /// `Lf` variant.
104    Lf,
105    /// `Crlf` variant.
106    Crlf,
107    /// `Native` variant.
108    Native,
109}
110
111/// What `core.safecrlf` is set to.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum SafeCrlf {
114    /// `True` variant.
115    True,
116    /// `Warn` variant.
117    Warn,
118    /// `False` variant.
119    False,
120}
121
122/// Per-file text attribute from .gitattributes.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum TextAttr {
125    /// `text` — always treat as text.
126    Set,
127    /// `text=auto` — auto-detect.
128    Auto,
129    /// `-text` or `binary` — never convert.
130    Unset,
131    /// No text attribute specified.
132    Unspecified,
133}
134
135/// Per-file eol attribute from .gitattributes.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum EolAttr {
138    /// `Lf` variant.
139    Lf,
140    /// `Crlf` variant.
141    Crlf,
142    /// `Unspecified` variant.
143    Unspecified,
144}
145
146/// Legacy `crlf` gitattribute (deprecated in Git; still honored for EOL conversion).
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
148pub enum CrlfLegacyAttr {
149    #[default]
150    /// `Unspecified` variant.
151    Unspecified,
152    /// `-crlf` — disable CRLF conversion.
153    Unset,
154    /// `crlf=input` — normalize to LF in the object database; no CRLF on checkout.
155    Input,
156    /// Bare `crlf` (set) — force CRLF on checkout for text files.
157    Crlf,
158}
159
160/// Per-file merge attribute from .gitattributes.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum MergeAttr {
163    /// No merge attribute specified.
164    Unspecified,
165    /// `-merge` — treat as binary/non-text merge.
166    Unset,
167    /// `merge=<driver>` — use named merge driver.
168    Driver(String),
169}
170
171/// How the `diff` gitattribute affects diff output.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub enum DiffAttr {
174    /// No `diff` attribute (use heuristics / default).
175    Unspecified,
176    /// `-diff` / `diff=unset` / `binary` — treat as binary for diff purposes.
177    Unset,
178    /// Bare `diff` (set) — force textual diff even when the blob contains NUL.
179    Set,
180    /// `diff=<driver>` — use named driver (e.g. for textconv).
181    Driver(String),
182}
183
184/// Per-file attributes relevant to conversion.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct FileAttrs {
187    /// `text` field.
188    pub text: TextAttr,
189    /// `eol` field.
190    pub eol: EolAttr,
191    /// Effect of the `diff` gitattribute on diff output.
192    pub diff_attr: DiffAttr,
193    /// `filter_clean` field.
194    pub filter_clean: Option<String>,
195    /// `filter_smudge` field.
196    pub filter_smudge: Option<String>,
197    /// `filter.<name>.process` — long-running filter (takes precedence over clean/smudge commands).
198    pub filter_process: Option<String>,
199    /// Driver name from the active `filter=<name>` gitattribute (for error messages).
200    pub filter_driver_name: Option<String>,
201    /// Whether `filter.<name>.required` is set for this path's filter driver.
202    pub filter_smudge_required: bool,
203    /// Same config key as smudge; clean direction fails when unset if true.
204    pub filter_clean_required: bool,
205    /// `ident` field.
206    pub ident: bool,
207    /// `merge` field.
208    pub merge: MergeAttr,
209    /// `conflict_marker_size` field.
210    pub conflict_marker_size: Option<String>,
211    /// Working tree encoding (e.g. "utf-16") — content is converted to UTF-8 on add.
212    pub working_tree_encoding: Option<String>,
213    /// Legacy `crlf` / `-crlf` / `crlf=input` from `.gitattributes`.
214    pub crlf_legacy: CrlfLegacyAttr,
215    /// `whitespace` attribute value: `None` if unset, `Some("set")` for bare `whitespace`,
216    /// `Some("unset")` for `-whitespace`, or `Some("trailing,...")` for `whitespace=...`.
217    pub whitespace: Option<String>,
218}
219
220impl Default for FileAttrs {
221    fn default() -> Self {
222        FileAttrs {
223            text: TextAttr::Unspecified,
224            eol: EolAttr::Unspecified,
225            diff_attr: DiffAttr::Unspecified,
226            filter_clean: None,
227            filter_smudge: None,
228            filter_process: None,
229            filter_driver_name: None,
230            filter_smudge_required: false,
231            filter_clean_required: false,
232            ident: false,
233            merge: MergeAttr::Unspecified,
234            conflict_marker_size: None,
235            working_tree_encoding: None,
236            crlf_legacy: CrlfLegacyAttr::Unspecified,
237            whitespace: None,
238        }
239    }
240}
241
242impl FileAttrs {
243    /// Width of conflict marker lines for this path (`conflict-marker-size` attribute).
244    ///
245    /// Matches Git: unset or invalid values default to 7; explicit `0` also maps to 7.
246    #[must_use]
247    pub(crate) fn effective_conflict_marker_size(&self) -> usize {
248        match self
249            .conflict_marker_size
250            .as_ref()
251            .and_then(|value| value.parse::<usize>().ok())
252        {
253            None | Some(0) => 7,
254            Some(n) => n,
255        }
256    }
257}
258
259/// Global conversion settings derived from config.
260#[derive(Clone)]
261pub struct ConversionConfig {
262    /// `autocrlf` field.
263    pub autocrlf: AutoCrlf,
264    /// `eol` field.
265    pub eol: CoreEol,
266    /// `safecrlf` field.
267    pub safecrlf: SafeCrlf,
268    /// Subprocess runner for clean/smudge filters and iconv helpers.
269    pub command_runner: Arc<dyn CommandRunner>,
270}
271
272impl ConversionConfig {
273    /// Load conversion settings from a ConfigSet.
274    pub fn from_config(config: &ConfigSet) -> Self {
275        let autocrlf = match config.get("core.autocrlf") {
276            Some(v) => match v.to_lowercase().as_str() {
277                "true" | "yes" | "on" | "1" => AutoCrlf::True,
278                "input" => AutoCrlf::Input,
279                _ => AutoCrlf::False,
280            },
281            None => AutoCrlf::False,
282        };
283
284        let eol = match config.get("core.eol") {
285            Some(v) => match v.to_lowercase().as_str() {
286                "crlf" => CoreEol::Crlf,
287                "lf" => CoreEol::Lf,
288                "native" => CoreEol::Native,
289                _ => CoreEol::Native,
290            },
291            None => CoreEol::Native,
292        };
293
294        let safecrlf = match config.get("core.safecrlf") {
295            Some(v) => match v.to_lowercase().as_str() {
296                "true" | "yes" | "on" | "1" => SafeCrlf::True,
297                "warn" => SafeCrlf::Warn,
298                _ => SafeCrlf::False,
299            },
300            // Git warns on round-trip EOL issues by default when unset.
301            None => SafeCrlf::Warn,
302        };
303
304        ConversionConfig {
305            autocrlf,
306            eol,
307            safecrlf,
308            command_runner: system_command_runner(),
309        }
310    }
311
312    /// Like [`Self::from_config`] with an explicit filter/hook subprocess runner.
313    #[must_use]
314    pub fn from_config_with_runner(
315        config: &ConfigSet,
316        command_runner: Arc<dyn CommandRunner>,
317    ) -> Self {
318        let mut cfg = Self::from_config(config);
319        cfg.command_runner = command_runner;
320        cfg
321    }
322}
323
324impl std::fmt::Debug for ConversionConfig {
325    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326        f.debug_struct("ConversionConfig")
327            .field("autocrlf", &self.autocrlf)
328            .field("eol", &self.eol)
329            .field("safecrlf", &self.safecrlf)
330            .field("command_runner", &"<CommandRunner>")
331            .finish()
332    }
333}
334
335pub use crate::attributes::AttrRule;
336
337fn merge_parsed_into(
338    merged: &mut ParsedGitAttributes,
339    mut parsed: ParsedGitAttributes,
340    take_macros: bool,
341) {
342    merged.rules.append(&mut parsed.rules);
343    if take_macros {
344        merged.macros.defs.extend(parsed.macros.defs.drain());
345    }
346    merged.warnings.append(&mut parsed.warnings);
347}
348
349fn merge_gitattributes_text(
350    merged: &mut ParsedGitAttributes,
351    content: &str,
352    display: &str,
353    attr_base: &str,
354) {
355    if content.len() > MAX_ATTR_FILE_BYTES {
356        return;
357    }
358    let parsed = if attr_base.is_empty() {
359        parse_gitattributes_file_content(content, display)
360    } else {
361        parse_gitattributes_file_content_with_base(content, display, attr_base)
362    };
363    merge_parsed_into(merged, parsed, attr_base.is_empty());
364}
365
366/// Load root and `info/attributes` gitattributes from the worktree.
367#[must_use]
368pub(crate) fn load_gitattributes_parsed(work_tree: &Path) -> ParsedGitAttributes {
369    let mut merged = ParsedGitAttributes::default();
370    let root_attrs = work_tree.join(".gitattributes");
371    if let Ok(content) = std::fs::read_to_string(&root_attrs) {
372        merge_gitattributes_text(&mut merged, &content, ".gitattributes", "");
373    }
374    let info_attrs = work_tree.join(".git/info/attributes");
375    if let Ok(content) = std::fs::read_to_string(&info_attrs) {
376        merge_gitattributes_text(&mut merged, &content, "info/attributes", "");
377    }
378    merged
379}
380
381/// Load .gitattributes from the worktree root.
382#[must_use]
383pub fn load_gitattributes(work_tree: &Path) -> Vec<AttrRule> {
384    load_gitattributes_parsed(work_tree).rules
385}
386
387/// Parse gitattributes content into attribute rules (repository root scope).
388///
389/// This is useful when attributes are sourced from non-worktree inputs
390/// (for example, tree objects selected by `--attr-source`).
391#[must_use]
392pub fn parse_gitattributes_content(content: &str) -> Vec<AttrRule> {
393    parse_gitattributes_file_content(content, ".gitattributes").rules
394}
395
396/// Load `.gitattributes` from the index (stage 0 paths only).
397#[must_use]
398pub fn load_gitattributes_from_index(
399    index: &crate::index::Index,
400    odb: &crate::odb::Odb,
401) -> Vec<AttrRule> {
402    crate::attributes::load_gitattributes_from_index(index, odb, Path::new("."))
403        .map(|p| p.rules)
404        .unwrap_or_default()
405}
406
407/// Load merged `.gitattributes` for `rel_path` (root, nested dirs, info; index fallback).
408#[must_use]
409pub(crate) fn load_gitattributes_for_checkout_parsed(
410    work_tree: &Path,
411    rel_path: &str,
412    index: &crate::index::Index,
413    odb: &crate::odb::Odb,
414) -> ParsedGitAttributes {
415    let mut merged = load_gitattributes_parsed(work_tree);
416
417    if !work_tree.join(".gitattributes").exists() {
418        if let Some(entry) = index.get(b".gitattributes", 0) {
419            if let Ok(obj) = odb.read(&entry.oid) {
420                if let Ok(content) = String::from_utf8(obj.data) {
421                    merge_gitattributes_text(&mut merged, &content, ".gitattributes", "");
422                }
423            }
424        }
425    }
426
427    let path = Path::new(rel_path);
428    if let Some(parent) = path.parent() {
429        let mut accum = PathBuf::new();
430        for comp in parent.components() {
431            accum.push(comp);
432            let ga_rel = accum.join(".gitattributes");
433            let ga_stored = ga_rel.to_string_lossy().replace('\\', "/");
434            let attr_base = Path::new(&ga_stored)
435                .parent()
436                .map(|p| p.to_string_lossy().replace('\\', "/"))
437                .unwrap_or_default();
438            let wt_ga = work_tree.join(&ga_rel);
439            if let Ok(content) = std::fs::read_to_string(&wt_ga) {
440                merge_gitattributes_text(&mut merged, &content, &ga_stored, &attr_base);
441            } else {
442                let key = crate::repo_relpath::storage_bytes(&ga_stored).into_owned();
443                if let Some(entry) = index.get(&key, 0) {
444                    if let Ok(obj) = odb.read(&entry.oid) {
445                        if let Ok(content) = String::from_utf8(obj.data) {
446                            merge_gitattributes_text(&mut merged, &content, &ga_stored, &attr_base);
447                        }
448                    }
449                }
450            }
451        }
452    }
453
454    merged
455}
456
457/// Load `.gitattributes` rules that apply to `rel_path`, including root and
458/// nested `dir/.gitattributes` along parent directories (Git-consistent order:
459/// root first, then each ancestor directory; later rules win in `get_file_attrs`).
460///
461/// Reads from the working tree when present, otherwise from a stage-0 index entry.
462#[must_use]
463pub fn load_gitattributes_for_checkout(
464    work_tree: &Path,
465    rel_path: &str,
466    index: &crate::index::Index,
467    odb: &crate::odb::Odb,
468) -> Vec<AttrRule> {
469    load_gitattributes_for_checkout_parsed(work_tree, rel_path, index, odb).rules
470}
471
472fn config_bool_truthy(value: &str) -> bool {
473    matches!(
474        value.trim().to_ascii_lowercase().as_str(),
475        "true" | "yes" | "on" | "1"
476    )
477}
478
479fn attr_path_for_match(rel_path: &str, is_dir: bool) -> String {
480    if is_dir && !rel_path.is_empty() && !rel_path.ends_with('/') {
481        format!("{rel_path}/")
482    } else {
483        rel_path.to_owned()
484    }
485}
486
487/// Map merged gitattributes (Git `collect_attrs`) to CRLF/filter [`FileAttrs`].
488#[must_use]
489pub(crate) fn file_attrs_from_attr_map(
490    map: &HashMap<String, AttrValue>,
491    config: &ConfigSet,
492) -> FileAttrs {
493    let mut fa = FileAttrs::default();
494    for (name, value) in map {
495        match name.as_str() {
496            "text" => {
497                fa.text = match value {
498                    AttrValue::Set => TextAttr::Set,
499                    AttrValue::Unset => TextAttr::Unset,
500                    AttrValue::Value(v) if v == "auto" => TextAttr::Auto,
501                    AttrValue::Clear | AttrValue::Value(_) => TextAttr::Unspecified,
502                };
503            }
504            "eol" => {
505                fa.eol = match value {
506                    AttrValue::Value(v) if v == "lf" => EolAttr::Lf,
507                    AttrValue::Value(v) if v == "crlf" => EolAttr::Crlf,
508                    _ => EolAttr::Unspecified,
509                };
510            }
511            "filter" => {
512                let driver = match value {
513                    AttrValue::Unset => {
514                        fa.filter_clean = None;
515                        fa.filter_smudge = None;
516                        fa.filter_process = None;
517                        fa.filter_driver_name = None;
518                        fa.filter_smudge_required = false;
519                        fa.filter_clean_required = false;
520                        continue;
521                    }
522                    AttrValue::Set => continue,
523                    AttrValue::Clear => continue,
524                    AttrValue::Value(d) => d.as_str(),
525                };
526                let clean_key = format!("filter.{driver}.clean");
527                let smudge_key = format!("filter.{driver}.smudge");
528                let process_key = format!("filter.{driver}.process");
529                let req_key = format!("filter.{driver}.required");
530                fa.filter_driver_name = Some(driver.to_owned());
531                fa.filter_process = config.get(&process_key).filter(|s| !s.is_empty());
532                if fa.filter_process.is_some() {
533                    fa.filter_clean = None;
534                    fa.filter_smudge = None;
535                } else {
536                    fa.filter_clean = config.get(&clean_key);
537                    fa.filter_smudge = config.get(&smudge_key);
538                }
539                let required = config
540                    .get(&req_key)
541                    .is_some_and(|v| config_bool_truthy(v.as_str()));
542                fa.filter_smudge_required = required;
543                fa.filter_clean_required = required;
544            }
545            "diff" => {
546                fa.diff_attr = match value {
547                    AttrValue::Unset => DiffAttr::Unset,
548                    AttrValue::Set => DiffAttr::Set,
549                    AttrValue::Value(v) if !v.is_empty() => DiffAttr::Driver(v.clone()),
550                    AttrValue::Clear => DiffAttr::Unspecified,
551                    _ => DiffAttr::Unspecified,
552                };
553            }
554            "ident" => {
555                fa.ident = matches!(value, AttrValue::Set);
556            }
557            "merge" => {
558                fa.merge = match value {
559                    AttrValue::Unset => MergeAttr::Unset,
560                    AttrValue::Set => MergeAttr::Unspecified,
561                    AttrValue::Value(v) => MergeAttr::Driver(v.clone()),
562                    AttrValue::Clear => MergeAttr::Unspecified,
563                };
564            }
565            "conflict-marker-size" => match value {
566                AttrValue::Unset | AttrValue::Clear => fa.conflict_marker_size = None,
567                AttrValue::Value(v) => fa.conflict_marker_size = Some(v.clone()),
568                AttrValue::Set => {}
569            },
570            "working-tree-encoding" => {
571                if let AttrValue::Value(v) = value {
572                    if !v.is_empty() {
573                        fa.working_tree_encoding = Some(v.clone());
574                    }
575                }
576            }
577            "crlf" => {
578                fa.crlf_legacy = match value {
579                    AttrValue::Unset => CrlfLegacyAttr::Unset,
580                    AttrValue::Value(v) if v == "input" => CrlfLegacyAttr::Input,
581                    AttrValue::Set => CrlfLegacyAttr::Crlf,
582                    AttrValue::Value(v) if v == "true" || v == "set" => CrlfLegacyAttr::Crlf,
583                    _ => CrlfLegacyAttr::Unspecified,
584                };
585            }
586            "whitespace" => match value {
587                AttrValue::Unset => fa.whitespace = Some("unset".to_owned()),
588                AttrValue::Set => fa.whitespace = Some("set".to_owned()),
589                AttrValue::Value(v) if !v.is_empty() => fa.whitespace = Some(v.clone()),
590                _ => {}
591            },
592            _ => {}
593        }
594    }
595    fa
596}
597
598/// Resolve [`FileAttrs`] using Git-compatible attribute matching and macro expansion.
599#[must_use]
600pub(crate) fn file_attrs_from_gitattributes(
601    rules: &[AttrRule],
602    macros: &MacroTable,
603    rel_path: &str,
604    is_dir: bool,
605    config: &ConfigSet,
606) -> FileAttrs {
607    let path = attr_path_for_match(rel_path, is_dir);
608    let map = collect_attrs_for_path(rules, macros, &path, config.ignore_case());
609    file_attrs_from_attr_map(&map, config)
610}
611
612/// Get file attributes for a given path from .gitattributes rules and config.
613///
614/// `is_dir` should be true when `rel_path` names a directory (Git passes a trailing `/` for
615/// directory paths in some call sites; we accept either trailing `/` or this flag from tree walks).
616#[must_use]
617pub(crate) fn get_file_attrs(
618    rules: &[AttrRule],
619    rel_path: &str,
620    is_dir: bool,
621    config: &ConfigSet,
622) -> FileAttrs {
623    file_attrs_from_gitattributes(rules, &MacroTable::default(), rel_path, is_dir, config)
624}
625
626/// Every attribute assigned to `path` after macro expansion, with `icase` selecting
627/// case-insensitive pattern matching (`core.ignorecase`).
628pub(crate) fn path_gitattribute_values(
629    rules: &[AttrRule],
630    macros: &MacroTable,
631    icase: bool,
632    path: &str,
633    is_dir: bool,
634) -> std::collections::HashMap<String, crate::attributes::AttrValue> {
635    collect_attrs_for_path(rules, macros, &attr_path_for_match(path, is_dir), icase)
636}
637
638/// Whether `rule` matches `rel_path` given directory vs file context (Git `path_matches`).
639#[must_use]
640#[allow(dead_code)] // integration / re-export use only
641pub(crate) fn attr_rule_matches(rule: &AttrRule, rel_path: &str, is_dir: bool) -> bool {
642    let match_path = attr_path_for_match(rel_path, is_dir);
643    git_attr_rule_matches(rule, &match_path, false)
644}
645
646/// Returns true if the data looks binary (contains NUL bytes in the first 8000 bytes).
647pub fn is_binary(data: &[u8]) -> bool {
648    let check_len = data.len().min(8000);
649    data[..check_len].contains(&0)
650}
651
652// Git `convert.c` `CONVERT_STAT_BITS_*` / `gather_convert_stats_ascii` (for `ls-files --eol`).
653const CONVERT_STAT_BITS_TXT_LF: u32 = 0x1;
654const CONVERT_STAT_BITS_TXT_CRLF: u32 = 0x2;
655const CONVERT_STAT_BITS_BIN: u32 = 0x4;
656
657#[derive(Default, Clone)]
658struct TextStat {
659    nul: u32,
660    lonecr: u32,
661    lonelf: u32,
662    crlf: u32,
663    printable: u32,
664    nonprintable: u32,
665}
666
667fn gather_text_stat(data: &[u8]) -> TextStat {
668    let mut s = TextStat::default();
669    let mut i = 0usize;
670    while i < data.len() {
671        let c = data[i];
672        if c == b'\r' {
673            if i + 1 < data.len() && data[i + 1] == b'\n' {
674                s.crlf += 1;
675                i += 2;
676            } else {
677                s.lonecr += 1;
678                i += 1;
679            }
680            continue;
681        }
682        if c == b'\n' {
683            s.lonelf += 1;
684            i += 1;
685            continue;
686        }
687        if c == 127 {
688            s.nonprintable += 1;
689        } else if c < 32 {
690            match c {
691                b'\t' | b'\x08' | b'\x1b' | b'\x0c' => s.printable += 1,
692                0 => {
693                    s.nul += 1;
694                    s.nonprintable += 1;
695                }
696                _ => s.nonprintable += 1,
697            }
698        } else {
699            s.printable += 1;
700        }
701        i += 1;
702    }
703    s
704}
705
706fn convert_is_binary(stats: &TextStat) -> bool {
707    stats.lonecr > 0 || stats.nul > 0 || (stats.printable >> 7) < stats.nonprintable
708}
709
710fn git_text_stat(data: &[u8]) -> TextStat {
711    let mut stats = gather_text_stat(data);
712    if !data.is_empty() && data[data.len() - 1] == 0x1a {
713        stats.nonprintable = stats.nonprintable.saturating_sub(1);
714    }
715    stats
716}
717
718/// Git `will_convert_lf_to_crlf` using [`TextStat`] (same rules as [`should_convert_to_crlf`] on bytes).
719fn will_convert_lf_to_crlf_from_stats(
720    stats: &TextStat,
721    conv: &ConversionConfig,
722    attrs: &FileAttrs,
723) -> bool {
724    let has_lone_lf = stats.lonelf > 0;
725    let is_bin = convert_is_binary(stats);
726
727    match attrs.crlf_legacy {
728        CrlfLegacyAttr::Unset | CrlfLegacyAttr::Input => return false,
729        CrlfLegacyAttr::Crlf => {
730            if attrs.text == TextAttr::Unset {
731                return false;
732            }
733            return has_lone_lf;
734        }
735        CrlfLegacyAttr::Unspecified => {}
736    }
737
738    if attrs.text == TextAttr::Unset {
739        return false;
740    }
741
742    if attrs.eol != EolAttr::Unspecified {
743        if attrs.text == TextAttr::Auto && is_bin {
744            return false;
745        }
746        if attrs.eol != EolAttr::Crlf {
747            return false;
748        }
749        if attrs.text == TextAttr::Auto {
750            return auto_crlf_should_smudge_lf_to_crlf_from_stats(stats);
751        }
752        return has_lone_lf;
753    }
754
755    if attrs.text == TextAttr::Set {
756        if !output_eol_is_crlf(conv) {
757            return false;
758        }
759        return has_lone_lf;
760    }
761
762    if attrs.text == TextAttr::Auto {
763        if is_bin || !output_eol_is_crlf(conv) {
764            return false;
765        }
766        return auto_crlf_should_smudge_lf_to_crlf_from_stats(stats);
767    }
768
769    match conv.autocrlf {
770        AutoCrlf::True => {
771            if is_bin {
772                return false;
773            }
774            auto_crlf_should_smudge_lf_to_crlf_from_stats(stats)
775        }
776        AutoCrlf::Input | AutoCrlf::False => false,
777    }
778}
779
780fn auto_crlf_should_smudge_lf_to_crlf_from_stats(stats: &TextStat) -> bool {
781    if stats.lonelf == 0 {
782        return false;
783    }
784    if stats.lonecr > 0 || stats.crlf > 0 {
785        return false;
786    }
787    !convert_is_binary(stats)
788}
789
790fn gather_convert_stats(data: &[u8]) -> u32 {
791    if data.is_empty() {
792        return 0;
793    }
794    let mut stats = gather_text_stat(data);
795    if !data.is_empty() && data[data.len() - 1] == 0x1a {
796        stats.nonprintable = stats.nonprintable.saturating_sub(1);
797    }
798    let mut ret = 0u32;
799    if convert_is_binary(&stats) {
800        ret |= CONVERT_STAT_BITS_BIN;
801    }
802    if stats.crlf > 0 {
803        ret |= CONVERT_STAT_BITS_TXT_CRLF;
804    }
805    if stats.lonelf > 0 {
806        ret |= CONVERT_STAT_BITS_TXT_LF;
807    }
808    ret
809}
810
811/// Git `convert.c` `gather_convert_stats_ascii` — worktree/index blob EOL stats for `ls-files --eol`.
812#[must_use]
813pub fn gather_convert_stats_ascii(data: &[u8]) -> &'static str {
814    let convert_stats = gather_convert_stats(data);
815    if convert_stats & CONVERT_STAT_BITS_BIN != 0 {
816        return "-text";
817    }
818    match convert_stats {
819        CONVERT_STAT_BITS_TXT_LF => "lf",
820        CONVERT_STAT_BITS_TXT_CRLF => "crlf",
821        x if x == (CONVERT_STAT_BITS_TXT_LF | CONVERT_STAT_BITS_TXT_CRLF) => "mixed",
822        _ => "none",
823    }
824}
825
826/// Returns true if data contains any CRLF sequences.
827pub fn has_crlf(data: &[u8]) -> bool {
828    data.windows(2).any(|w| w == b"\r\n")
829}
830
831/// Returns true if data contains any lone LF (not preceded by CR).
832pub fn has_lone_lf(data: &[u8]) -> bool {
833    for i in 0..data.len() {
834        if data[i] == b'\n' && (i == 0 || data[i - 1] != b'\r') {
835            return true;
836        }
837    }
838    false
839}
840
841/// Returns true if data contains a bare CR not followed by LF (Git `text_stat.lonecr`).
842fn has_lone_cr(data: &[u8]) -> bool {
843    for i in 0..data.len() {
844        if data[i] == b'\r' && (i + 1 >= data.len() || data[i + 1] != b'\n') {
845            return true;
846        }
847    }
848    false
849}
850
851/// Git `convert.c` `will_convert_lf_to_crlf` for `CRLF_AUTO` / `CRLF_AUTO_INPUT` / `CRLF_AUTO_CRLF`:
852/// if the blob already has CRLF pairs or lone CRs, do not convert lone LFs to CRLF on checkout.
853fn auto_crlf_should_smudge_lf_to_crlf(data: &[u8]) -> bool {
854    if !has_lone_lf(data) {
855        return false;
856    }
857    if has_lone_cr(data) || has_crlf(data) {
858        return false;
859    }
860    if is_binary(data) {
861        return false;
862    }
863    true
864}
865
866/// Returns true if ALL line endings are CRLF (no lone LF).
867pub fn is_all_crlf(data: &[u8]) -> bool {
868    has_crlf(data) && !has_lone_lf(data)
869}
870
871/// Returns true if ALL line endings are LF (no CRLF).
872pub fn is_all_lf(data: &[u8]) -> bool {
873    has_lone_lf(data) && !has_crlf(data)
874}
875
876/// Git `convert.c` `has_crlf_in_index`: index blob already contains CRLF pairs (non-binary).
877#[must_use]
878pub fn has_crlf_in_index_blob(data: &[u8]) -> bool {
879    if !data.contains(&b'\r') {
880        return false;
881    }
882    let st = gather_convert_stats(data);
883    st & CONVERT_STAT_BITS_BIN == 0 && (st & CONVERT_STAT_BITS_TXT_CRLF) != 0
884}
885
886/// Whether clean conversion uses Git's `has_crlf_in_index` guard (`convert.c` only for
887/// `CRLF_AUTO`, `CRLF_AUTO_INPUT`, `CRLF_AUTO_CRLF`). Bare `eol=` without `text=auto` becomes
888/// `CRLF_TEXT_*` and must not use this guard.
889#[must_use]
890pub fn clean_uses_autocrlf_index_guard(attrs: &FileAttrs, conv: &ConversionConfig) -> bool {
891    if attrs.text == TextAttr::Unset || attrs.crlf_legacy == CrlfLegacyAttr::Unset {
892        return false;
893    }
894    if attrs.eol != EolAttr::Unspecified && attrs.text != TextAttr::Auto {
895        return false;
896    }
897    attrs.text == TextAttr::Auto
898        || (attrs.text == TextAttr::Unspecified
899            && matches!(conv.autocrlf, AutoCrlf::True | AutoCrlf::Input))
900}
901
902/// Optional inputs for `convert_to_git_with_opts` (Git `CONV_EOL_RENORMALIZE` / index blob).
903#[derive(Clone, Copy)]
904pub struct ConvertToGitOpts<'a> {
905    /// Stage-0 blob bytes for this path before the current add (for safer-autocrlf).
906    pub index_blob: Option<&'a [u8]>,
907    /// When true, always apply CRLF→LF when configured (merge/cherry-pick renormalize).
908    pub renormalize: bool,
909    /// When false, skip `core.safecrlf` simulation (used for internal diff/hashing — must not spam stderr).
910    pub check_safecrlf: bool,
911    /// Long-running filter registry for this repository handle.
912    pub filter_process: Option<&'a FilterProcessState>,
913    /// Subprocess runner for clean/smudge shell filters (defaults to the conversion config runner).
914    pub command_runner: Option<&'a dyn CommandRunner>,
915}
916
917impl Default for ConvertToGitOpts<'_> {
918    fn default() -> Self {
919        Self {
920            index_blob: None,
921            renormalize: false,
922            check_safecrlf: true,
923            filter_process: None,
924            command_runner: None,
925        }
926    }
927}
928
929impl std::fmt::Debug for ConvertToGitOpts<'_> {
930    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
931        f.debug_struct("ConvertToGitOpts")
932            .field("index_blob", &self.index_blob.map(|b| b.len()))
933            .field("renormalize", &self.renormalize)
934            .field("check_safecrlf", &self.check_safecrlf)
935            .field("command_runner", &self.command_runner.is_some())
936            .finish()
937    }
938}
939
940// ---------------------------------------------------------------------------
941// working-tree-encoding (Git `convert.c` `encode_to_git` / `encode_to_worktree`)
942// ---------------------------------------------------------------------------
943
944// BOM byte sequences (Git `utf8.c`).
945const UTF16_BE_BOM: &[u8] = &[0xFE, 0xFF];
946const UTF16_LE_BOM: &[u8] = &[0xFF, 0xFE];
947const UTF32_BE_BOM: &[u8] = &[0x00, 0x00, 0xFE, 0xFF];
948const UTF32_LE_BOM: &[u8] = &[0xFF, 0xFE, 0x00, 0x00];
949
950/// Canonical lowercase UTF label for a `working-tree-encoding` value, or `None` if the label is
951/// not a UTF-16/UTF-32/UTF-8 variant Git treats specially. Mirrors Git's `same_utf_encoding`
952/// (strip a leading `utf` then an optional `-`, case-insensitive), so `utf16`, `UTF-16`,
953/// `Utf16Le-Bom` all normalize.
954fn canonical_utf_label(label: &str) -> Option<String> {
955    let trimmed = label.trim();
956    let lower = trimmed.to_ascii_lowercase();
957    let rest = lower.strip_prefix("utf")?;
958    let rest = rest.strip_prefix('-').unwrap_or(rest);
959    match rest {
960        "8" => Some("utf-8".to_string()),
961        "16" => Some("utf-16".to_string()),
962        "16be" => Some("utf-16be".to_string()),
963        "16le" => Some("utf-16le".to_string()),
964        "16be-bom" => Some("utf-16be-bom".to_string()),
965        "16le-bom" => Some("utf-16le-bom".to_string()),
966        "32" => Some("utf-32".to_string()),
967        "32be" => Some("utf-32be".to_string()),
968        "32le" => Some("utf-32le".to_string()),
969        _ => None,
970    }
971}
972
973fn has_bom_prefix(data: &[u8], bom: &[u8]) -> bool {
974    data.len() >= bom.len() && &data[..bom.len()] == bom
975}
976
977/// Git `has_prohibited_utf_bom`: UTF-16BE/LE and UTF-32BE/LE must not begin with a BOM.
978fn has_prohibited_utf_bom(canon: &str, data: &[u8]) -> bool {
979    match canon {
980        "utf-16be" | "utf-16le" => {
981            has_bom_prefix(data, UTF16_BE_BOM) || has_bom_prefix(data, UTF16_LE_BOM)
982        }
983        "utf-32be" | "utf-32le" => {
984            has_bom_prefix(data, UTF32_BE_BOM) || has_bom_prefix(data, UTF32_LE_BOM)
985        }
986        _ => false,
987    }
988}
989
990/// Git `is_missing_required_utf_bom`: bare UTF-16 / UTF-32 must begin with a BOM.
991fn is_missing_required_utf_bom(canon: &str, data: &[u8]) -> bool {
992    match canon {
993        "utf-16" => !(has_bom_prefix(data, UTF16_BE_BOM) || has_bom_prefix(data, UTF16_LE_BOM)),
994        "utf-32" => !(has_bom_prefix(data, UTF32_BE_BOM) || has_bom_prefix(data, UTF32_LE_BOM)),
995        _ => false,
996    }
997}
998
999/// Git `validate_encoding`: emit the advice line to stderr and return an error body when the BOM
1000/// presence is wrong for a UTF-16/UTF-32 encoding.
1001///
1002/// `label` is the original attribute spelling (preserved in messages, like Git). When
1003/// `die_on_error` is true (`CONV_WRITE_OBJECT`) the body is prefixed `fatal:` so the top-level
1004/// printer surfaces it verbatim; otherwise the `error:` line is printed here (Git `error()` returns
1005/// "content unmodified") and the same body is returned for the caller to swallow.
1006fn validate_utf_bom(
1007    canon: &str,
1008    label: &str,
1009    rel_path: &str,
1010    data: &[u8],
1011    die_on_error: bool,
1012) -> Result<(), String> {
1013    if has_prohibited_utf_bom(canon, data) {
1014        // Advice cuts the trailing "be"/"le" so the user sees the BOM-capable name (UTF-16/UTF-32).
1015        let stripped = label
1016            .strip_prefix("utf")
1017            .or_else(|| label.strip_prefix("UTF"));
1018        let _utf_num = stripped
1019            .map(|s| s.trim_start_matches('-'))
1020            .and_then(|s| s.get(..s.len().saturating_sub(2)))
1021            .unwrap_or("");
1022        let body = format!("BOM is prohibited in '{rel_path}' if encoded as {label}");
1023        if die_on_error {
1024            return Err(body);
1025        }
1026        return Err(body);
1027    }
1028    if is_missing_required_utf_bom(canon, data) {
1029        let _utf_num = label
1030            .strip_prefix("utf")
1031            .or_else(|| label.strip_prefix("UTF"))
1032            .map(|s| s.trim_start_matches('-'))
1033            .unwrap_or("");
1034        let body = format!("BOM is required in '{rel_path}' if encoded as {label}");
1035        if die_on_error {
1036            return Err(body);
1037        }
1038        return Err(body);
1039    }
1040    Ok(())
1041}
1042
1043/// Decode raw working-tree bytes (`enc_label`) into UTF-8 for the object DB (Git `encode_to_git`).
1044///
1045/// When `validate` is true (writing to the object DB), enforce Git's UTF BOM rules and surface the
1046/// matching fatal message + advice (`die_on_error`). For internal diff/status reads it is false.
1047fn decode_working_tree_bytes_to_utf8(
1048    src: &[u8],
1049    rel_path: &str,
1050    enc_label: &str,
1051    validate: bool,
1052) -> Result<Vec<u8>, ConversionError> {
1053    let label = enc_label.trim();
1054    if label.is_empty() {
1055        return Ok(src.to_vec());
1056    }
1057
1058    let canon = canonical_utf_label(label);
1059
1060    // BOM validation (only the UTF-16/UTF-32 family). Git validates on every `encode_to_git`; when
1061    // writing to the object DB it dies, otherwise (diff/status reads) it prints `error:` and treats
1062    // the content as unmodified — `validate` here is Git's `die_on_error` (`CONV_WRITE_OBJECT`).
1063    if let Some(ref c) = canon {
1064        validate_utf_bom(c, label, rel_path, src, validate)?;
1065    }
1066
1067    // UTF-8 is the default encoding: no conversion (Git `git_path_check_encoding`).
1068    if canon.as_deref() == Some("utf-8") {
1069        return Ok(src.to_vec());
1070    }
1071
1072    // The `*-BOM` aliases decode like the matching raw encoding once the BOM is stripped.
1073    let (iconv_from, body): (&str, &[u8]) = match canon.as_deref() {
1074        Some("utf-16le-bom") => {
1075            let body = if has_bom_prefix(src, UTF16_LE_BOM) {
1076                &src[2..]
1077            } else {
1078                src
1079            };
1080            ("UTF-16LE", body)
1081        }
1082        Some("utf-16be-bom") => {
1083            let body = if has_bom_prefix(src, UTF16_BE_BOM) {
1084                &src[2..]
1085            } else {
1086                src
1087            };
1088            ("UTF-16BE", body)
1089        }
1090        // Bare UTF-16/UTF-32 keep their BOM; iconv consumes it to pick the byte order.
1091        Some(c) => (utf_canon_to_iconv_name(c), src),
1092        None => {
1093            if crate::commit_encoding::is_iso_8859_1(label) {
1094                return Ok(crate::commit_encoding::worktree_latin1_to_utf8_bytes(src));
1095            }
1096            // Unknown / unsupported label (Git `reencode_string_len` returns NULL →
1097            // `failed to encode '%s' from %s to %s`).
1098            let Some(enc) = crate::commit_encoding::resolve(label) else {
1099                return Err(WorkTreeEncodingError::UnsupportedEncoding {
1100                    label: label.to_owned(),
1101                }
1102                .into());
1103            };
1104            if enc == UTF_8 {
1105                return Ok(src.to_vec());
1106            }
1107            let (cow, _, had_errors) = enc.decode(src);
1108            if had_errors {
1109                return Err(ConversionError::Message(format!(
1110                    "failed to encode '{rel_path}' from {label} to UTF-8"
1111                )));
1112            }
1113            return Ok(cow.into_owned().into_bytes());
1114        }
1115    };
1116
1117    decode_utf_bytes_with_encoding_rs(body, rel_path, label, iconv_from)
1118}
1119
1120/// Decode UTF-16/UTF-32 family bytes to UTF-8 (`encoding_rs` or manual UTF-32).
1121fn decode_utf_bytes_with_encoding_rs(
1122    body: &[u8],
1123    rel_path: &str,
1124    label: &str,
1125    iconv_from: &str,
1126) -> Result<Vec<u8>, ConversionError> {
1127    let fail = || {
1128        ConversionError::Message(format!(
1129            "failed to encode '{rel_path}' from {label} to UTF-8"
1130        ))
1131    };
1132    match iconv_from {
1133        "UTF-16BE" => {
1134            let (cow, _, had_errors) = encoding_rs::UTF_16BE.decode(body);
1135            if had_errors {
1136                return Err(fail());
1137            }
1138            Ok(cow.into_owned().into_bytes())
1139        }
1140        "UTF-16LE" => {
1141            let (cow, _, had_errors) = encoding_rs::UTF_16LE.decode(body);
1142            if had_errors {
1143                return Err(fail());
1144            }
1145            Ok(cow.into_owned().into_bytes())
1146        }
1147        "UTF-16" => {
1148            if has_bom_prefix(body, UTF16_BE_BOM) {
1149                decode_utf_bytes_with_encoding_rs(&body[2..], rel_path, label, "UTF-16BE")
1150            } else if has_bom_prefix(body, UTF16_LE_BOM) {
1151                decode_utf_bytes_with_encoding_rs(&body[2..], rel_path, label, "UTF-16LE")
1152            } else {
1153                Err(fail())
1154            }
1155        }
1156        "UTF-32" => {
1157            if has_bom_prefix(body, UTF32_BE_BOM) {
1158                decode_utf32_body_to_utf8_bytes(&body[4..], rel_path, true)
1159            } else if has_bom_prefix(body, UTF32_LE_BOM) {
1160                decode_utf32_body_to_utf8_bytes(&body[4..], rel_path, false)
1161            } else {
1162                Err(fail())
1163            }
1164        }
1165        "UTF-32BE" => decode_utf32_body_to_utf8_bytes(body, rel_path, true),
1166        "UTF-32LE" => decode_utf32_body_to_utf8_bytes(body, rel_path, false),
1167        _ => Err(fail()),
1168    }
1169}
1170
1171fn decode_utf32_body_to_utf8_bytes(
1172    body: &[u8],
1173    rel_path: &str,
1174    big_endian: bool,
1175) -> Result<Vec<u8>, ConversionError> {
1176    let fail = || {
1177        ConversionError::Message(format!(
1178            "failed to encode '{rel_path}' from UTF-32 to UTF-8"
1179        ))
1180    };
1181    if !body.len().is_multiple_of(4) {
1182        return Err(fail());
1183    }
1184    let mut s = String::new();
1185    for chunk in body.as_chunks::<4>().0 {
1186        let cp = if big_endian {
1187            u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
1188        } else {
1189            u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
1190        };
1191        let Some(ch) = char::from_u32(cp) else {
1192            return Err(fail());
1193        };
1194        s.push(ch);
1195    }
1196    Ok(s.into_bytes())
1197}
1198
1199/// iconv encoding name for a canonical UTF label (raw encodings only; `*-bom` handled separately).
1200fn utf_canon_to_iconv_name(canon: &str) -> &'static str {
1201    match canon {
1202        "utf-16" => "UTF-16",
1203        "utf-16be" => "UTF-16BE",
1204        "utf-16le" => "UTF-16LE",
1205        "utf-32" => "UTF-32",
1206        "utf-32be" => "UTF-32BE",
1207        "utf-32le" => "UTF-32LE",
1208        _ => "UTF-8",
1209    }
1210}
1211
1212/// Encode a UTF-8 blob into raw working-tree bytes for `enc_label` (Git `encode_to_worktree`).
1213///
1214/// Bare `UTF-16`/`UTF-32` and the `*-BOM` aliases get a BOM (Git relies on libiconv / explicit
1215/// BOM handling); the raw `UTF-16BE`/`UTF-16LE`/`UTF-32BE`/`UTF-32LE` encodings produce no BOM.
1216fn encode_utf8_blob_to_working_tree_bytes(
1217    src: &[u8],
1218    rel_path: &str,
1219    enc_label: &str,
1220) -> Result<Vec<u8>, ConversionError> {
1221    let label = enc_label.trim();
1222    if label.is_empty() {
1223        return Ok(src.to_vec());
1224    }
1225
1226    let canon = canonical_utf_label(label);
1227    if canon.as_deref() == Some("utf-8") {
1228        return Ok(src.to_vec());
1229    }
1230
1231    let fail = || {
1232        ConversionError::Message(format!(
1233            "failed to encode '{rel_path}' from UTF-8 to {label}"
1234        ))
1235    };
1236
1237    // The `*-BOM` aliases: encode to the raw form, then prepend the requested BOM.
1238    match canon.as_deref() {
1239        Some("utf-16le-bom") => {
1240            let body = encode_utf_with_encoding_rs(src, "utf-16le").ok_or_else(fail)?;
1241            let mut out = UTF16_LE_BOM.to_vec();
1242            out.extend(body);
1243            return Ok(out);
1244        }
1245        Some("utf-16be-bom") => {
1246            let body = encode_utf_with_encoding_rs(src, "utf-16be").ok_or_else(fail)?;
1247            let mut out = UTF16_BE_BOM.to_vec();
1248            out.extend(body);
1249            return Ok(out);
1250        }
1251        // Bare UTF-16/UTF-32: Git/libiconv emits a BOM then little-endian code units (see `iconv`).
1252        Some("utf-16") => {
1253            let body = encode_utf_with_encoding_rs(src, "utf-16le").ok_or_else(fail)?;
1254            let mut out = UTF16_LE_BOM.to_vec();
1255            out.extend(body);
1256            return Ok(out);
1257        }
1258        Some("utf-32") => {
1259            let body = encode_utf_with_encoding_rs(src, "utf-32le").ok_or_else(fail)?;
1260            let mut out = UTF32_LE_BOM.to_vec();
1261            out.extend(body);
1262            return Ok(out);
1263        }
1264        Some(c) => {
1265            return encode_utf_with_encoding_rs(src, c).ok_or_else(fail);
1266        }
1267        None => {}
1268    }
1269
1270    if crate::commit_encoding::is_iso_8859_1(label) {
1271        return crate::commit_encoding::utf8_bytes_to_worktree_latin1(src).ok_or_else(fail);
1272    }
1273
1274    let s = std::str::from_utf8(src).map_err(|_| fail())?;
1275    let Some(enc) = crate::commit_encoding::resolve(label) else {
1276        return Err(WorkTreeEncodingError::UnsupportedEncoding {
1277            label: label.to_owned(),
1278        }
1279        .into());
1280    };
1281    if enc == UTF_8 {
1282        return Ok(src.to_vec());
1283    }
1284    let (cow, _, had_errors) = enc.encode(s);
1285    if had_errors {
1286        return Err(fail());
1287    }
1288    Ok(cow.into_owned())
1289}
1290
1291/// UTF encode via `encoding_rs`/manual UTF-32. `target` is a canonical label (`utf-16le` etc.).
1292/// Produces raw bytes (no BOM).
1293fn encode_utf_with_encoding_rs(src: &[u8], target: &str) -> Option<Vec<u8>> {
1294    let s = std::str::from_utf8(src).ok()?;
1295    let lower = target.to_ascii_lowercase();
1296    let mut out = Vec::new();
1297    match lower.as_str() {
1298        "utf-16" | "utf-16be" => {
1299            for u in s.encode_utf16() {
1300                out.extend_from_slice(&u.to_be_bytes());
1301            }
1302        }
1303        "utf-16le" => {
1304            for u in s.encode_utf16() {
1305                out.extend_from_slice(&u.to_le_bytes());
1306            }
1307        }
1308        "utf-32" | "utf-32be" => {
1309            for ch in s.chars() {
1310                out.extend_from_slice(&(ch as u32).to_be_bytes());
1311            }
1312        }
1313        "utf-32le" => {
1314            for ch in s.chars() {
1315                out.extend_from_slice(&(ch as u32).to_le_bytes());
1316            }
1317        }
1318        _ => return None,
1319    }
1320    Some(out)
1321}
1322
1323// ---------------------------------------------------------------------------
1324// Input (add / clean) direction
1325// ---------------------------------------------------------------------------
1326
1327/// Convert data for storage in the index/object database (the "clean" direction).
1328///
1329/// This handles:
1330/// 1. Clean filter execution
1331/// 2. `working-tree-encoding` decode to UTF-8
1332/// 3. CRLF → LF conversion based on config + attributes
1333/// 4. `$Id: … $` → `$Id$` collapse when `ident` is set
1334/// 5. safecrlf checking
1335///
1336/// Returns `Ok(data)` on success, or an error if safecrlf rejects it.
1337pub fn convert_to_git(
1338    data: &[u8],
1339    rel_path: &str,
1340    conv: &ConversionConfig,
1341    file_attrs: &FileAttrs,
1342) -> Result<Vec<u8>, FilterError> {
1343    convert_to_git_with_opts(
1344        data,
1345        rel_path,
1346        conv,
1347        file_attrs,
1348        ConvertToGitOpts::default(),
1349    )
1350}
1351
1352/// Like [`convert_to_git`] with Git-compatible safer-autocrlf index handling.
1353pub(crate) fn convert_to_git_with_opts(
1354    data: &[u8],
1355    rel_path: &str,
1356    conv: &ConversionConfig,
1357    file_attrs: &FileAttrs,
1358    opts: ConvertToGitOpts<'_>,
1359) -> Result<Vec<u8>, FilterError> {
1360    convert_to_git_owned(data.to_vec(), rel_path, conv, file_attrs, opts)
1361}
1362
1363/// [`convert_to_git_with_opts`] taking ownership of `data`, so content that needs no
1364/// conversion is returned without a copy.
1365pub(crate) fn convert_to_git_owned(
1366    data: Vec<u8>,
1367    rel_path: &str,
1368    conv: &ConversionConfig,
1369    file_attrs: &FileAttrs,
1370    opts: ConvertToGitOpts<'_>,
1371) -> Result<Vec<u8>, FilterError> {
1372    let mut buf = data;
1373    let owned_filter_state;
1374    let filter_state = if let Some(s) = opts.filter_process {
1375        s
1376    } else {
1377        owned_filter_state = FilterProcessState::new(Arc::clone(&conv.command_runner));
1378        &owned_filter_state
1379    };
1380    let runner = opts.command_runner.unwrap_or(conv.command_runner.as_ref());
1381
1382    // 1. Run clean filter if configured (long-running `process` overrides clean command)
1383    if let Some(ref proc_cmd) = file_attrs.filter_process {
1384        let name = file_attrs.filter_driver_name.as_deref().unwrap_or_default();
1385        if file_attrs.filter_clean_required {
1386            start_required_process_filter(
1387                filter_state,
1388                proc_cmd,
1389                name,
1390                rel_path,
1391                FilterPhase::Clean,
1392            )?;
1393        }
1394        match apply_process_clean(filter_state, proc_cmd, rel_path, &buf) {
1395            Ok(filtered) => buf = filtered,
1396            Err(e) => {
1397                if file_attrs.filter_clean_required {
1398                    if e.contains("expected git-filter-server") {
1399                        return Err(filter_detail(rel_path, e));
1400                    }
1401                    return Err(FilterError::ExternalFilterFailed {
1402                        driver: name.to_owned(),
1403                        path: rel_path.to_owned(),
1404                        phase: FilterPhase::Clean,
1405                    });
1406                }
1407                if e.starts_with("filter status: abort") {
1408                    filter_state.disable_process_filter(proc_cmd);
1409                }
1410            }
1411        }
1412    } else {
1413        match file_attrs.filter_clean.as_ref() {
1414            Some(clean_cmd) => {
1415                match run_filter_command(runner, clean_cmd, &buf, rel_path, Some(filter_state)) {
1416                    Ok(filtered) => buf = filtered,
1417                    Err(e) if file_attrs.filter_clean_required => {
1418                        let name = file_attrs.filter_driver_name.as_deref().unwrap_or_default();
1419                        return Err(e.into_required_error(name, rel_path, FilterPhase::Clean));
1420                    }
1421                    // Like Git, an optional filter that fails leaves the content unfiltered.
1422                    Err(_) => {}
1423                }
1424            }
1425            None => {
1426                if file_attrs.filter_clean_required {
1427                    let name = file_attrs.filter_driver_name.as_deref().unwrap_or_default();
1428                    return Err(FilterError::ExternalFilterFailed {
1429                        driver: name.to_owned(),
1430                        path: rel_path.to_owned(),
1431                        phase: FilterPhase::Clean,
1432                    });
1433                }
1434            }
1435        }
1436    }
1437
1438    // 2. working-tree-encoding: working tree bytes → UTF-8 for the object DB (Git `encode_to_git`).
1439    if let Some(ref enc) = file_attrs.working_tree_encoding {
1440        // Bare `working-tree-encoding` (boolean true) / `false` are rejected (Git
1441        // `git_path_check_encoding`).
1442        if enc == "set" || enc == "true" || enc == "false" {
1443            return Err(FilterError::InvalidWorkingTreeEncoding);
1444        }
1445        // `CONV_WRITE_OBJECT` → validate BOM rules and die on error (Git `encode_to_git`).
1446        let writing_object = opts.check_safecrlf;
1447        match decode_working_tree_bytes_to_utf8(&buf, rel_path, enc, writing_object) {
1448            Ok(decoded) => buf = decoded,
1449            Err(e) if writing_object => return Err(filter_detail(rel_path, e.to_string())),
1450            // Reads for diff/status/hashing keep the bytes as they are when they cannot be
1451            // re-encoded, as Git does without `CONV_WRITE_OBJECT`.
1452            Err(_) => {}
1453        }
1454    }
1455
1456    // 3. Determine if we should do CRLF→LF conversion
1457    let would_convert = would_convert_on_input(conv, file_attrs, &buf);
1458
1459    let mut convert_crlf_into_lf = would_convert && has_crlf(&buf);
1460    if convert_crlf_into_lf
1461        && clean_uses_autocrlf_index_guard(file_attrs, conv)
1462        && !opts.renormalize
1463        && opts.index_blob.is_some_and(has_crlf_in_index_blob)
1464    {
1465        convert_crlf_into_lf = false;
1466    }
1467
1468    // 4. safecrlf check — Git simulates clean then smudge (`check_global_conv_flags_eol`).
1469    if would_convert && opts.check_safecrlf {
1470        check_safecrlf_roundtrip(conv, file_attrs, &buf, rel_path, convert_crlf_into_lf)?;
1471    }
1472
1473    // 5. Actually convert CRLF → LF if the file has CRLFs
1474    if convert_crlf_into_lf {
1475        buf = crlf_to_lf(&buf);
1476    }
1477
1478    // 6. Ident collapse (Git `ident_to_git` after filter / encode / crlf_to_git).
1479    if file_attrs.ident && count_ident_regions(&buf) {
1480        buf = collapse_ident(&buf);
1481    }
1482
1483    Ok(buf)
1484}
1485
1486/// Whether [`convert_to_git_with_opts`] returns every file at a path with these attributes
1487/// unchanged.
1488///
1489/// Decided from configuration and attributes alone, without looking at content: no clean or
1490/// process filter (required or not) and no `working-tree-encoding` applies, no `ident` collapse,
1491/// and no line-ending setting can turn CRLF into LF. When this holds, a work-tree file's blob id
1492/// can be computed by hashing the file as it is, for example in chunks without loading it whole.
1493///
1494/// # Parameters
1495///
1496/// - `conv` — line-ending configuration (`core.autocrlf`, `core.eol`).
1497/// - `attrs` — the path's resolved attributes.
1498#[must_use]
1499pub(crate) fn git_conversion_is_identity(conv: &ConversionConfig, attrs: &FileAttrs) -> bool {
1500    attrs.filter_process.is_none()
1501        && attrs.filter_clean.is_none()
1502        && !attrs.filter_clean_required
1503        && attrs.working_tree_encoding.is_none()
1504        && !attrs.ident
1505        && input_eol_rule(conv, attrs) == InputEolRule::Never
1506}
1507
1508/// Whether a regular worktree file can be hashed or stored by streaming raw bytes without
1509/// loading it whole.
1510///
1511/// Matches [`git_conversion_is_identity`] when line-ending rules never apply. When the only
1512/// blocker is `core.autocrlf` / `text=auto` (`InputEolRule::UnlessBinary`), probes the first
1513/// 8000 bytes for a NUL (same as [`is_binary`]) and allows streaming when the file is binary.
1514///
1515/// Returns `false` when a clean filter, `working-tree-encoding`, `ident`, or a non-binary
1516/// file needs the autocrlf index guard's stage-0 blob. Binary files stream even when indexed,
1517/// matching Git's `convert.c` order (binary check before the index CRLF guard).
1518///
1519/// # Errors
1520///
1521/// Returns [`crate::error::Error::Io`] when the probe read or seek fails.
1522pub(crate) fn worktree_allows_raw_byte_stream(
1523    conv: &ConversionConfig,
1524    attrs: &FileAttrs,
1525    index_entry: Option<&crate::index::IndexEntry>,
1526    file: &mut std::fs::File,
1527    size: u64,
1528) -> crate::error::Result<bool> {
1529    use std::io::{Read, Seek, SeekFrom};
1530
1531    if attrs.filter_process.is_some()
1532        || attrs.filter_clean.is_some()
1533        || attrs.filter_clean_required
1534        || attrs.working_tree_encoding.is_some()
1535        || attrs.ident
1536    {
1537        return Ok(false);
1538    }
1539    match input_eol_rule(conv, attrs) {
1540        InputEolRule::Never => Ok(true),
1541        InputEolRule::Always => Ok(false),
1542        InputEolRule::UnlessBinary => {
1543            let probe_len = usize::try_from(size).unwrap_or(8000).min(8000);
1544            if probe_len == 0 {
1545                return Ok(false);
1546            }
1547            file.seek(SeekFrom::Start(0))
1548                .map_err(crate::error::Error::Io)?;
1549            let mut buf = vec![0u8; probe_len];
1550            file.read_exact(&mut buf).map_err(crate::error::Error::Io)?;
1551            file.seek(SeekFrom::Start(0))
1552                .map_err(crate::error::Error::Io)?;
1553            if is_binary(&buf) {
1554                return Ok(true);
1555            }
1556            if index_entry.is_some() && clean_uses_autocrlf_index_guard(attrs, conv) {
1557                return Ok(false);
1558            }
1559            Ok(false)
1560        }
1561    }
1562}
1563
1564/// Decide whether CRLF/LF conversion is configured for this file on input.
1565/// Returns true if the file *would* be subject to conversion (even if no
1566/// actual bytes need changing).
1567fn would_convert_on_input(conv: &ConversionConfig, attrs: &FileAttrs, data: &[u8]) -> bool {
1568    match input_eol_rule(conv, attrs) {
1569        InputEolRule::Never => false,
1570        InputEolRule::Always => true,
1571        InputEolRule::UnlessBinary => !is_binary(data),
1572    }
1573}
1574
1575/// Whether CRLF/LF conversion applies on input, before looking at the content.
1576#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1577enum InputEolRule {
1578    Never,
1579    Always,
1580    UnlessBinary,
1581}
1582
1583fn input_eol_rule(conv: &ConversionConfig, attrs: &FileAttrs) -> InputEolRule {
1584    match attrs.crlf_legacy {
1585        CrlfLegacyAttr::Unset => return InputEolRule::Never,
1586        CrlfLegacyAttr::Input => return InputEolRule::UnlessBinary,
1587        CrlfLegacyAttr::Crlf => {
1588            if attrs.text == TextAttr::Unset {
1589                return InputEolRule::Never;
1590            }
1591            return InputEolRule::UnlessBinary;
1592        }
1593        CrlfLegacyAttr::Unspecified => {}
1594    }
1595
1596    // If text is explicitly unset (-text or binary), never convert
1597    if attrs.text == TextAttr::Unset {
1598        return InputEolRule::Never;
1599    }
1600
1601    // If eol attr is set, this implies text mode
1602    if attrs.eol != EolAttr::Unspecified {
1603        if attrs.text == TextAttr::Auto {
1604            return InputEolRule::UnlessBinary;
1605        }
1606        return InputEolRule::Always;
1607    }
1608
1609    match attrs.text {
1610        TextAttr::Set => return InputEolRule::Always,
1611        TextAttr::Auto => return InputEolRule::UnlessBinary,
1612        _ => {}
1613    }
1614
1615    // No text attribute: fall back to core.autocrlf
1616    match conv.autocrlf {
1617        AutoCrlf::True | AutoCrlf::Input => InputEolRule::UnlessBinary,
1618        AutoCrlf::False => InputEolRule::Never,
1619    }
1620}
1621
1622/// Git-compatible stderr when `core.safecrlf` is `warn` (clean direction, CRLF→LF).
1623fn eprint_safecrlf_warn_crlf_to_lf(_rel_path: &str) {}
1624
1625/// Git-compatible stderr when `core.safecrlf` is `warn` (clean direction, LF→CRLF).
1626fn eprint_safecrlf_warn_lf_to_crlf(_rel_path: &str) {}
1627
1628/// Git `convert.c` `check_global_conv_flags_eol` after simulating clean + smudge.
1629fn check_safecrlf_roundtrip(
1630    conv: &ConversionConfig,
1631    file_attrs: &FileAttrs,
1632    data: &[u8],
1633    rel_path: &str,
1634    convert_crlf_into_lf: bool,
1635) -> Result<(), FilterError> {
1636    if conv.safecrlf == SafeCrlf::False {
1637        return Ok(());
1638    }
1639
1640    let old_stats = git_text_stat(data);
1641
1642    let mut new_stats = old_stats.clone();
1643    if convert_crlf_into_lf && new_stats.crlf > 0 {
1644        new_stats.lonelf += new_stats.crlf;
1645        new_stats.crlf = 0;
1646    }
1647    if will_convert_lf_to_crlf_from_stats(&new_stats, conv, file_attrs) {
1648        new_stats.crlf += new_stats.lonelf;
1649        new_stats.lonelf = 0;
1650    }
1651
1652    if old_stats.crlf > 0 && new_stats.crlf == 0 {
1653        let err = FilterError::LineEndingWouldChange {
1654            detail: format!("CRLF would be replaced by LF in {rel_path}"),
1655        };
1656        if conv.safecrlf == SafeCrlf::True {
1657            return Err(err);
1658        }
1659        eprint_safecrlf_warn_crlf_to_lf(rel_path);
1660    } else if old_stats.lonelf > 0 && new_stats.lonelf == 0 {
1661        let err = FilterError::LineEndingWouldChange {
1662            detail: format!("LF would be replaced by CRLF in {rel_path}"),
1663        };
1664        if conv.safecrlf == SafeCrlf::True {
1665            return Err(err);
1666        }
1667        eprint_safecrlf_warn_lf_to_crlf(rel_path);
1668    }
1669
1670    Ok(())
1671}
1672
1673/// Replace CRLF with LF.
1674pub fn crlf_to_lf(data: &[u8]) -> Vec<u8> {
1675    let mut out = Vec::with_capacity(data.len());
1676    let mut i = 0;
1677    while i < data.len() {
1678        if i + 1 < data.len() && data[i] == b'\r' && data[i + 1] == b'\n' {
1679            out.push(b'\n');
1680            i += 2;
1681        } else {
1682            out.push(data[i]);
1683            i += 1;
1684        }
1685    }
1686    out
1687}
1688
1689/// Replace lone LF with CRLF.
1690pub fn lf_to_crlf(data: &[u8]) -> Vec<u8> {
1691    let mut out = Vec::with_capacity(data.len() + data.len() / 10);
1692    let mut i = 0;
1693    while i < data.len() {
1694        if data[i] == b'\n' && (i == 0 || data[i - 1] != b'\r') {
1695            out.push(b'\r');
1696            out.push(b'\n');
1697        } else {
1698            out.push(data[i]);
1699        }
1700        i += 1;
1701    }
1702    out
1703}
1704
1705// ---------------------------------------------------------------------------
1706// Output (checkout / smudge) direction
1707// ---------------------------------------------------------------------------
1708
1709/// Convert data from the object database for writing to the working tree
1710/// (the "smudge" direction).
1711///
1712/// This handles (Git `convert_to_working_tree_ca_internal` order):
1713/// 1. Ident keyword expansion
1714/// 2. LF → CRLF conversion based on config + attributes
1715/// 3. `working-tree-encoding` (UTF-8 blob → working tree bytes)
1716/// 4. Smudge filter execution
1717///
1718/// Returns `Ok(None)` when the process filter returned `status=delayed` and `delayed_checkout` was
1719/// provided (Git `delayed_checkout`); the path is queued for delayed checkout finish.
1720#[allow(clippy::too_many_arguments)]
1721pub(crate) fn convert_to_worktree(
1722    data: &[u8],
1723    rel_path: &str,
1724    conv: &ConversionConfig,
1725    file_attrs: &FileAttrs,
1726    oid_hex: Option<&str>,
1727    smudge_meta: Option<&FilterSmudgeMeta>,
1728    delayed_checkout: Option<&mut crate::filter_process::DelayedProcessCheckout>,
1729    filter_process: Option<&FilterProcessState>,
1730) -> Result<Option<Vec<u8>>, FilterError> {
1731    convert_to_worktree_with_runner(
1732        data,
1733        rel_path,
1734        conv,
1735        file_attrs,
1736        oid_hex,
1737        smudge_meta,
1738        delayed_checkout,
1739        filter_process,
1740        None,
1741    )
1742}
1743
1744/// Like [`convert_to_worktree`] with an explicit filter [`CommandRunner`].
1745#[expect(clippy::too_many_arguments)]
1746pub(crate) fn convert_to_worktree_with_runner(
1747    data: &[u8],
1748    rel_path: &str,
1749    conv: &ConversionConfig,
1750    file_attrs: &FileAttrs,
1751    oid_hex: Option<&str>,
1752    smudge_meta: Option<&FilterSmudgeMeta>,
1753    delayed_checkout: Option<&mut crate::filter_process::DelayedProcessCheckout>,
1754    filter_process: Option<&FilterProcessState>,
1755    command_runner: Option<&dyn CommandRunner>,
1756) -> Result<Option<Vec<u8>>, FilterError> {
1757    let mut buf = data.to_vec();
1758    let owned_filter_state;
1759    let filter_state = if let Some(s) = filter_process {
1760        s
1761    } else {
1762        owned_filter_state = FilterProcessState::new(Arc::clone(&conv.command_runner));
1763        &owned_filter_state
1764    };
1765    let runner = command_runner.unwrap_or(conv.command_runner.as_ref());
1766
1767    // 1. Ident expansion
1768    if file_attrs.ident {
1769        if let Some(oid) = oid_hex {
1770            buf = expand_ident(&buf, oid);
1771        }
1772    }
1773
1774    let can_delay_smudge = delayed_checkout.is_some()
1775        && file_attrs.working_tree_encoding.is_none()
1776        && !file_attrs.ident
1777        && file_attrs
1778            .filter_process
1779            .as_deref()
1780            .is_some_and(|c| !c.is_empty())
1781        && !should_convert_to_crlf(conv, file_attrs, &buf)
1782        && file_attrs
1783            .filter_process
1784            .as_deref()
1785            .is_some_and(|c| crate::filter_process::process_filter_supports_delay(filter_state, c));
1786
1787    // 2. LF→CRLF for working tree
1788    let should_convert = should_convert_to_crlf(conv, file_attrs, &buf);
1789    if should_convert {
1790        buf = lf_to_crlf(&buf);
1791    }
1792
1793    // 3. working-tree-encoding (Git `encode_to_worktree`)
1794    if let Some(ref enc) = file_attrs.working_tree_encoding {
1795        buf = encode_utf8_blob_to_working_tree_bytes(&buf, rel_path, enc)
1796            .map_err(|e| filter_detail(rel_path, e.to_string()))?;
1797    }
1798
1799    // 4. Smudge filter — process driver overrides shell smudge
1800    let driver = file_attrs.filter_driver_name.as_deref().unwrap_or("");
1801    if let Some(ref proc_cmd) = file_attrs.filter_process {
1802        if file_attrs.filter_smudge_required {
1803            start_required_process_filter(
1804                filter_state,
1805                proc_cmd,
1806                driver,
1807                rel_path,
1808                FilterPhase::Smudge,
1809            )?;
1810        }
1811        let smudge_out = match apply_process_smudge(
1812            filter_state,
1813            proc_cmd,
1814            rel_path,
1815            &buf,
1816            smudge_meta,
1817            can_delay_smudge,
1818        ) {
1819            Ok(out) => out,
1820            Err(e) => {
1821                if file_attrs.filter_smudge_required {
1822                    return Err(external_filter_failed(
1823                        rel_path,
1824                        driver,
1825                        FilterPhase::Smudge,
1826                    ));
1827                }
1828                if e.starts_with("filter status: abort") {
1829                    filter_state.disable_process_filter(proc_cmd);
1830                }
1831                return Ok(Some(buf));
1832            }
1833        };
1834        let Some(out) = smudge_out else {
1835            let Some(q) = delayed_checkout else {
1836                return Err(filter_detail(
1837                    rel_path,
1838                    format!("internal error: delayed smudge without checkout queue for {rel_path}"),
1839                ));
1840            };
1841            q.push_delayed(
1842                proc_cmd.clone(),
1843                rel_path.to_string(),
1844                smudge_meta.cloned().unwrap_or_default(),
1845            );
1846            return Ok(None);
1847        };
1848        buf = out;
1849    } else {
1850        match file_attrs.filter_smudge.as_ref() {
1851            Some(smudge_cmd) => {
1852                match run_filter_command(runner, smudge_cmd, &buf, rel_path, Some(filter_state)) {
1853                    Ok(filtered) => buf = filtered,
1854                    Err(e) if file_attrs.filter_smudge_required => {
1855                        return Err(e.into_required_error(driver, rel_path, FilterPhase::Smudge));
1856                    }
1857                    Err(_) => {}
1858                }
1859            }
1860            None => {
1861                if file_attrs.filter_smudge_required {
1862                    return Err(external_filter_failed(
1863                        rel_path,
1864                        driver,
1865                        FilterPhase::Smudge,
1866                    ));
1867                }
1868            }
1869        }
1870    }
1871
1872    Ok(Some(buf))
1873}
1874
1875/// Like `convert_to_worktree` without delayed-checkout queueing (always materializes or errors).
1876pub fn convert_to_worktree_eager(
1877    data: &[u8],
1878    rel_path: &str,
1879    conv: &ConversionConfig,
1880    file_attrs: &FileAttrs,
1881    oid_hex: Option<&str>,
1882    smudge_meta: Option<&FilterSmudgeMeta>,
1883    filter_process: Option<&FilterProcessState>,
1884) -> Result<Vec<u8>, FilterError> {
1885    match convert_to_worktree(
1886        data,
1887        rel_path,
1888        conv,
1889        file_attrs,
1890        oid_hex,
1891        smudge_meta,
1892        None,
1893        filter_process,
1894    )? {
1895        Some(v) => Ok(v),
1896        None => Err(filter_detail(
1897            rel_path,
1898            format!("internal error: unexpected delayed smudge for {rel_path}"),
1899        )),
1900    }
1901}
1902
1903/// Whether [`convert_to_worktree`] returns every blob at a path with these attributes unchanged.
1904///
1905/// Decided from configuration and attributes alone, without looking at content: no `ident`,
1906/// `working-tree-encoding`, smudge or process filter applies, and no line-ending setting can
1907/// turn LF into CRLF. When this holds, a blob can be copied into the work tree verbatim, for
1908/// example streamed straight from the object database.
1909///
1910/// # Parameters
1911///
1912/// - `conv` — line-ending configuration (`core.autocrlf`, `core.eol`).
1913/// - `attrs` — the path's resolved attributes.
1914#[must_use]
1915pub(crate) fn worktree_conversion_is_identity(conv: &ConversionConfig, attrs: &FileAttrs) -> bool {
1916    !attrs.ident
1917        && attrs.working_tree_encoding.is_none()
1918        && attrs.filter_smudge.is_none()
1919        && attrs.filter_process.is_none()
1920        && !attrs.filter_smudge_required
1921        && !crlf_output_possible(conv, attrs)
1922}
1923
1924/// Whether some content at this path could be converted LF→CRLF on output.
1925///
1926/// `false` means [`should_convert_to_crlf`] is `false` for every blob.
1927fn crlf_output_possible(conv: &ConversionConfig, attrs: &FileAttrs) -> bool {
1928    match attrs.crlf_legacy {
1929        CrlfLegacyAttr::Unset | CrlfLegacyAttr::Input => return false,
1930        CrlfLegacyAttr::Crlf => return attrs.text != TextAttr::Unset,
1931        CrlfLegacyAttr::Unspecified => {}
1932    }
1933    if attrs.text == TextAttr::Unset {
1934        return false;
1935    }
1936    if attrs.eol != EolAttr::Unspecified {
1937        return attrs.eol == EolAttr::Crlf;
1938    }
1939    match attrs.text {
1940        TextAttr::Set | TextAttr::Auto => output_eol_is_crlf(conv),
1941        _ => conv.autocrlf == AutoCrlf::True,
1942    }
1943}
1944
1945/// Decide whether to convert LF→CRLF on output (working tree / smudge direction).
1946#[must_use]
1947pub fn should_convert_to_crlf(conv: &ConversionConfig, attrs: &FileAttrs, data: &[u8]) -> bool {
1948    if !crlf_output_possible(conv, attrs) {
1949        return false;
1950    }
1951    match attrs.crlf_legacy {
1952        CrlfLegacyAttr::Unset | CrlfLegacyAttr::Input => return false,
1953        CrlfLegacyAttr::Crlf => {
1954            if attrs.text == TextAttr::Unset {
1955                return false;
1956            }
1957            // Legacy `crlf` (set) forces CRLF on checkout (even for paths Git
1958            // would otherwise treat as binary; see t0020 "t* crlf" + `three`).
1959            return true;
1960        }
1961        CrlfLegacyAttr::Unspecified => {}
1962    }
1963
1964    // If text is explicitly unset, never convert
1965    if attrs.text == TextAttr::Unset {
1966        return false;
1967    }
1968
1969    // If there's an explicit eol attribute
1970    if attrs.eol != EolAttr::Unspecified {
1971        if attrs.text == TextAttr::Auto && is_binary(data) {
1972            return false;
1973        }
1974        if attrs.eol != EolAttr::Crlf {
1975            return false;
1976        }
1977        // `text=auto` + `eol=crlf` → Git `CRLF_AUTO_CRLF` (safe mixed handling).
1978        if attrs.text == TextAttr::Auto {
1979            return auto_crlf_should_smudge_lf_to_crlf(data);
1980        }
1981        // Explicit `eol=crlf` with `text` set, etc. → `CRLF_TEXT_CRLF` (always normalize).
1982        return true;
1983    }
1984
1985    // If text is explicitly set, use eol config
1986    if attrs.text == TextAttr::Set {
1987        return output_eol_is_crlf(conv);
1988    }
1989
1990    if attrs.text == TextAttr::Auto {
1991        if is_binary(data) {
1992            return false;
1993        }
1994        if !output_eol_is_crlf(conv) {
1995            return false;
1996        }
1997        return auto_crlf_should_smudge_lf_to_crlf(data);
1998    }
1999
2000    // No text attribute: fall back to core.autocrlf
2001    match conv.autocrlf {
2002        AutoCrlf::True => {
2003            if is_binary(data) {
2004                return false;
2005            }
2006            auto_crlf_should_smudge_lf_to_crlf(data)
2007        }
2008        AutoCrlf::Input | AutoCrlf::False => false,
2009    }
2010}
2011
2012/// Whether the output EOL should be CRLF based on config.
2013fn output_eol_is_crlf(conv: &ConversionConfig) -> bool {
2014    // Git `text_eol_is_crlf`: autocrlf=input forces LF output before `core.eol` is consulted.
2015    if conv.autocrlf == AutoCrlf::Input {
2016        return false;
2017    }
2018    if conv.autocrlf == AutoCrlf::True {
2019        return true;
2020    }
2021    match conv.eol {
2022        CoreEol::Crlf => true,
2023        CoreEol::Lf => false,
2024        CoreEol::Native => {
2025            // On Unix, native is LF
2026            cfg!(windows)
2027        }
2028    }
2029}
2030
2031/// Expand `$Id$` → `$Id: <oid>$` in data.
2032///
2033/// Matches Git's `ident_to_worktree` in `convert.c`: same-line `$` terminator, and foreign
2034/// idents (internal spaces before the closing `$`) are left unchanged.
2035fn expand_ident(data: &[u8], oid: &str) -> Vec<u8> {
2036    if !count_ident_regions(data) {
2037        return data.to_vec();
2038    }
2039    let replacement = format!("$Id: {oid} $");
2040    let mut out = Vec::with_capacity(data.len() + 60);
2041    let mut i = 0;
2042    while i < data.len() {
2043        if data[i] != b'$' {
2044            out.push(data[i]);
2045            i += 1;
2046            continue;
2047        }
2048        if i + 3 > data.len() || data[i + 1] != b'I' || data[i + 2] != b'd' {
2049            out.push(data[i]);
2050            i += 1;
2051            continue;
2052        }
2053        let after_id = i + 3;
2054        let ch = data.get(after_id).copied();
2055        match ch {
2056            Some(b'$') => {
2057                out.extend_from_slice(replacement.as_bytes());
2058                i = after_id + 1;
2059            }
2060            Some(b':') => {
2061                let rest = &data[after_id + 1..];
2062                let line_end = rest
2063                    .iter()
2064                    .position(|&b| b == b'\n' || b == b'\r')
2065                    .unwrap_or(rest.len());
2066                let line = &rest[..line_end];
2067                let Some(dollar_rel) = line.iter().position(|&b| b == b'$') else {
2068                    out.push(data[i]);
2069                    i += 1;
2070                    continue;
2071                };
2072                if line[..dollar_rel].contains(&b'\n') {
2073                    out.push(data[i]);
2074                    i += 1;
2075                    continue;
2076                }
2077                // Foreign ident (Git `ident_to_worktree`): first space in the payload after the
2078                // byte following `:` must not be the last character before `$`.
2079                let payload = &line[..dollar_rel];
2080                let foreign = payload.len() > 1
2081                    && payload[1..]
2082                        .iter()
2083                        .position(|&b| b == b' ')
2084                        .is_some_and(|rel| {
2085                            let pos = 1 + rel;
2086                            pos < payload.len().saturating_sub(1)
2087                        });
2088                if foreign {
2089                    out.push(data[i]);
2090                    i += 1;
2091                    continue;
2092                }
2093                out.extend_from_slice(replacement.as_bytes());
2094                i = after_id + 1 + dollar_rel + 1;
2095            }
2096            _ => {
2097                out.push(data[i]);
2098                i += 1;
2099            }
2100        }
2101    }
2102    out
2103}
2104
2105/// Whether the buffer contains any `$Id$` / `$Id: ... $` regions Git would rewrite (`count_ident`).
2106fn count_ident_regions(data: &[u8]) -> bool {
2107    let mut i = 0usize;
2108    while i < data.len() {
2109        if data[i] != b'$' {
2110            i += 1;
2111            continue;
2112        }
2113        if i + 3 > data.len() || data[i + 1] != b'I' || data[i + 2] != b'd' {
2114            i += 1;
2115            continue;
2116        }
2117        let after = i + 3;
2118        match data.get(after).copied() {
2119            Some(b'$') => return true,
2120            Some(b':') => {
2121                let mut j = after + 1;
2122                let mut found = false;
2123                while j < data.len() {
2124                    match data[j] {
2125                        b'$' => {
2126                            found = true;
2127                            break;
2128                        }
2129                        // Git `ident_to_git` / `count_ident`: LF ends the scan; CR may appear
2130                        // inside the payload before the closing `$`.
2131                        b'\n' => break,
2132                        _ => j += 1,
2133                    }
2134                }
2135                if found {
2136                    return true;
2137                }
2138                i += 1;
2139            }
2140            _ => i += 1,
2141        }
2142    }
2143    false
2144}
2145
2146/// Collapse `$Id: ... $` back to `$Id$`.
2147pub fn collapse_ident(data: &[u8]) -> Vec<u8> {
2148    let mut out = Vec::with_capacity(data.len());
2149    let mut i = 0;
2150    while i < data.len() {
2151        if i + 4 <= data.len() && &data[i..i + 4] == b"$Id:" {
2152            let rest = &data[i + 4..];
2153            let line_end = rest.iter().position(|&b| b == b'\n').unwrap_or(rest.len());
2154            let line = &rest[..line_end];
2155            if let Some(end) = line.iter().position(|&b| b == b'$') {
2156                out.extend_from_slice(b"$Id$");
2157                i += 4 + end + 1;
2158                continue;
2159            }
2160        }
2161        out.push(data[i]);
2162        i += 1;
2163    }
2164    out
2165}
2166
2167/// Shell-quote `s` with single quotes, matching Git's `sq_quote_buf` (`'` → `'\''`).
2168fn sq_quote_buf(s: &str) -> String {
2169    let mut out = String::with_capacity(s.len() + 2);
2170    out.push('\'');
2171    for ch in s.chars() {
2172        if ch == '\'' {
2173            out.push_str("'\\''");
2174        } else {
2175            out.push(ch);
2176        }
2177    }
2178    out.push('\'');
2179    out
2180}
2181
2182/// Expand Git filter command placeholders: `%%` → `%`, `%f` → quoted repository-relative path.
2183fn expand_filter_command(cmd: &str, rel_path: &str) -> String {
2184    let mut out = String::with_capacity(cmd.len() + rel_path.len() + 8);
2185    let mut chars = cmd.chars().peekable();
2186    while let Some(c) = chars.next() {
2187        if c == '%' {
2188            match chars.peek() {
2189                Some('%') => {
2190                    chars.next();
2191                    out.push('%');
2192                }
2193                Some('f') => {
2194                    chars.next();
2195                    out.push_str(&sq_quote_buf(rel_path));
2196                }
2197                _ => out.push('%'),
2198            }
2199        } else {
2200            out.push(c);
2201        }
2202    }
2203    out
2204}
2205
2206/// Run a filter command, piping data through stdin→stdout.
2207pub fn run_filter(
2208    runner: &dyn CommandRunner,
2209    cmd: &str,
2210    data: &[u8],
2211    rel_path: &str,
2212) -> Result<Vec<u8>, FilterError> {
2213    run_filter_command(runner, cmd, data, rel_path, None).map_err(|e| match e {
2214        FilterRunError::Spawn { source, .. } | FilterRunError::Io(source) => {
2215            filter_detail(rel_path, source.to_string())
2216        }
2217        FilterRunError::Exit(status) => FilterError::Failed { status },
2218    })
2219}
2220
2221/// How a one-shot clean/smudge command failed.
2222#[derive(Debug)]
2223enum FilterRunError {
2224    /// `program` could not be spawned.
2225    Spawn {
2226        program: PathBuf,
2227        source: std::io::Error,
2228    },
2229    /// Piping data to or from the running command failed.
2230    Io(std::io::Error),
2231    /// The command exited with this non-zero status.
2232    Exit(i32),
2233}
2234
2235impl FilterRunError {
2236    /// The error for a failure of a `filter.<driver>.required` filter.
2237    fn into_required_error(self, driver: &str, rel_path: &str, phase: FilterPhase) -> FilterError {
2238        match self {
2239            Self::Spawn { program, source } => {
2240                filter_spawn_failed(driver, rel_path, phase, &program, &source)
2241            }
2242            Self::Io(_) | Self::Exit(_) => external_filter_failed(rel_path, driver, phase),
2243        }
2244    }
2245}
2246
2247fn filter_spawn_failed(
2248    driver: &str,
2249    rel_path: &str,
2250    phase: FilterPhase,
2251    program: &Path,
2252    source: &std::io::Error,
2253) -> FilterError {
2254    FilterError::ExternalFilterSpawnFailed {
2255        driver: driver.to_owned(),
2256        path: rel_path.to_owned(),
2257        phase,
2258        program: program.display().to_string(),
2259        kind: source.kind(),
2260        detail: source.to_string(),
2261    }
2262}
2263
2264fn run_filter_command(
2265    runner: &dyn CommandRunner,
2266    cmd: &str,
2267    data: &[u8],
2268    rel_path: &str,
2269    filter_state: Option<&FilterProcessState>,
2270) -> Result<Vec<u8>, FilterRunError> {
2271    let expanded = expand_filter_command(cmd, rel_path);
2272    let mut spec = CommandSpec::sh_dash_c_filter(expanded);
2273    if let Some(st) = filter_state {
2274        st.apply_subprocess_to_spec(&mut spec);
2275    }
2276    spec.stdin = CommandStdin::Pipe(data.to_vec());
2277    let output = runner
2278        .spawn(&spec)
2279        .map_err(|source| FilterRunError::Spawn {
2280            program: spec.program.clone(),
2281            source,
2282        })?
2283        .wait_with_output()
2284        .map_err(FilterRunError::Io)?;
2285    if !output.status.success {
2286        return Err(FilterRunError::Exit(output.status.code.unwrap_or(1)));
2287    }
2288    Ok(output.stdout)
2289}
2290
2291/// Start the long-running filter `proc_cmd` before a required filter uses it, so a spawn
2292/// failure is reported with its cause instead of as a generic filter failure.
2293fn start_required_process_filter(
2294    filter_state: &FilterProcessState,
2295    proc_cmd: &str,
2296    driver: &str,
2297    rel_path: &str,
2298    phase: FilterPhase,
2299) -> Result<(), FilterError> {
2300    if filter_state.process_filter_is_disabled(proc_cmd) {
2301        return Ok(());
2302    }
2303    match crate::filter_process::start_process_filter(filter_state, proc_cmd) {
2304        Err(crate::filter_process::ProcessFilterStartError::Spawn(source)) => Err(
2305            filter_spawn_failed(driver, rel_path, phase, Path::new("sh"), &source),
2306        ),
2307        // Handshake failures surface from the filter call itself, as before.
2308        Err(crate::filter_process::ProcessFilterStartError::Other(_)) | Ok(()) => Ok(()),
2309    }
2310}
2311
2312// Re-export AttrRule type is internal, but we expose the vec through load_gitattributes.
2313// The public API uses the opaque Vec from load_gitattributes + get_file_attrs.
2314
2315/// Opaque type alias for loaded gitattributes rules.
2316pub type GitAttributes = Vec<AttrRule>;
2317
2318#[cfg(test)]
2319mod tests {
2320    use super::*;
2321
2322    /// Attribute and config combinations covering every line-ending branch.
2323    fn conversion_matrix() -> Vec<(ConversionConfig, FileAttrs)> {
2324        let mut out = Vec::new();
2325        for autocrlf in [AutoCrlf::False, AutoCrlf::True, AutoCrlf::Input] {
2326            for eol in [CoreEol::Lf, CoreEol::Crlf, CoreEol::Native] {
2327                for text in [
2328                    TextAttr::Set,
2329                    TextAttr::Auto,
2330                    TextAttr::Unset,
2331                    TextAttr::Unspecified,
2332                ] {
2333                    for attr_eol in [EolAttr::Unspecified, EolAttr::Lf, EolAttr::Crlf] {
2334                        for crlf_legacy in [
2335                            CrlfLegacyAttr::Unspecified,
2336                            CrlfLegacyAttr::Crlf,
2337                            CrlfLegacyAttr::Input,
2338                            CrlfLegacyAttr::Unset,
2339                        ] {
2340                            let conv = ConversionConfig {
2341                                autocrlf,
2342                                eol,
2343                                safecrlf: SafeCrlf::False,
2344                                command_runner: system_command_runner(),
2345                            };
2346                            let attrs = FileAttrs {
2347                                text,
2348                                eol: attr_eol,
2349                                crlf_legacy,
2350                                ..FileAttrs::default()
2351                            };
2352                            out.push((conv, attrs));
2353                        }
2354                    }
2355                }
2356            }
2357        }
2358        out
2359    }
2360
2361    const SAMPLES: [&[u8]; 4] = [
2362        b"a\nb\n",
2363        b"a\r\nb\r\n",
2364        b"mixed\r\nends\n",
2365        b"bin\0\r\nary\n",
2366    ];
2367
2368    #[test]
2369    fn worktree_identity_implies_no_output_conversion_for_any_content() {
2370        let mut identities = 0;
2371        for (conv, attrs) in conversion_matrix() {
2372            if !worktree_conversion_is_identity(&conv, &attrs) {
2373                continue;
2374            }
2375            identities += 1;
2376            for data in SAMPLES {
2377                let out = convert_to_worktree(data, "f", &conv, &attrs, None, None, None, None)
2378                    .expect("convert")
2379                    .expect("not delayed");
2380                assert_eq!(out, data, "{conv:?} {attrs:?} changed {data:?}");
2381            }
2382        }
2383        assert!(identities > 0);
2384    }
2385
2386    #[test]
2387    fn git_identity_implies_no_input_conversion_for_any_content() {
2388        let mut identities = 0;
2389        for (conv, attrs) in conversion_matrix() {
2390            if !git_conversion_is_identity(&conv, &attrs) {
2391                continue;
2392            }
2393            identities += 1;
2394            for data in SAMPLES {
2395                let out = convert_to_git_with_opts(
2396                    data,
2397                    "f",
2398                    &conv,
2399                    &attrs,
2400                    ConvertToGitOpts {
2401                        index_blob: None,
2402                        renormalize: false,
2403                        check_safecrlf: false,
2404                        filter_process: None,
2405                        command_runner: None,
2406                    },
2407                )
2408                .expect("convert");
2409                assert_eq!(out, data, "{conv:?} {attrs:?} changed {data:?}");
2410            }
2411        }
2412        assert!(identities > 0);
2413    }
2414
2415    #[test]
2416    fn raw_byte_streaming_under_autocrlf_when_binary() {
2417        use std::io::{Seek, SeekFrom, Write};
2418
2419        let mut conv = ConversionConfig::from_config(&ConfigSet::new());
2420        conv.autocrlf = AutoCrlf::True;
2421        let attrs = FileAttrs::default();
2422        assert!(!git_conversion_is_identity(&conv, &attrs));
2423
2424        let nul_file = tempfile::NamedTempFile::new().expect("temp");
2425        nul_file.as_file().write_all(&[0u8; 9000]).expect("write");
2426        let mut with_nul = nul_file.reopen().expect("reopen");
2427        assert!(worktree_allows_raw_byte_stream(&conv, &attrs, None, &mut with_nul, 9000).unwrap());
2428
2429        let text_file = tempfile::NamedTempFile::new().expect("temp");
2430        text_file
2431            .as_file()
2432            .write_all(b"hello\r\nworld")
2433            .expect("write");
2434        let mut text = text_file.reopen().expect("reopen");
2435        assert!(!worktree_allows_raw_byte_stream(&conv, &attrs, None, &mut text, 12).unwrap());
2436
2437        let mut data = vec![1u8; 20_000];
2438        data[500] = 0;
2439        let huge_file = tempfile::NamedTempFile::new().expect("temp");
2440        huge_file.as_file().write_all(&data).expect("write");
2441        let mut huge = huge_file.reopen().expect("reopen");
2442        huge.seek(SeekFrom::Start(10_000)).expect("seek");
2443        assert!(worktree_allows_raw_byte_stream(&conv, &attrs, None, &mut huge, 20_000).unwrap());
2444
2445        use crate::index::{IndexEntry, MODE_REGULAR};
2446        use crate::objects::ObjectId;
2447        let indexed = IndexEntry {
2448            ctime_sec: 1,
2449            ctime_nsec: 0,
2450            mtime_sec: 1,
2451            mtime_nsec: 0,
2452            dev: 0,
2453            ino: 0,
2454            mode: MODE_REGULAR,
2455            uid: 0,
2456            gid: 0,
2457            size: 9000,
2458            oid: ObjectId::zero(),
2459            flags: 0,
2460            flags_extended: None,
2461            path: b"f.bin".to_vec(),
2462            base_index_pos: 0,
2463        };
2464        assert!(clean_uses_autocrlf_index_guard(&attrs, &conv));
2465        let mut again = nul_file.reopen().expect("reopen");
2466        assert!(
2467            worktree_allows_raw_byte_stream(&conv, &attrs, Some(&indexed), &mut again, 9000)
2468                .unwrap()
2469        );
2470    }
2471
2472    #[test]
2473    fn identity_checks_see_filters_ident_and_encoding() {
2474        let conv = ConversionConfig::from_config(&ConfigSet::new());
2475        assert!(worktree_conversion_is_identity(
2476            &conv,
2477            &FileAttrs::default()
2478        ));
2479        assert!(git_conversion_is_identity(&conv, &FileAttrs::default()));
2480        let with = |f: &dyn Fn(&mut FileAttrs)| {
2481            let mut attrs = FileAttrs::default();
2482            f(&mut attrs);
2483            attrs
2484        };
2485        for attrs in [
2486            with(&|a| a.ident = true),
2487            with(&|a| a.working_tree_encoding = Some("UTF-16".into())),
2488            with(&|a| a.filter_smudge = Some("cat".into())),
2489            with(&|a| a.filter_process = Some("proc".into())),
2490            with(&|a| a.filter_smudge_required = true),
2491        ] {
2492            assert!(!worktree_conversion_is_identity(&conv, &attrs), "{attrs:?}");
2493        }
2494        for attrs in [
2495            with(&|a| a.ident = true),
2496            with(&|a| a.working_tree_encoding = Some("UTF-16".into())),
2497            with(&|a| a.filter_clean = Some("cat".into())),
2498            with(&|a| a.filter_process = Some("proc".into())),
2499            with(&|a| a.filter_clean_required = true),
2500        ] {
2501            assert!(!git_conversion_is_identity(&conv, &attrs), "{attrs:?}");
2502        }
2503    }
2504
2505    #[test]
2506    fn collapse_ident_allows_cr_before_closing_dollar() {
2507        let data = b"$Id: abc\r$\n";
2508        assert_eq!(collapse_ident(data), b"$Id$\n");
2509    }
2510
2511    #[test]
2512    fn convert_to_git_collapses_ident_with_cr_before_closing_dollar() {
2513        let conv = ConversionConfig::from_config(&ConfigSet::new());
2514        let mut attrs = FileAttrs::default();
2515        attrs.ident = true;
2516        let data = b"$Id: abc\r$\n";
2517        let out = convert_to_git(data, "f", &conv, &attrs).expect("convert");
2518        assert_eq!(out, b"$Id$\n");
2519    }
2520
2521    #[test]
2522    fn convert_to_git_collapses_expanded_ident_to_keyword() {
2523        let conv = ConversionConfig::from_config(&ConfigSet::new());
2524        let mut attrs = FileAttrs::default();
2525        attrs.ident = true;
2526        let expanded = b"$Id: 10d2a9379e5d822c92a372599358dd0e616aadac $\nfoo\n";
2527        let out = convert_to_git(expanded, "id.txt", &conv, &attrs).expect("convert");
2528        assert_eq!(out, b"$Id$\nfoo\n");
2529    }
2530
2531    #[test]
2532    fn test_crlf_to_lf() {
2533        assert_eq!(crlf_to_lf(b"hello\r\nworld\r\n"), b"hello\nworld\n");
2534        assert_eq!(crlf_to_lf(b"hello\nworld\n"), b"hello\nworld\n");
2535        assert_eq!(crlf_to_lf(b"hello\r\n"), b"hello\n");
2536    }
2537
2538    #[test]
2539    fn test_lf_to_crlf() {
2540        assert_eq!(lf_to_crlf(b"hello\nworld\n"), b"hello\r\nworld\r\n");
2541        assert_eq!(lf_to_crlf(b"hello\r\nworld\r\n"), b"hello\r\nworld\r\n");
2542    }
2543
2544    #[test]
2545    fn test_has_crlf() {
2546        assert!(has_crlf(b"hello\r\nworld"));
2547        assert!(!has_crlf(b"hello\nworld"));
2548    }
2549
2550    #[test]
2551    fn smudge_mixed_line_endings_unchanged_with_autocrlf_true() {
2552        let mut blob = Vec::new();
2553        for part in [
2554            b"Oh\n".as_slice(),
2555            b"here\n",
2556            b"is\n",
2557            b"CRLF\r\n",
2558            b"in\n",
2559            b"text\n",
2560        ] {
2561            blob.extend_from_slice(part);
2562        }
2563        let conv = ConversionConfig {
2564            autocrlf: AutoCrlf::True,
2565            eol: CoreEol::Lf,
2566            safecrlf: SafeCrlf::False,
2567            command_runner: system_command_runner(),
2568        };
2569        let attrs = FileAttrs::default();
2570        let out =
2571            convert_to_worktree_eager(&blob, "mixed", &conv, &attrs, None, None, None).unwrap();
2572        assert_eq!(out, blob);
2573    }
2574
2575    #[test]
2576    fn smudge_lf_only_gets_crlf_with_autocrlf_true() {
2577        let blob = b"a\nb\n";
2578        let conv = ConversionConfig {
2579            autocrlf: AutoCrlf::True,
2580            eol: CoreEol::Lf,
2581            safecrlf: SafeCrlf::False,
2582            command_runner: system_command_runner(),
2583        };
2584        let attrs = FileAttrs::default();
2585        let out = convert_to_worktree_eager(blob, "x", &conv, &attrs, None, None, None).unwrap();
2586        assert_eq!(out, b"a\r\nb\r\n");
2587    }
2588
2589    #[test]
2590    fn test_is_binary() {
2591        assert!(is_binary(b"hello\0world"));
2592        assert!(!is_binary(b"hello world"));
2593    }
2594
2595    #[test]
2596    fn attr_dir_only_pattern_does_not_match_same_named_file() {
2597        let rules = parse_gitattributes_content("dir-only-pattern/ text\n");
2598        let rule = &rules[0];
2599        assert!(!attr_rule_matches(rule, "parent/dir-only-pattern", false));
2600        assert!(attr_rule_matches(rule, "dir-only-pattern", true));
2601    }
2602
2603    #[test]
2604    fn test_expand_collapse_ident() {
2605        let data = b"$Id$";
2606        let expanded = expand_ident(data, "abc123");
2607        assert_eq!(expanded, b"$Id: abc123 $");
2608        let collapsed = collapse_ident(&expanded);
2609        assert_eq!(collapsed, b"$Id$");
2610    }
2611
2612    #[test]
2613    fn expand_ident_does_not_span_lines_for_partial_keyword() {
2614        let data = b"$Id: NoTerminatingSymbol\n$Id: deadbeef $\n";
2615        let expanded = expand_ident(data, "newoid");
2616        assert_eq!(expanded, b"$Id: NoTerminatingSymbol\n$Id: newoid $\n");
2617    }
2618
2619    #[test]
2620    fn expand_ident_preserves_foreign_id_with_internal_spaces() {
2621        let data = b"$Id: Foreign Commit With Spaces $\n";
2622        let expanded = expand_ident(data, "abc");
2623        assert_eq!(expanded, data);
2624    }
2625
2626    #[test]
2627    fn expand_filter_command_percent_f_quotes_path() {
2628        let s = expand_filter_command("sh ./x.sh %f --extra", "name  with 'sq'");
2629        assert_eq!(s, "sh ./x.sh 'name  with '\\''sq'\\''' --extra");
2630        assert_eq!(expand_filter_command("a %% b", "p"), "a % b");
2631    }
2632}