1use 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
36pub enum WorkTreeEncodingError {
37 #[error("unsupported encoding '{label}'")]
39 UnsupportedEncoding {
40 label: String,
42 },
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
47pub enum ConversionError {
48 #[error(transparent)]
50 Encoding(WorkTreeEncodingError),
51 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum AutoCrlf {
92 True,
94 Input,
96 False,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum CoreEol {
103 Lf,
105 Crlf,
107 Native,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum SafeCrlf {
114 True,
116 Warn,
118 False,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum TextAttr {
125 Set,
127 Auto,
129 Unset,
131 Unspecified,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum EolAttr {
138 Lf,
140 Crlf,
142 Unspecified,
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
148pub enum CrlfLegacyAttr {
149 #[default]
150 Unspecified,
152 Unset,
154 Input,
156 Crlf,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum MergeAttr {
163 Unspecified,
165 Unset,
167 Driver(String),
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
173pub enum DiffAttr {
174 Unspecified,
176 Unset,
178 Set,
180 Driver(String),
182}
183
184#[derive(Debug, Clone)]
186pub struct FileAttrs {
187 pub text: TextAttr,
189 pub eol: EolAttr,
191 pub diff_attr: DiffAttr,
193 pub filter_clean: Option<String>,
195 pub filter_smudge: Option<String>,
197 pub filter_process: Option<String>,
199 pub filter_driver_name: Option<String>,
201 pub filter_smudge_required: bool,
203 pub filter_clean_required: bool,
205 pub ident: bool,
207 pub merge: MergeAttr,
209 pub conflict_marker_size: Option<String>,
211 pub working_tree_encoding: Option<String>,
213 pub crlf_legacy: CrlfLegacyAttr,
215 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 #[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#[derive(Clone)]
261pub struct ConversionConfig {
262 pub autocrlf: AutoCrlf,
264 pub eol: CoreEol,
266 pub safecrlf: SafeCrlf,
268 pub command_runner: Arc<dyn CommandRunner>,
270}
271
272impl ConversionConfig {
273 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 None => SafeCrlf::Warn,
302 };
303
304 ConversionConfig {
305 autocrlf,
306 eol,
307 safecrlf,
308 command_runner: system_command_runner(),
309 }
310 }
311
312 #[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#[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#[must_use]
390pub fn load_gitattributes(work_tree: &Path) -> Vec<AttrRule> {
391 load_gitattributes_parsed(work_tree).rules
392}
393
394#[must_use]
399pub fn parse_gitattributes_content(content: &str) -> Vec<AttrRule> {
400 parse_gitattributes_file_content(content, ".gitattributes").rules
401}
402
403#[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#[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#[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#[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#[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#[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#[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
650pub(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#[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
669pub fn is_binary(data: &[u8]) -> bool {
671 let check_len = data.len().min(8000);
672 data[..check_len].contains(&0)
673}
674
675const 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
741fn 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#[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
849pub fn has_crlf(data: &[u8]) -> bool {
851 data.windows(2).any(|w| w == b"\r\n")
852}
853
854pub 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
864fn 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
874fn 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
889pub fn is_all_crlf(data: &[u8]) -> bool {
891 has_crlf(data) && !has_lone_lf(data)
892}
893
894pub fn is_all_lf(data: &[u8]) -> bool {
896 has_lone_lf(data) && !has_crlf(data)
897}
898
899#[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#[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#[derive(Clone, Copy)]
927pub struct ConvertToGitOpts<'a> {
928 pub index_blob: Option<&'a [u8]>,
930 pub renormalize: bool,
932 pub check_safecrlf: bool,
934 pub filter_process: Option<&'a FilterProcessState>,
936 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
963const 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
973fn 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
1000fn 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
1013fn 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
1022fn 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 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
1066fn 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 if let Some(ref c) = canon {
1087 validate_utf_bom(c, label, rel_path, src, validate)?;
1088 }
1089
1090 if canon.as_deref() == Some("utf-8") {
1092 return Ok(src.to_vec());
1093 }
1094
1095 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 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 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
1143fn 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
1222fn 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
1235fn 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 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 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
1314fn 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
1346pub 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
1375pub 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
1386pub(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 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 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 if let Some(ref enc) = file_attrs.working_tree_encoding {
1463 if enc == "set" || enc == "true" || enc == "false" {
1466 return Err(FilterError::InvalidWorkingTreeEncoding);
1467 }
1468 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 Err(_) => {}
1476 }
1477 }
1478
1479 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 if would_convert && opts.check_safecrlf {
1493 check_safecrlf_roundtrip(conv, file_attrs, &buf, rel_path, convert_crlf_into_lf)?;
1494 }
1495
1496 if convert_crlf_into_lf {
1498 buf = crlf_to_lf(&buf);
1499 }
1500
1501 if file_attrs.ident && count_ident_regions(&buf) {
1503 buf = collapse_ident(&buf);
1504 }
1505
1506 Ok(buf)
1507}
1508
1509#[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
1531pub(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
1587fn 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#[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 attrs.text == TextAttr::Unset {
1621 return InputEolRule::Never;
1622 }
1623
1624 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 match conv.autocrlf {
1640 AutoCrlf::True | AutoCrlf::Input => InputEolRule::UnlessBinary,
1641 AutoCrlf::False => InputEolRule::Never,
1642 }
1643}
1644
1645fn eprint_safecrlf_warn_crlf_to_lf(_rel_path: &str) {}
1647
1648fn eprint_safecrlf_warn_lf_to_crlf(_rel_path: &str) {}
1650
1651fn 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
1696pub 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
1712pub 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#[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#[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 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 let should_convert = should_convert_to_crlf(conv, file_attrs, &buf);
1812 if should_convert {
1813 buf = lf_to_crlf(&buf);
1814 }
1815
1816 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 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
1898pub 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#[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
1947fn 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#[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 return true;
1983 }
1984 CrlfLegacyAttr::Unspecified => {}
1985 }
1986
1987 if attrs.text == TextAttr::Unset {
1989 return false;
1990 }
1991
1992 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 if attrs.text == TextAttr::Auto {
2002 return auto_crlf_should_smudge_lf_to_crlf(data);
2003 }
2004 return true;
2006 }
2007
2008 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 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
2035fn output_eol_is_crlf(conv: &ConversionConfig) -> bool {
2037 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 cfg!(windows)
2050 }
2051 }
2052}
2053
2054fn 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 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
2128fn 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 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
2169pub 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
2190fn 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
2205fn 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
2229pub 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#[derive(Debug)]
2246enum FilterRunError {
2247 Spawn {
2249 program: PathBuf,
2250 source: std::io::Error,
2251 },
2252 Io(std::io::Error),
2254 Exit(i32),
2256}
2257
2258impl FilterRunError {
2259 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
2314fn 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 Err(crate::filter_process::ProcessFilterStartError::Other(_)) | Ok(()) => Ok(()),
2332 }
2333}
2334
2335pub type GitAttributes = Vec<AttrRule>;
2340
2341#[cfg(test)]
2342mod tests {
2343 use super::*;
2344
2345 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}