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