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
630pub(crate) fn path_gitattribute_values(
633 rules: &[AttrRule],
634 macros: &MacroTable,
635 icase: bool,
636 path: &str,
637 is_dir: bool,
638) -> std::collections::HashMap<String, crate::attributes::AttrValue> {
639 collect_attrs_for_path(rules, macros, &attr_path_for_match(path, is_dir), icase)
640}
641
642#[must_use]
644pub fn attr_rule_matches(rule: &AttrRule, rel_path: &str, is_dir: bool) -> bool {
645 let match_path = attr_path_for_match(rel_path, is_dir);
646 git_attr_rule_matches(rule, &match_path, false)
647}
648
649pub fn is_binary(data: &[u8]) -> bool {
651 let check_len = data.len().min(8000);
652 data[..check_len].contains(&0)
653}
654
655const CONVERT_STAT_BITS_TXT_LF: u32 = 0x1;
657const CONVERT_STAT_BITS_TXT_CRLF: u32 = 0x2;
658const CONVERT_STAT_BITS_BIN: u32 = 0x4;
659
660#[derive(Default, Clone)]
661struct TextStat {
662 nul: u32,
663 lonecr: u32,
664 lonelf: u32,
665 crlf: u32,
666 printable: u32,
667 nonprintable: u32,
668}
669
670fn gather_text_stat(data: &[u8]) -> TextStat {
671 let mut s = TextStat::default();
672 let mut i = 0usize;
673 while i < data.len() {
674 let c = data[i];
675 if c == b'\r' {
676 if i + 1 < data.len() && data[i + 1] == b'\n' {
677 s.crlf += 1;
678 i += 2;
679 } else {
680 s.lonecr += 1;
681 i += 1;
682 }
683 continue;
684 }
685 if c == b'\n' {
686 s.lonelf += 1;
687 i += 1;
688 continue;
689 }
690 if c == 127 {
691 s.nonprintable += 1;
692 } else if c < 32 {
693 match c {
694 b'\t' | b'\x08' | b'\x1b' | b'\x0c' => s.printable += 1,
695 0 => {
696 s.nul += 1;
697 s.nonprintable += 1;
698 }
699 _ => s.nonprintable += 1,
700 }
701 } else {
702 s.printable += 1;
703 }
704 i += 1;
705 }
706 s
707}
708
709fn convert_is_binary(stats: &TextStat) -> bool {
710 stats.lonecr > 0 || stats.nul > 0 || (stats.printable >> 7) < stats.nonprintable
711}
712
713fn git_text_stat(data: &[u8]) -> TextStat {
714 let mut stats = gather_text_stat(data);
715 if !data.is_empty() && data[data.len() - 1] == 0x1a {
716 stats.nonprintable = stats.nonprintable.saturating_sub(1);
717 }
718 stats
719}
720
721fn will_convert_lf_to_crlf_from_stats(
723 stats: &TextStat,
724 conv: &ConversionConfig,
725 attrs: &FileAttrs,
726) -> bool {
727 let has_lone_lf = stats.lonelf > 0;
728 let is_bin = convert_is_binary(stats);
729
730 match attrs.crlf_legacy {
731 CrlfLegacyAttr::Unset | CrlfLegacyAttr::Input => return false,
732 CrlfLegacyAttr::Crlf => {
733 if attrs.text == TextAttr::Unset {
734 return false;
735 }
736 return has_lone_lf;
737 }
738 CrlfLegacyAttr::Unspecified => {}
739 }
740
741 if attrs.text == TextAttr::Unset {
742 return false;
743 }
744
745 if attrs.eol != EolAttr::Unspecified {
746 if attrs.text == TextAttr::Auto && is_bin {
747 return false;
748 }
749 if attrs.eol != EolAttr::Crlf {
750 return false;
751 }
752 if attrs.text == TextAttr::Auto {
753 return auto_crlf_should_smudge_lf_to_crlf_from_stats(stats);
754 }
755 return has_lone_lf;
756 }
757
758 if attrs.text == TextAttr::Set {
759 if !output_eol_is_crlf(conv) {
760 return false;
761 }
762 return has_lone_lf;
763 }
764
765 if attrs.text == TextAttr::Auto {
766 if is_bin || !output_eol_is_crlf(conv) {
767 return false;
768 }
769 return auto_crlf_should_smudge_lf_to_crlf_from_stats(stats);
770 }
771
772 match conv.autocrlf {
773 AutoCrlf::True => {
774 if is_bin {
775 return false;
776 }
777 auto_crlf_should_smudge_lf_to_crlf_from_stats(stats)
778 }
779 AutoCrlf::Input | AutoCrlf::False => false,
780 }
781}
782
783fn auto_crlf_should_smudge_lf_to_crlf_from_stats(stats: &TextStat) -> bool {
784 if stats.lonelf == 0 {
785 return false;
786 }
787 if stats.lonecr > 0 || stats.crlf > 0 {
788 return false;
789 }
790 !convert_is_binary(stats)
791}
792
793fn gather_convert_stats(data: &[u8]) -> u32 {
794 if data.is_empty() {
795 return 0;
796 }
797 let mut stats = gather_text_stat(data);
798 if !data.is_empty() && data[data.len() - 1] == 0x1a {
799 stats.nonprintable = stats.nonprintable.saturating_sub(1);
800 }
801 let mut ret = 0u32;
802 if convert_is_binary(&stats) {
803 ret |= CONVERT_STAT_BITS_BIN;
804 }
805 if stats.crlf > 0 {
806 ret |= CONVERT_STAT_BITS_TXT_CRLF;
807 }
808 if stats.lonelf > 0 {
809 ret |= CONVERT_STAT_BITS_TXT_LF;
810 }
811 ret
812}
813
814#[must_use]
816pub fn gather_convert_stats_ascii(data: &[u8]) -> &'static str {
817 let convert_stats = gather_convert_stats(data);
818 if convert_stats & CONVERT_STAT_BITS_BIN != 0 {
819 return "-text";
820 }
821 match convert_stats {
822 CONVERT_STAT_BITS_TXT_LF => "lf",
823 CONVERT_STAT_BITS_TXT_CRLF => "crlf",
824 x if x == (CONVERT_STAT_BITS_TXT_LF | CONVERT_STAT_BITS_TXT_CRLF) => "mixed",
825 _ => "none",
826 }
827}
828
829pub fn has_crlf(data: &[u8]) -> bool {
831 data.windows(2).any(|w| w == b"\r\n")
832}
833
834pub fn has_lone_lf(data: &[u8]) -> bool {
836 for i in 0..data.len() {
837 if data[i] == b'\n' && (i == 0 || data[i - 1] != b'\r') {
838 return true;
839 }
840 }
841 false
842}
843
844fn has_lone_cr(data: &[u8]) -> bool {
846 for i in 0..data.len() {
847 if data[i] == b'\r' && (i + 1 >= data.len() || data[i + 1] != b'\n') {
848 return true;
849 }
850 }
851 false
852}
853
854fn auto_crlf_should_smudge_lf_to_crlf(data: &[u8]) -> bool {
857 if !has_lone_lf(data) {
858 return false;
859 }
860 if has_lone_cr(data) || has_crlf(data) {
861 return false;
862 }
863 if is_binary(data) {
864 return false;
865 }
866 true
867}
868
869pub fn is_all_crlf(data: &[u8]) -> bool {
871 has_crlf(data) && !has_lone_lf(data)
872}
873
874pub fn is_all_lf(data: &[u8]) -> bool {
876 has_lone_lf(data) && !has_crlf(data)
877}
878
879#[must_use]
881pub fn has_crlf_in_index_blob(data: &[u8]) -> bool {
882 if !data.contains(&b'\r') {
883 return false;
884 }
885 let st = gather_convert_stats(data);
886 st & CONVERT_STAT_BITS_BIN == 0 && (st & CONVERT_STAT_BITS_TXT_CRLF) != 0
887}
888
889#[must_use]
893pub fn clean_uses_autocrlf_index_guard(attrs: &FileAttrs, conv: &ConversionConfig) -> bool {
894 if attrs.text == TextAttr::Unset || attrs.crlf_legacy == CrlfLegacyAttr::Unset {
895 return false;
896 }
897 if attrs.eol != EolAttr::Unspecified && attrs.text != TextAttr::Auto {
898 return false;
899 }
900 attrs.text == TextAttr::Auto
901 || (attrs.text == TextAttr::Unspecified
902 && matches!(conv.autocrlf, AutoCrlf::True | AutoCrlf::Input))
903}
904
905#[derive(Clone, Copy)]
907pub struct ConvertToGitOpts<'a> {
908 pub index_blob: Option<&'a [u8]>,
910 pub renormalize: bool,
912 pub check_safecrlf: bool,
914 pub filter_process: Option<&'a FilterProcessState>,
916 pub command_runner: Option<&'a dyn CommandRunner>,
918}
919
920impl Default for ConvertToGitOpts<'_> {
921 fn default() -> Self {
922 Self {
923 index_blob: None,
924 renormalize: false,
925 check_safecrlf: true,
926 filter_process: None,
927 command_runner: None,
928 }
929 }
930}
931
932impl std::fmt::Debug for ConvertToGitOpts<'_> {
933 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
934 f.debug_struct("ConvertToGitOpts")
935 .field("index_blob", &self.index_blob.map(|b| b.len()))
936 .field("renormalize", &self.renormalize)
937 .field("check_safecrlf", &self.check_safecrlf)
938 .field("command_runner", &self.command_runner.is_some())
939 .finish()
940 }
941}
942
943const UTF16_BE_BOM: &[u8] = &[0xFE, 0xFF];
949const UTF16_LE_BOM: &[u8] = &[0xFF, 0xFE];
950const UTF32_BE_BOM: &[u8] = &[0x00, 0x00, 0xFE, 0xFF];
951const UTF32_LE_BOM: &[u8] = &[0xFF, 0xFE, 0x00, 0x00];
952
953fn canonical_utf_label(label: &str) -> Option<String> {
958 let trimmed = label.trim();
959 let lower = trimmed.to_ascii_lowercase();
960 let rest = lower.strip_prefix("utf")?;
961 let rest = rest.strip_prefix('-').unwrap_or(rest);
962 match rest {
963 "8" => Some("utf-8".to_string()),
964 "16" => Some("utf-16".to_string()),
965 "16be" => Some("utf-16be".to_string()),
966 "16le" => Some("utf-16le".to_string()),
967 "16be-bom" => Some("utf-16be-bom".to_string()),
968 "16le-bom" => Some("utf-16le-bom".to_string()),
969 "32" => Some("utf-32".to_string()),
970 "32be" => Some("utf-32be".to_string()),
971 "32le" => Some("utf-32le".to_string()),
972 _ => None,
973 }
974}
975
976fn has_bom_prefix(data: &[u8], bom: &[u8]) -> bool {
977 data.len() >= bom.len() && &data[..bom.len()] == bom
978}
979
980fn has_prohibited_utf_bom(canon: &str, data: &[u8]) -> bool {
982 match canon {
983 "utf-16be" | "utf-16le" => {
984 has_bom_prefix(data, UTF16_BE_BOM) || has_bom_prefix(data, UTF16_LE_BOM)
985 }
986 "utf-32be" | "utf-32le" => {
987 has_bom_prefix(data, UTF32_BE_BOM) || has_bom_prefix(data, UTF32_LE_BOM)
988 }
989 _ => false,
990 }
991}
992
993fn is_missing_required_utf_bom(canon: &str, data: &[u8]) -> bool {
995 match canon {
996 "utf-16" => !(has_bom_prefix(data, UTF16_BE_BOM) || has_bom_prefix(data, UTF16_LE_BOM)),
997 "utf-32" => !(has_bom_prefix(data, UTF32_BE_BOM) || has_bom_prefix(data, UTF32_LE_BOM)),
998 _ => false,
999 }
1000}
1001
1002fn validate_utf_bom(
1010 canon: &str,
1011 label: &str,
1012 rel_path: &str,
1013 data: &[u8],
1014 die_on_error: bool,
1015) -> Result<(), String> {
1016 if has_prohibited_utf_bom(canon, data) {
1017 let stripped = label
1019 .strip_prefix("utf")
1020 .or_else(|| label.strip_prefix("UTF"));
1021 let _utf_num = stripped
1022 .map(|s| s.trim_start_matches('-'))
1023 .and_then(|s| s.get(..s.len().saturating_sub(2)))
1024 .unwrap_or("");
1025 let body = format!("BOM is prohibited in '{rel_path}' if encoded as {label}");
1026 if die_on_error {
1027 return Err(body);
1028 }
1029 return Err(body);
1030 }
1031 if is_missing_required_utf_bom(canon, data) {
1032 let _utf_num = label
1033 .strip_prefix("utf")
1034 .or_else(|| label.strip_prefix("UTF"))
1035 .map(|s| s.trim_start_matches('-'))
1036 .unwrap_or("");
1037 let body = format!("BOM is required in '{rel_path}' if encoded as {label}");
1038 if die_on_error {
1039 return Err(body);
1040 }
1041 return Err(body);
1042 }
1043 Ok(())
1044}
1045
1046fn decode_working_tree_bytes_to_utf8(
1051 src: &[u8],
1052 rel_path: &str,
1053 enc_label: &str,
1054 validate: bool,
1055) -> Result<Vec<u8>, ConversionError> {
1056 let label = enc_label.trim();
1057 if label.is_empty() {
1058 return Ok(src.to_vec());
1059 }
1060
1061 let canon = canonical_utf_label(label);
1062
1063 if let Some(ref c) = canon {
1067 validate_utf_bom(c, label, rel_path, src, validate)?;
1068 }
1069
1070 if canon.as_deref() == Some("utf-8") {
1072 return Ok(src.to_vec());
1073 }
1074
1075 let (iconv_from, body): (&str, &[u8]) = match canon.as_deref() {
1077 Some("utf-16le-bom") => {
1078 let body = if has_bom_prefix(src, UTF16_LE_BOM) {
1079 &src[2..]
1080 } else {
1081 src
1082 };
1083 ("UTF-16LE", body)
1084 }
1085 Some("utf-16be-bom") => {
1086 let body = if has_bom_prefix(src, UTF16_BE_BOM) {
1087 &src[2..]
1088 } else {
1089 src
1090 };
1091 ("UTF-16BE", body)
1092 }
1093 Some(c) => (utf_canon_to_iconv_name(c), src),
1095 None => {
1096 if crate::commit_encoding::is_iso_8859_1(label) {
1097 return Ok(crate::commit_encoding::worktree_latin1_to_utf8_bytes(src));
1098 }
1099 let Some(enc) = crate::commit_encoding::resolve(label) else {
1102 return Err(WorkTreeEncodingError::UnsupportedEncoding {
1103 label: label.to_owned(),
1104 }
1105 .into());
1106 };
1107 if enc == UTF_8 {
1108 return Ok(src.to_vec());
1109 }
1110 let (cow, _, had_errors) = enc.decode(src);
1111 if had_errors {
1112 return Err(ConversionError::Message(format!(
1113 "failed to encode '{rel_path}' from {label} to UTF-8"
1114 )));
1115 }
1116 return Ok(cow.into_owned().into_bytes());
1117 }
1118 };
1119
1120 decode_utf_bytes_with_encoding_rs(body, rel_path, label, iconv_from)
1121}
1122
1123fn decode_utf_bytes_with_encoding_rs(
1125 body: &[u8],
1126 rel_path: &str,
1127 label: &str,
1128 iconv_from: &str,
1129) -> Result<Vec<u8>, ConversionError> {
1130 let fail = || {
1131 ConversionError::Message(format!(
1132 "failed to encode '{rel_path}' from {label} to UTF-8"
1133 ))
1134 };
1135 match iconv_from {
1136 "UTF-16BE" => {
1137 let (cow, _, had_errors) = encoding_rs::UTF_16BE.decode(body);
1138 if had_errors {
1139 return Err(fail());
1140 }
1141 Ok(cow.into_owned().into_bytes())
1142 }
1143 "UTF-16LE" => {
1144 let (cow, _, had_errors) = encoding_rs::UTF_16LE.decode(body);
1145 if had_errors {
1146 return Err(fail());
1147 }
1148 Ok(cow.into_owned().into_bytes())
1149 }
1150 "UTF-16" => {
1151 if has_bom_prefix(body, UTF16_BE_BOM) {
1152 decode_utf_bytes_with_encoding_rs(&body[2..], rel_path, label, "UTF-16BE")
1153 } else if has_bom_prefix(body, UTF16_LE_BOM) {
1154 decode_utf_bytes_with_encoding_rs(&body[2..], rel_path, label, "UTF-16LE")
1155 } else {
1156 Err(fail())
1157 }
1158 }
1159 "UTF-32" => {
1160 if has_bom_prefix(body, UTF32_BE_BOM) {
1161 decode_utf32_body_to_utf8_bytes(&body[4..], rel_path, true)
1162 } else if has_bom_prefix(body, UTF32_LE_BOM) {
1163 decode_utf32_body_to_utf8_bytes(&body[4..], rel_path, false)
1164 } else {
1165 Err(fail())
1166 }
1167 }
1168 "UTF-32BE" => decode_utf32_body_to_utf8_bytes(body, rel_path, true),
1169 "UTF-32LE" => decode_utf32_body_to_utf8_bytes(body, rel_path, false),
1170 _ => Err(fail()),
1171 }
1172}
1173
1174fn decode_utf32_body_to_utf8_bytes(
1175 body: &[u8],
1176 rel_path: &str,
1177 big_endian: bool,
1178) -> Result<Vec<u8>, ConversionError> {
1179 let fail = || {
1180 ConversionError::Message(format!(
1181 "failed to encode '{rel_path}' from UTF-32 to UTF-8"
1182 ))
1183 };
1184 if !body.len().is_multiple_of(4) {
1185 return Err(fail());
1186 }
1187 let mut s = String::new();
1188 for chunk in body.as_chunks::<4>().0 {
1189 let cp = if big_endian {
1190 u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
1191 } else {
1192 u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
1193 };
1194 let Some(ch) = char::from_u32(cp) else {
1195 return Err(fail());
1196 };
1197 s.push(ch);
1198 }
1199 Ok(s.into_bytes())
1200}
1201
1202fn utf_canon_to_iconv_name(canon: &str) -> &'static str {
1204 match canon {
1205 "utf-16" => "UTF-16",
1206 "utf-16be" => "UTF-16BE",
1207 "utf-16le" => "UTF-16LE",
1208 "utf-32" => "UTF-32",
1209 "utf-32be" => "UTF-32BE",
1210 "utf-32le" => "UTF-32LE",
1211 _ => "UTF-8",
1212 }
1213}
1214
1215fn encode_utf8_blob_to_working_tree_bytes(
1220 src: &[u8],
1221 rel_path: &str,
1222 enc_label: &str,
1223) -> Result<Vec<u8>, ConversionError> {
1224 let label = enc_label.trim();
1225 if label.is_empty() {
1226 return Ok(src.to_vec());
1227 }
1228
1229 let canon = canonical_utf_label(label);
1230 if canon.as_deref() == Some("utf-8") {
1231 return Ok(src.to_vec());
1232 }
1233
1234 let fail = || {
1235 ConversionError::Message(format!(
1236 "failed to encode '{rel_path}' from UTF-8 to {label}"
1237 ))
1238 };
1239
1240 match canon.as_deref() {
1242 Some("utf-16le-bom") => {
1243 let body = encode_utf_with_encoding_rs(src, "utf-16le").ok_or_else(fail)?;
1244 let mut out = UTF16_LE_BOM.to_vec();
1245 out.extend(body);
1246 return Ok(out);
1247 }
1248 Some("utf-16be-bom") => {
1249 let body = encode_utf_with_encoding_rs(src, "utf-16be").ok_or_else(fail)?;
1250 let mut out = UTF16_BE_BOM.to_vec();
1251 out.extend(body);
1252 return Ok(out);
1253 }
1254 Some("utf-16") => {
1256 let body = encode_utf_with_encoding_rs(src, "utf-16le").ok_or_else(fail)?;
1257 let mut out = UTF16_LE_BOM.to_vec();
1258 out.extend(body);
1259 return Ok(out);
1260 }
1261 Some("utf-32") => {
1262 let body = encode_utf_with_encoding_rs(src, "utf-32le").ok_or_else(fail)?;
1263 let mut out = UTF32_LE_BOM.to_vec();
1264 out.extend(body);
1265 return Ok(out);
1266 }
1267 Some(c) => {
1268 return encode_utf_with_encoding_rs(src, c).ok_or_else(fail);
1269 }
1270 None => {}
1271 }
1272
1273 if crate::commit_encoding::is_iso_8859_1(label) {
1274 return crate::commit_encoding::utf8_bytes_to_worktree_latin1(src).ok_or_else(fail);
1275 }
1276
1277 let s = std::str::from_utf8(src).map_err(|_| fail())?;
1278 let Some(enc) = crate::commit_encoding::resolve(label) else {
1279 return Err(WorkTreeEncodingError::UnsupportedEncoding {
1280 label: label.to_owned(),
1281 }
1282 .into());
1283 };
1284 if enc == UTF_8 {
1285 return Ok(src.to_vec());
1286 }
1287 let (cow, _, had_errors) = enc.encode(s);
1288 if had_errors {
1289 return Err(fail());
1290 }
1291 Ok(cow.into_owned())
1292}
1293
1294fn encode_utf_with_encoding_rs(src: &[u8], target: &str) -> Option<Vec<u8>> {
1297 let s = std::str::from_utf8(src).ok()?;
1298 let lower = target.to_ascii_lowercase();
1299 let mut out = Vec::new();
1300 match lower.as_str() {
1301 "utf-16" | "utf-16be" => {
1302 for u in s.encode_utf16() {
1303 out.extend_from_slice(&u.to_be_bytes());
1304 }
1305 }
1306 "utf-16le" => {
1307 for u in s.encode_utf16() {
1308 out.extend_from_slice(&u.to_le_bytes());
1309 }
1310 }
1311 "utf-32" | "utf-32be" => {
1312 for ch in s.chars() {
1313 out.extend_from_slice(&(ch as u32).to_be_bytes());
1314 }
1315 }
1316 "utf-32le" => {
1317 for ch in s.chars() {
1318 out.extend_from_slice(&(ch as u32).to_le_bytes());
1319 }
1320 }
1321 _ => return None,
1322 }
1323 Some(out)
1324}
1325
1326pub fn convert_to_git(
1341 data: &[u8],
1342 rel_path: &str,
1343 conv: &ConversionConfig,
1344 file_attrs: &FileAttrs,
1345) -> Result<Vec<u8>, FilterError> {
1346 convert_to_git_with_opts(
1347 data,
1348 rel_path,
1349 conv,
1350 file_attrs,
1351 ConvertToGitOpts::default(),
1352 )
1353}
1354
1355pub fn convert_to_git_with_opts(
1357 data: &[u8],
1358 rel_path: &str,
1359 conv: &ConversionConfig,
1360 file_attrs: &FileAttrs,
1361 opts: ConvertToGitOpts<'_>,
1362) -> Result<Vec<u8>, FilterError> {
1363 convert_to_git_owned(data.to_vec(), rel_path, conv, file_attrs, opts)
1364}
1365
1366pub(crate) fn convert_to_git_owned(
1369 data: Vec<u8>,
1370 rel_path: &str,
1371 conv: &ConversionConfig,
1372 file_attrs: &FileAttrs,
1373 opts: ConvertToGitOpts<'_>,
1374) -> Result<Vec<u8>, FilterError> {
1375 let mut buf = data;
1376 let owned_filter_state;
1377 let filter_state = if let Some(s) = opts.filter_process {
1378 s
1379 } else {
1380 owned_filter_state = FilterProcessState::new(Arc::clone(&conv.command_runner));
1381 &owned_filter_state
1382 };
1383 let runner = opts.command_runner.unwrap_or(conv.command_runner.as_ref());
1384
1385 if let Some(ref proc_cmd) = file_attrs.filter_process {
1387 let name = file_attrs.filter_driver_name.as_deref().unwrap_or_default();
1388 if file_attrs.filter_clean_required {
1389 start_required_process_filter(
1390 filter_state,
1391 proc_cmd,
1392 name,
1393 rel_path,
1394 FilterPhase::Clean,
1395 )?;
1396 }
1397 match apply_process_clean(filter_state, proc_cmd, rel_path, &buf) {
1398 Ok(filtered) => buf = filtered,
1399 Err(e) => {
1400 if file_attrs.filter_clean_required {
1401 if e.contains("expected git-filter-server") {
1402 return Err(filter_detail(rel_path, e));
1403 }
1404 return Err(FilterError::ExternalFilterFailed {
1405 driver: name.to_owned(),
1406 path: rel_path.to_owned(),
1407 phase: FilterPhase::Clean,
1408 });
1409 }
1410 if e.starts_with("filter status: abort") {
1411 filter_state.disable_process_filter(proc_cmd);
1412 }
1413 }
1414 }
1415 } else {
1416 match file_attrs.filter_clean.as_ref() {
1417 Some(clean_cmd) => {
1418 match run_filter_command(runner, clean_cmd, &buf, rel_path, Some(filter_state)) {
1419 Ok(filtered) => buf = filtered,
1420 Err(e) if file_attrs.filter_clean_required => {
1421 let name = file_attrs.filter_driver_name.as_deref().unwrap_or_default();
1422 return Err(e.into_required_error(name, rel_path, FilterPhase::Clean));
1423 }
1424 Err(_) => {}
1426 }
1427 }
1428 None => {
1429 if file_attrs.filter_clean_required {
1430 let name = file_attrs.filter_driver_name.as_deref().unwrap_or_default();
1431 return Err(FilterError::ExternalFilterFailed {
1432 driver: name.to_owned(),
1433 path: rel_path.to_owned(),
1434 phase: FilterPhase::Clean,
1435 });
1436 }
1437 }
1438 }
1439 }
1440
1441 if let Some(ref enc) = file_attrs.working_tree_encoding {
1443 if enc == "set" || enc == "true" || enc == "false" {
1446 return Err(FilterError::InvalidWorkingTreeEncoding);
1447 }
1448 let writing_object = opts.check_safecrlf;
1450 match decode_working_tree_bytes_to_utf8(&buf, rel_path, enc, writing_object) {
1451 Ok(decoded) => buf = decoded,
1452 Err(e) if writing_object => return Err(filter_detail(rel_path, e.to_string())),
1453 Err(_) => {}
1456 }
1457 }
1458
1459 let would_convert = would_convert_on_input(conv, file_attrs, &buf);
1461
1462 let mut convert_crlf_into_lf = would_convert && has_crlf(&buf);
1463 if convert_crlf_into_lf
1464 && clean_uses_autocrlf_index_guard(file_attrs, conv)
1465 && !opts.renormalize
1466 && opts.index_blob.is_some_and(has_crlf_in_index_blob)
1467 {
1468 convert_crlf_into_lf = false;
1469 }
1470
1471 if would_convert && opts.check_safecrlf {
1473 check_safecrlf_roundtrip(conv, file_attrs, &buf, rel_path, convert_crlf_into_lf)?;
1474 }
1475
1476 if convert_crlf_into_lf {
1478 buf = crlf_to_lf(&buf);
1479 }
1480
1481 if file_attrs.ident && count_ident_regions(&buf) {
1483 buf = collapse_ident(&buf);
1484 }
1485
1486 Ok(buf)
1487}
1488
1489#[must_use]
1502pub fn git_conversion_is_identity(conv: &ConversionConfig, attrs: &FileAttrs) -> bool {
1503 attrs.filter_process.is_none()
1504 && attrs.filter_clean.is_none()
1505 && !attrs.filter_clean_required
1506 && attrs.working_tree_encoding.is_none()
1507 && !attrs.ident
1508 && input_eol_rule(conv, attrs) == InputEolRule::Never
1509}
1510
1511pub(crate) fn worktree_allows_raw_byte_stream(
1526 conv: &ConversionConfig,
1527 attrs: &FileAttrs,
1528 index_entry: Option<&crate::index::IndexEntry>,
1529 file: &mut std::fs::File,
1530 size: u64,
1531) -> crate::error::Result<bool> {
1532 use std::io::{Read, Seek, SeekFrom};
1533
1534 if attrs.filter_process.is_some()
1535 || attrs.filter_clean.is_some()
1536 || attrs.filter_clean_required
1537 || attrs.working_tree_encoding.is_some()
1538 || attrs.ident
1539 {
1540 return Ok(false);
1541 }
1542 match input_eol_rule(conv, attrs) {
1543 InputEolRule::Never => Ok(true),
1544 InputEolRule::Always => Ok(false),
1545 InputEolRule::UnlessBinary => {
1546 let probe_len = usize::try_from(size).unwrap_or(8000).min(8000);
1547 if probe_len == 0 {
1548 return Ok(false);
1549 }
1550 file.seek(SeekFrom::Start(0))
1551 .map_err(crate::error::Error::Io)?;
1552 let mut buf = vec![0u8; probe_len];
1553 file.read_exact(&mut buf).map_err(crate::error::Error::Io)?;
1554 file.seek(SeekFrom::Start(0))
1555 .map_err(crate::error::Error::Io)?;
1556 if is_binary(&buf) {
1557 return Ok(true);
1558 }
1559 if index_entry.is_some() && clean_uses_autocrlf_index_guard(attrs, conv) {
1560 return Ok(false);
1561 }
1562 Ok(false)
1563 }
1564 }
1565}
1566
1567fn would_convert_on_input(conv: &ConversionConfig, attrs: &FileAttrs, data: &[u8]) -> bool {
1571 match input_eol_rule(conv, attrs) {
1572 InputEolRule::Never => false,
1573 InputEolRule::Always => true,
1574 InputEolRule::UnlessBinary => !is_binary(data),
1575 }
1576}
1577
1578#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1580enum InputEolRule {
1581 Never,
1582 Always,
1583 UnlessBinary,
1584}
1585
1586fn input_eol_rule(conv: &ConversionConfig, attrs: &FileAttrs) -> InputEolRule {
1587 match attrs.crlf_legacy {
1588 CrlfLegacyAttr::Unset => return InputEolRule::Never,
1589 CrlfLegacyAttr::Input => return InputEolRule::UnlessBinary,
1590 CrlfLegacyAttr::Crlf => {
1591 if attrs.text == TextAttr::Unset {
1592 return InputEolRule::Never;
1593 }
1594 return InputEolRule::UnlessBinary;
1595 }
1596 CrlfLegacyAttr::Unspecified => {}
1597 }
1598
1599 if attrs.text == TextAttr::Unset {
1601 return InputEolRule::Never;
1602 }
1603
1604 if attrs.eol != EolAttr::Unspecified {
1606 if attrs.text == TextAttr::Auto {
1607 return InputEolRule::UnlessBinary;
1608 }
1609 return InputEolRule::Always;
1610 }
1611
1612 match attrs.text {
1613 TextAttr::Set => return InputEolRule::Always,
1614 TextAttr::Auto => return InputEolRule::UnlessBinary,
1615 _ => {}
1616 }
1617
1618 match conv.autocrlf {
1620 AutoCrlf::True | AutoCrlf::Input => InputEolRule::UnlessBinary,
1621 AutoCrlf::False => InputEolRule::Never,
1622 }
1623}
1624
1625fn eprint_safecrlf_warn_crlf_to_lf(_rel_path: &str) {}
1627
1628fn eprint_safecrlf_warn_lf_to_crlf(_rel_path: &str) {}
1630
1631fn check_safecrlf_roundtrip(
1633 conv: &ConversionConfig,
1634 file_attrs: &FileAttrs,
1635 data: &[u8],
1636 rel_path: &str,
1637 convert_crlf_into_lf: bool,
1638) -> Result<(), FilterError> {
1639 if conv.safecrlf == SafeCrlf::False {
1640 return Ok(());
1641 }
1642
1643 let old_stats = git_text_stat(data);
1644
1645 let mut new_stats = old_stats.clone();
1646 if convert_crlf_into_lf && new_stats.crlf > 0 {
1647 new_stats.lonelf += new_stats.crlf;
1648 new_stats.crlf = 0;
1649 }
1650 if will_convert_lf_to_crlf_from_stats(&new_stats, conv, file_attrs) {
1651 new_stats.crlf += new_stats.lonelf;
1652 new_stats.lonelf = 0;
1653 }
1654
1655 if old_stats.crlf > 0 && new_stats.crlf == 0 {
1656 let err = FilterError::LineEndingWouldChange {
1657 detail: format!("CRLF would be replaced by LF in {rel_path}"),
1658 };
1659 if conv.safecrlf == SafeCrlf::True {
1660 return Err(err);
1661 }
1662 eprint_safecrlf_warn_crlf_to_lf(rel_path);
1663 } else if old_stats.lonelf > 0 && new_stats.lonelf == 0 {
1664 let err = FilterError::LineEndingWouldChange {
1665 detail: format!("LF would be replaced by CRLF in {rel_path}"),
1666 };
1667 if conv.safecrlf == SafeCrlf::True {
1668 return Err(err);
1669 }
1670 eprint_safecrlf_warn_lf_to_crlf(rel_path);
1671 }
1672
1673 Ok(())
1674}
1675
1676pub fn crlf_to_lf(data: &[u8]) -> Vec<u8> {
1678 let mut out = Vec::with_capacity(data.len());
1679 let mut i = 0;
1680 while i < data.len() {
1681 if i + 1 < data.len() && data[i] == b'\r' && data[i + 1] == b'\n' {
1682 out.push(b'\n');
1683 i += 2;
1684 } else {
1685 out.push(data[i]);
1686 i += 1;
1687 }
1688 }
1689 out
1690}
1691
1692pub fn lf_to_crlf(data: &[u8]) -> Vec<u8> {
1694 let mut out = Vec::with_capacity(data.len() + data.len() / 10);
1695 let mut i = 0;
1696 while i < data.len() {
1697 if data[i] == b'\n' && (i == 0 || data[i - 1] != b'\r') {
1698 out.push(b'\r');
1699 out.push(b'\n');
1700 } else {
1701 out.push(data[i]);
1702 }
1703 i += 1;
1704 }
1705 out
1706}
1707
1708#[allow(clippy::too_many_arguments)]
1724pub fn convert_to_worktree(
1725 data: &[u8],
1726 rel_path: &str,
1727 conv: &ConversionConfig,
1728 file_attrs: &FileAttrs,
1729 oid_hex: Option<&str>,
1730 smudge_meta: Option<&FilterSmudgeMeta>,
1731 delayed_checkout: Option<&mut crate::filter_process::DelayedProcessCheckout>,
1732 filter_process: Option<&FilterProcessState>,
1733) -> Result<Option<Vec<u8>>, FilterError> {
1734 convert_to_worktree_with_runner(
1735 data,
1736 rel_path,
1737 conv,
1738 file_attrs,
1739 oid_hex,
1740 smudge_meta,
1741 delayed_checkout,
1742 filter_process,
1743 None,
1744 )
1745}
1746
1747#[expect(clippy::too_many_arguments)]
1749pub fn convert_to_worktree_with_runner(
1750 data: &[u8],
1751 rel_path: &str,
1752 conv: &ConversionConfig,
1753 file_attrs: &FileAttrs,
1754 oid_hex: Option<&str>,
1755 smudge_meta: Option<&FilterSmudgeMeta>,
1756 delayed_checkout: Option<&mut crate::filter_process::DelayedProcessCheckout>,
1757 filter_process: Option<&FilterProcessState>,
1758 command_runner: Option<&dyn CommandRunner>,
1759) -> Result<Option<Vec<u8>>, FilterError> {
1760 let mut buf = data.to_vec();
1761 let owned_filter_state;
1762 let filter_state = if let Some(s) = filter_process {
1763 s
1764 } else {
1765 owned_filter_state = FilterProcessState::new(Arc::clone(&conv.command_runner));
1766 &owned_filter_state
1767 };
1768 let runner = command_runner.unwrap_or(conv.command_runner.as_ref());
1769
1770 if file_attrs.ident {
1772 if let Some(oid) = oid_hex {
1773 buf = expand_ident(&buf, oid);
1774 }
1775 }
1776
1777 let can_delay_smudge = delayed_checkout.is_some()
1778 && file_attrs.working_tree_encoding.is_none()
1779 && !file_attrs.ident
1780 && file_attrs
1781 .filter_process
1782 .as_deref()
1783 .is_some_and(|c| !c.is_empty())
1784 && !should_convert_to_crlf(conv, file_attrs, &buf)
1785 && file_attrs
1786 .filter_process
1787 .as_deref()
1788 .is_some_and(|c| crate::filter_process::process_filter_supports_delay(filter_state, c));
1789
1790 let should_convert = should_convert_to_crlf(conv, file_attrs, &buf);
1792 if should_convert {
1793 buf = lf_to_crlf(&buf);
1794 }
1795
1796 if let Some(ref enc) = file_attrs.working_tree_encoding {
1798 buf = encode_utf8_blob_to_working_tree_bytes(&buf, rel_path, enc)
1799 .map_err(|e| filter_detail(rel_path, e.to_string()))?;
1800 }
1801
1802 let driver = file_attrs.filter_driver_name.as_deref().unwrap_or("");
1804 if let Some(ref proc_cmd) = file_attrs.filter_process {
1805 if file_attrs.filter_smudge_required {
1806 start_required_process_filter(
1807 filter_state,
1808 proc_cmd,
1809 driver,
1810 rel_path,
1811 FilterPhase::Smudge,
1812 )?;
1813 }
1814 let smudge_out = match apply_process_smudge(
1815 filter_state,
1816 proc_cmd,
1817 rel_path,
1818 &buf,
1819 smudge_meta,
1820 can_delay_smudge,
1821 ) {
1822 Ok(out) => out,
1823 Err(e) => {
1824 if file_attrs.filter_smudge_required {
1825 return Err(external_filter_failed(
1826 rel_path,
1827 driver,
1828 FilterPhase::Smudge,
1829 ));
1830 }
1831 if e.starts_with("filter status: abort") {
1832 filter_state.disable_process_filter(proc_cmd);
1833 }
1834 return Ok(Some(buf));
1835 }
1836 };
1837 let Some(out) = smudge_out else {
1838 let Some(q) = delayed_checkout else {
1839 return Err(filter_detail(
1840 rel_path,
1841 format!("internal error: delayed smudge without checkout queue for {rel_path}"),
1842 ));
1843 };
1844 q.push_delayed(
1845 proc_cmd.clone(),
1846 rel_path.to_string(),
1847 smudge_meta.cloned().unwrap_or_default(),
1848 );
1849 return Ok(None);
1850 };
1851 buf = out;
1852 } else {
1853 match file_attrs.filter_smudge.as_ref() {
1854 Some(smudge_cmd) => {
1855 match run_filter_command(runner, smudge_cmd, &buf, rel_path, Some(filter_state)) {
1856 Ok(filtered) => buf = filtered,
1857 Err(e) if file_attrs.filter_smudge_required => {
1858 return Err(e.into_required_error(driver, rel_path, FilterPhase::Smudge));
1859 }
1860 Err(_) => {}
1861 }
1862 }
1863 None => {
1864 if file_attrs.filter_smudge_required {
1865 return Err(external_filter_failed(
1866 rel_path,
1867 driver,
1868 FilterPhase::Smudge,
1869 ));
1870 }
1871 }
1872 }
1873 }
1874
1875 Ok(Some(buf))
1876}
1877
1878pub fn convert_to_worktree_eager(
1880 data: &[u8],
1881 rel_path: &str,
1882 conv: &ConversionConfig,
1883 file_attrs: &FileAttrs,
1884 oid_hex: Option<&str>,
1885 smudge_meta: Option<&FilterSmudgeMeta>,
1886 filter_process: Option<&FilterProcessState>,
1887) -> Result<Vec<u8>, FilterError> {
1888 match convert_to_worktree(
1889 data,
1890 rel_path,
1891 conv,
1892 file_attrs,
1893 oid_hex,
1894 smudge_meta,
1895 None,
1896 filter_process,
1897 )? {
1898 Some(v) => Ok(v),
1899 None => Err(filter_detail(
1900 rel_path,
1901 format!("internal error: unexpected delayed smudge for {rel_path}"),
1902 )),
1903 }
1904}
1905
1906#[must_use]
1918pub fn worktree_conversion_is_identity(conv: &ConversionConfig, attrs: &FileAttrs) -> bool {
1919 !attrs.ident
1920 && attrs.working_tree_encoding.is_none()
1921 && attrs.filter_smudge.is_none()
1922 && attrs.filter_process.is_none()
1923 && !attrs.filter_smudge_required
1924 && !crlf_output_possible(conv, attrs)
1925}
1926
1927fn crlf_output_possible(conv: &ConversionConfig, attrs: &FileAttrs) -> bool {
1931 match attrs.crlf_legacy {
1932 CrlfLegacyAttr::Unset | CrlfLegacyAttr::Input => return false,
1933 CrlfLegacyAttr::Crlf => return attrs.text != TextAttr::Unset,
1934 CrlfLegacyAttr::Unspecified => {}
1935 }
1936 if attrs.text == TextAttr::Unset {
1937 return false;
1938 }
1939 if attrs.eol != EolAttr::Unspecified {
1940 return attrs.eol == EolAttr::Crlf;
1941 }
1942 match attrs.text {
1943 TextAttr::Set | TextAttr::Auto => output_eol_is_crlf(conv),
1944 _ => conv.autocrlf == AutoCrlf::True,
1945 }
1946}
1947
1948#[must_use]
1950pub fn should_convert_to_crlf(conv: &ConversionConfig, attrs: &FileAttrs, data: &[u8]) -> bool {
1951 if !crlf_output_possible(conv, attrs) {
1952 return false;
1953 }
1954 match attrs.crlf_legacy {
1955 CrlfLegacyAttr::Unset | CrlfLegacyAttr::Input => return false,
1956 CrlfLegacyAttr::Crlf => {
1957 if attrs.text == TextAttr::Unset {
1958 return false;
1959 }
1960 return true;
1963 }
1964 CrlfLegacyAttr::Unspecified => {}
1965 }
1966
1967 if attrs.text == TextAttr::Unset {
1969 return false;
1970 }
1971
1972 if attrs.eol != EolAttr::Unspecified {
1974 if attrs.text == TextAttr::Auto && is_binary(data) {
1975 return false;
1976 }
1977 if attrs.eol != EolAttr::Crlf {
1978 return false;
1979 }
1980 if attrs.text == TextAttr::Auto {
1982 return auto_crlf_should_smudge_lf_to_crlf(data);
1983 }
1984 return true;
1986 }
1987
1988 if attrs.text == TextAttr::Set {
1990 return output_eol_is_crlf(conv);
1991 }
1992
1993 if attrs.text == TextAttr::Auto {
1994 if is_binary(data) {
1995 return false;
1996 }
1997 if !output_eol_is_crlf(conv) {
1998 return false;
1999 }
2000 return auto_crlf_should_smudge_lf_to_crlf(data);
2001 }
2002
2003 match conv.autocrlf {
2005 AutoCrlf::True => {
2006 if is_binary(data) {
2007 return false;
2008 }
2009 auto_crlf_should_smudge_lf_to_crlf(data)
2010 }
2011 AutoCrlf::Input | AutoCrlf::False => false,
2012 }
2013}
2014
2015fn output_eol_is_crlf(conv: &ConversionConfig) -> bool {
2017 if conv.autocrlf == AutoCrlf::Input {
2019 return false;
2020 }
2021 if conv.autocrlf == AutoCrlf::True {
2022 return true;
2023 }
2024 match conv.eol {
2025 CoreEol::Crlf => true,
2026 CoreEol::Lf => false,
2027 CoreEol::Native => {
2028 cfg!(windows)
2030 }
2031 }
2032}
2033
2034fn expand_ident(data: &[u8], oid: &str) -> Vec<u8> {
2039 if !count_ident_regions(data) {
2040 return data.to_vec();
2041 }
2042 let replacement = format!("$Id: {oid} $");
2043 let mut out = Vec::with_capacity(data.len() + 60);
2044 let mut i = 0;
2045 while i < data.len() {
2046 if data[i] != b'$' {
2047 out.push(data[i]);
2048 i += 1;
2049 continue;
2050 }
2051 if i + 3 > data.len() || data[i + 1] != b'I' || data[i + 2] != b'd' {
2052 out.push(data[i]);
2053 i += 1;
2054 continue;
2055 }
2056 let after_id = i + 3;
2057 let ch = data.get(after_id).copied();
2058 match ch {
2059 Some(b'$') => {
2060 out.extend_from_slice(replacement.as_bytes());
2061 i = after_id + 1;
2062 }
2063 Some(b':') => {
2064 let rest = &data[after_id + 1..];
2065 let line_end = rest
2066 .iter()
2067 .position(|&b| b == b'\n' || b == b'\r')
2068 .unwrap_or(rest.len());
2069 let line = &rest[..line_end];
2070 let Some(dollar_rel) = line.iter().position(|&b| b == b'$') else {
2071 out.push(data[i]);
2072 i += 1;
2073 continue;
2074 };
2075 if line[..dollar_rel].contains(&b'\n') {
2076 out.push(data[i]);
2077 i += 1;
2078 continue;
2079 }
2080 let payload = &line[..dollar_rel];
2083 let foreign = payload.len() > 1
2084 && payload[1..]
2085 .iter()
2086 .position(|&b| b == b' ')
2087 .is_some_and(|rel| {
2088 let pos = 1 + rel;
2089 pos < payload.len().saturating_sub(1)
2090 });
2091 if foreign {
2092 out.push(data[i]);
2093 i += 1;
2094 continue;
2095 }
2096 out.extend_from_slice(replacement.as_bytes());
2097 i = after_id + 1 + dollar_rel + 1;
2098 }
2099 _ => {
2100 out.push(data[i]);
2101 i += 1;
2102 }
2103 }
2104 }
2105 out
2106}
2107
2108fn count_ident_regions(data: &[u8]) -> bool {
2110 let mut i = 0usize;
2111 while i < data.len() {
2112 if data[i] != b'$' {
2113 i += 1;
2114 continue;
2115 }
2116 if i + 3 > data.len() || data[i + 1] != b'I' || data[i + 2] != b'd' {
2117 i += 1;
2118 continue;
2119 }
2120 let after = i + 3;
2121 match data.get(after).copied() {
2122 Some(b'$') => return true,
2123 Some(b':') => {
2124 let mut j = after + 1;
2125 let mut found = false;
2126 while j < data.len() {
2127 match data[j] {
2128 b'$' => {
2129 found = true;
2130 break;
2131 }
2132 b'\n' => break,
2135 _ => j += 1,
2136 }
2137 }
2138 if found {
2139 return true;
2140 }
2141 i += 1;
2142 }
2143 _ => i += 1,
2144 }
2145 }
2146 false
2147}
2148
2149pub fn collapse_ident(data: &[u8]) -> Vec<u8> {
2151 let mut out = Vec::with_capacity(data.len());
2152 let mut i = 0;
2153 while i < data.len() {
2154 if i + 4 <= data.len() && &data[i..i + 4] == b"$Id:" {
2155 let rest = &data[i + 4..];
2156 let line_end = rest.iter().position(|&b| b == b'\n').unwrap_or(rest.len());
2157 let line = &rest[..line_end];
2158 if let Some(end) = line.iter().position(|&b| b == b'$') {
2159 out.extend_from_slice(b"$Id$");
2160 i += 4 + end + 1;
2161 continue;
2162 }
2163 }
2164 out.push(data[i]);
2165 i += 1;
2166 }
2167 out
2168}
2169
2170fn sq_quote_buf(s: &str) -> String {
2172 let mut out = String::with_capacity(s.len() + 2);
2173 out.push('\'');
2174 for ch in s.chars() {
2175 if ch == '\'' {
2176 out.push_str("'\\''");
2177 } else {
2178 out.push(ch);
2179 }
2180 }
2181 out.push('\'');
2182 out
2183}
2184
2185fn expand_filter_command(cmd: &str, rel_path: &str) -> String {
2187 let mut out = String::with_capacity(cmd.len() + rel_path.len() + 8);
2188 let mut chars = cmd.chars().peekable();
2189 while let Some(c) = chars.next() {
2190 if c == '%' {
2191 match chars.peek() {
2192 Some('%') => {
2193 chars.next();
2194 out.push('%');
2195 }
2196 Some('f') => {
2197 chars.next();
2198 out.push_str(&sq_quote_buf(rel_path));
2199 }
2200 _ => out.push('%'),
2201 }
2202 } else {
2203 out.push(c);
2204 }
2205 }
2206 out
2207}
2208
2209pub fn run_filter(
2211 runner: &dyn CommandRunner,
2212 cmd: &str,
2213 data: &[u8],
2214 rel_path: &str,
2215) -> Result<Vec<u8>, FilterError> {
2216 run_filter_command(runner, cmd, data, rel_path, None).map_err(|e| match e {
2217 FilterRunError::Spawn { source, .. } | FilterRunError::Io(source) => {
2218 filter_detail(rel_path, source.to_string())
2219 }
2220 FilterRunError::Exit(status) => FilterError::Failed { status },
2221 })
2222}
2223
2224#[derive(Debug)]
2226enum FilterRunError {
2227 Spawn {
2229 program: PathBuf,
2230 source: std::io::Error,
2231 },
2232 Io(std::io::Error),
2234 Exit(i32),
2236}
2237
2238impl FilterRunError {
2239 fn into_required_error(self, driver: &str, rel_path: &str, phase: FilterPhase) -> FilterError {
2241 match self {
2242 Self::Spawn { program, source } => {
2243 filter_spawn_failed(driver, rel_path, phase, &program, &source)
2244 }
2245 Self::Io(_) | Self::Exit(_) => external_filter_failed(rel_path, driver, phase),
2246 }
2247 }
2248}
2249
2250fn filter_spawn_failed(
2251 driver: &str,
2252 rel_path: &str,
2253 phase: FilterPhase,
2254 program: &Path,
2255 source: &std::io::Error,
2256) -> FilterError {
2257 FilterError::ExternalFilterSpawnFailed {
2258 driver: driver.to_owned(),
2259 path: rel_path.to_owned(),
2260 phase,
2261 program: program.display().to_string(),
2262 kind: source.kind(),
2263 detail: source.to_string(),
2264 }
2265}
2266
2267fn run_filter_command(
2268 runner: &dyn CommandRunner,
2269 cmd: &str,
2270 data: &[u8],
2271 rel_path: &str,
2272 filter_state: Option<&FilterProcessState>,
2273) -> Result<Vec<u8>, FilterRunError> {
2274 let expanded = expand_filter_command(cmd, rel_path);
2275 let mut spec = CommandSpec::sh_dash_c_filter(expanded);
2276 if let Some(st) = filter_state {
2277 st.apply_subprocess_to_spec(&mut spec);
2278 }
2279 spec.stdin = CommandStdin::Pipe(data.to_vec());
2280 let output = runner
2281 .spawn(&spec)
2282 .map_err(|source| FilterRunError::Spawn {
2283 program: spec.program.clone(),
2284 source,
2285 })?
2286 .wait_with_output()
2287 .map_err(FilterRunError::Io)?;
2288 if !output.status.success {
2289 return Err(FilterRunError::Exit(output.status.code.unwrap_or(1)));
2290 }
2291 Ok(output.stdout)
2292}
2293
2294fn start_required_process_filter(
2297 filter_state: &FilterProcessState,
2298 proc_cmd: &str,
2299 driver: &str,
2300 rel_path: &str,
2301 phase: FilterPhase,
2302) -> Result<(), FilterError> {
2303 if filter_state.process_filter_is_disabled(proc_cmd) {
2304 return Ok(());
2305 }
2306 match crate::filter_process::start_process_filter(filter_state, proc_cmd) {
2307 Err(crate::filter_process::ProcessFilterStartError::Spawn(source)) => Err(
2308 filter_spawn_failed(driver, rel_path, phase, Path::new("sh"), &source),
2309 ),
2310 Err(crate::filter_process::ProcessFilterStartError::Other(_)) | Ok(()) => Ok(()),
2312 }
2313}
2314
2315pub type GitAttributes = Vec<AttrRule>;
2320
2321#[cfg(test)]
2322mod tests {
2323 use super::*;
2324
2325 fn conversion_matrix() -> Vec<(ConversionConfig, FileAttrs)> {
2327 let mut out = Vec::new();
2328 for autocrlf in [AutoCrlf::False, AutoCrlf::True, AutoCrlf::Input] {
2329 for eol in [CoreEol::Lf, CoreEol::Crlf, CoreEol::Native] {
2330 for text in [
2331 TextAttr::Set,
2332 TextAttr::Auto,
2333 TextAttr::Unset,
2334 TextAttr::Unspecified,
2335 ] {
2336 for attr_eol in [EolAttr::Unspecified, EolAttr::Lf, EolAttr::Crlf] {
2337 for crlf_legacy in [
2338 CrlfLegacyAttr::Unspecified,
2339 CrlfLegacyAttr::Crlf,
2340 CrlfLegacyAttr::Input,
2341 CrlfLegacyAttr::Unset,
2342 ] {
2343 let conv = ConversionConfig {
2344 autocrlf,
2345 eol,
2346 safecrlf: SafeCrlf::False,
2347 command_runner: system_command_runner(),
2348 };
2349 let attrs = FileAttrs {
2350 text,
2351 eol: attr_eol,
2352 crlf_legacy,
2353 ..FileAttrs::default()
2354 };
2355 out.push((conv, attrs));
2356 }
2357 }
2358 }
2359 }
2360 }
2361 out
2362 }
2363
2364 const SAMPLES: [&[u8]; 4] = [
2365 b"a\nb\n",
2366 b"a\r\nb\r\n",
2367 b"mixed\r\nends\n",
2368 b"bin\0\r\nary\n",
2369 ];
2370
2371 #[test]
2372 fn worktree_identity_implies_no_output_conversion_for_any_content() {
2373 let mut identities = 0;
2374 for (conv, attrs) in conversion_matrix() {
2375 if !worktree_conversion_is_identity(&conv, &attrs) {
2376 continue;
2377 }
2378 identities += 1;
2379 for data in SAMPLES {
2380 let out = convert_to_worktree(data, "f", &conv, &attrs, None, None, None, None)
2381 .expect("convert")
2382 .expect("not delayed");
2383 assert_eq!(out, data, "{conv:?} {attrs:?} changed {data:?}");
2384 }
2385 }
2386 assert!(identities > 0);
2387 }
2388
2389 #[test]
2390 fn git_identity_implies_no_input_conversion_for_any_content() {
2391 let mut identities = 0;
2392 for (conv, attrs) in conversion_matrix() {
2393 if !git_conversion_is_identity(&conv, &attrs) {
2394 continue;
2395 }
2396 identities += 1;
2397 for data in SAMPLES {
2398 let out = convert_to_git_with_opts(
2399 data,
2400 "f",
2401 &conv,
2402 &attrs,
2403 ConvertToGitOpts {
2404 index_blob: None,
2405 renormalize: false,
2406 check_safecrlf: false,
2407 filter_process: None,
2408 command_runner: None,
2409 },
2410 )
2411 .expect("convert");
2412 assert_eq!(out, data, "{conv:?} {attrs:?} changed {data:?}");
2413 }
2414 }
2415 assert!(identities > 0);
2416 }
2417
2418 #[test]
2419 fn raw_byte_streaming_under_autocrlf_when_binary() {
2420 use std::io::{Seek, SeekFrom, Write};
2421
2422 let mut conv = ConversionConfig::from_config(&ConfigSet::new());
2423 conv.autocrlf = AutoCrlf::True;
2424 let attrs = FileAttrs::default();
2425 assert!(!git_conversion_is_identity(&conv, &attrs));
2426
2427 let nul_file = tempfile::NamedTempFile::new().expect("temp");
2428 nul_file.as_file().write_all(&[0u8; 9000]).expect("write");
2429 let mut with_nul = nul_file.reopen().expect("reopen");
2430 assert!(worktree_allows_raw_byte_stream(&conv, &attrs, None, &mut with_nul, 9000).unwrap());
2431
2432 let text_file = tempfile::NamedTempFile::new().expect("temp");
2433 text_file
2434 .as_file()
2435 .write_all(b"hello\r\nworld")
2436 .expect("write");
2437 let mut text = text_file.reopen().expect("reopen");
2438 assert!(!worktree_allows_raw_byte_stream(&conv, &attrs, None, &mut text, 12).unwrap());
2439
2440 let mut data = vec![1u8; 20_000];
2441 data[500] = 0;
2442 let huge_file = tempfile::NamedTempFile::new().expect("temp");
2443 huge_file.as_file().write_all(&data).expect("write");
2444 let mut huge = huge_file.reopen().expect("reopen");
2445 huge.seek(SeekFrom::Start(10_000)).expect("seek");
2446 assert!(worktree_allows_raw_byte_stream(&conv, &attrs, None, &mut huge, 20_000).unwrap());
2447
2448 use crate::index::{IndexEntry, MODE_REGULAR};
2449 use crate::objects::ObjectId;
2450 let indexed = IndexEntry {
2451 ctime_sec: 1,
2452 ctime_nsec: 0,
2453 mtime_sec: 1,
2454 mtime_nsec: 0,
2455 dev: 0,
2456 ino: 0,
2457 mode: MODE_REGULAR,
2458 uid: 0,
2459 gid: 0,
2460 size: 9000,
2461 oid: ObjectId::zero(),
2462 flags: 0,
2463 flags_extended: None,
2464 path: b"f.bin".to_vec(),
2465 base_index_pos: 0,
2466 };
2467 assert!(clean_uses_autocrlf_index_guard(&attrs, &conv));
2468 let mut again = nul_file.reopen().expect("reopen");
2469 assert!(
2470 worktree_allows_raw_byte_stream(&conv, &attrs, Some(&indexed), &mut again, 9000)
2471 .unwrap()
2472 );
2473 }
2474
2475 #[test]
2476 fn identity_checks_see_filters_ident_and_encoding() {
2477 let conv = ConversionConfig::from_config(&ConfigSet::new());
2478 assert!(worktree_conversion_is_identity(
2479 &conv,
2480 &FileAttrs::default()
2481 ));
2482 assert!(git_conversion_is_identity(&conv, &FileAttrs::default()));
2483 let with = |f: &dyn Fn(&mut FileAttrs)| {
2484 let mut attrs = FileAttrs::default();
2485 f(&mut attrs);
2486 attrs
2487 };
2488 for attrs in [
2489 with(&|a| a.ident = true),
2490 with(&|a| a.working_tree_encoding = Some("UTF-16".into())),
2491 with(&|a| a.filter_smudge = Some("cat".into())),
2492 with(&|a| a.filter_process = Some("proc".into())),
2493 with(&|a| a.filter_smudge_required = true),
2494 ] {
2495 assert!(!worktree_conversion_is_identity(&conv, &attrs), "{attrs:?}");
2496 }
2497 for attrs in [
2498 with(&|a| a.ident = true),
2499 with(&|a| a.working_tree_encoding = Some("UTF-16".into())),
2500 with(&|a| a.filter_clean = Some("cat".into())),
2501 with(&|a| a.filter_process = Some("proc".into())),
2502 with(&|a| a.filter_clean_required = true),
2503 ] {
2504 assert!(!git_conversion_is_identity(&conv, &attrs), "{attrs:?}");
2505 }
2506 }
2507
2508 #[test]
2509 fn collapse_ident_allows_cr_before_closing_dollar() {
2510 let data = b"$Id: abc\r$\n";
2511 assert_eq!(collapse_ident(data), b"$Id$\n");
2512 }
2513
2514 #[test]
2515 fn convert_to_git_collapses_ident_with_cr_before_closing_dollar() {
2516 let conv = ConversionConfig::from_config(&ConfigSet::new());
2517 let mut attrs = FileAttrs::default();
2518 attrs.ident = true;
2519 let data = b"$Id: abc\r$\n";
2520 let out = convert_to_git(data, "f", &conv, &attrs).expect("convert");
2521 assert_eq!(out, b"$Id$\n");
2522 }
2523
2524 #[test]
2525 fn convert_to_git_collapses_expanded_ident_to_keyword() {
2526 let conv = ConversionConfig::from_config(&ConfigSet::new());
2527 let mut attrs = FileAttrs::default();
2528 attrs.ident = true;
2529 let expanded = b"$Id: 10d2a9379e5d822c92a372599358dd0e616aadac $\nfoo\n";
2530 let out = convert_to_git(expanded, "id.txt", &conv, &attrs).expect("convert");
2531 assert_eq!(out, b"$Id$\nfoo\n");
2532 }
2533
2534 #[test]
2535 fn test_crlf_to_lf() {
2536 assert_eq!(crlf_to_lf(b"hello\r\nworld\r\n"), b"hello\nworld\n");
2537 assert_eq!(crlf_to_lf(b"hello\nworld\n"), b"hello\nworld\n");
2538 assert_eq!(crlf_to_lf(b"hello\r\n"), b"hello\n");
2539 }
2540
2541 #[test]
2542 fn test_lf_to_crlf() {
2543 assert_eq!(lf_to_crlf(b"hello\nworld\n"), b"hello\r\nworld\r\n");
2544 assert_eq!(lf_to_crlf(b"hello\r\nworld\r\n"), b"hello\r\nworld\r\n");
2545 }
2546
2547 #[test]
2548 fn test_has_crlf() {
2549 assert!(has_crlf(b"hello\r\nworld"));
2550 assert!(!has_crlf(b"hello\nworld"));
2551 }
2552
2553 #[test]
2554 fn smudge_mixed_line_endings_unchanged_with_autocrlf_true() {
2555 let mut blob = Vec::new();
2556 for part in [
2557 b"Oh\n".as_slice(),
2558 b"here\n",
2559 b"is\n",
2560 b"CRLF\r\n",
2561 b"in\n",
2562 b"text\n",
2563 ] {
2564 blob.extend_from_slice(part);
2565 }
2566 let conv = ConversionConfig {
2567 autocrlf: AutoCrlf::True,
2568 eol: CoreEol::Lf,
2569 safecrlf: SafeCrlf::False,
2570 command_runner: system_command_runner(),
2571 };
2572 let attrs = FileAttrs::default();
2573 let out =
2574 convert_to_worktree_eager(&blob, "mixed", &conv, &attrs, None, None, None).unwrap();
2575 assert_eq!(out, blob);
2576 }
2577
2578 #[test]
2579 fn smudge_lf_only_gets_crlf_with_autocrlf_true() {
2580 let blob = b"a\nb\n";
2581 let conv = ConversionConfig {
2582 autocrlf: AutoCrlf::True,
2583 eol: CoreEol::Lf,
2584 safecrlf: SafeCrlf::False,
2585 command_runner: system_command_runner(),
2586 };
2587 let attrs = FileAttrs::default();
2588 let out = convert_to_worktree_eager(blob, "x", &conv, &attrs, None, None, None).unwrap();
2589 assert_eq!(out, b"a\r\nb\r\n");
2590 }
2591
2592 #[test]
2593 fn test_is_binary() {
2594 assert!(is_binary(b"hello\0world"));
2595 assert!(!is_binary(b"hello world"));
2596 }
2597
2598 #[test]
2599 fn attr_dir_only_pattern_does_not_match_same_named_file() {
2600 let rules = parse_gitattributes_content("dir-only-pattern/ text\n");
2601 let rule = &rules[0];
2602 assert!(!attr_rule_matches(rule, "parent/dir-only-pattern", false));
2603 assert!(attr_rule_matches(rule, "dir-only-pattern", true));
2604 }
2605
2606 #[test]
2607 fn test_expand_collapse_ident() {
2608 let data = b"$Id$";
2609 let expanded = expand_ident(data, "abc123");
2610 assert_eq!(expanded, b"$Id: abc123 $");
2611 let collapsed = collapse_ident(&expanded);
2612 assert_eq!(collapsed, b"$Id$");
2613 }
2614
2615 #[test]
2616 fn expand_ident_does_not_span_lines_for_partial_keyword() {
2617 let data = b"$Id: NoTerminatingSymbol\n$Id: deadbeef $\n";
2618 let expanded = expand_ident(data, "newoid");
2619 assert_eq!(expanded, b"$Id: NoTerminatingSymbol\n$Id: newoid $\n");
2620 }
2621
2622 #[test]
2623 fn expand_ident_preserves_foreign_id_with_internal_spaces() {
2624 let data = b"$Id: Foreign Commit With Spaces $\n";
2625 let expanded = expand_ident(data, "abc");
2626 assert_eq!(expanded, data);
2627 }
2628
2629 #[test]
2630 fn expand_filter_command_percent_f_quotes_path() {
2631 let s = expand_filter_command("sh ./x.sh %f --extra", "name with 'sq'");
2632 assert_eq!(s, "sh ./x.sh 'name with '\\''sq'\\''' --extra");
2633 assert_eq!(expand_filter_command("a %% b", "p"), "a % b");
2634 }
2635}