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