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