use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::attributes::{
attr_rule_matches as git_attr_rule_matches, collect_attrs_for_path,
parse_gitattributes_file_content, parse_gitattributes_file_content_with_base, AttrValue,
MacroTable, ParsedGitAttributes, MAX_ATTR_FILE_BYTES,
};
use crate::command_runner::{system_command_runner, CommandRunner, CommandSpec, CommandStdin};
use crate::config::ConfigSet;
use crate::error::{FilterError, FilterPhase};
use crate::filter_process::{
apply_process_clean, apply_process_smudge, FilterProcessState, FilterSmudgeMeta,
};
use encoding_rs::UTF_8;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum WorkTreeEncodingError {
#[error("unsupported encoding '{label}'")]
UnsupportedEncoding {
label: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ConversionError {
#[error(transparent)]
Encoding(WorkTreeEncodingError),
#[error("{0}")]
Message(String),
}
impl From<WorkTreeEncodingError> for ConversionError {
fn from(value: WorkTreeEncodingError) -> Self {
Self::Encoding(value)
}
}
impl From<String> for ConversionError {
fn from(value: String) -> Self {
Self::Message(value)
}
}
impl From<&str> for ConversionError {
fn from(value: &str) -> Self {
Self::Message(value.to_owned())
}
}
fn filter_detail(rel_path: &str, detail: String) -> FilterError {
FilterError::NotFilteredProperly {
path: rel_path.to_owned(),
detail,
}
}
fn external_filter_failed(rel_path: &str, driver: &str, phase: FilterPhase) -> FilterError {
FilterError::ExternalFilterFailed {
driver: driver.to_owned(),
path: rel_path.to_owned(),
phase,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutoCrlf {
True,
Input,
False,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoreEol {
Lf,
Crlf,
Native,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SafeCrlf {
True,
Warn,
False,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextAttr {
Set,
Auto,
Unset,
Unspecified,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EolAttr {
Lf,
Crlf,
Unspecified,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CrlfLegacyAttr {
#[default]
Unspecified,
Unset,
Input,
Crlf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MergeAttr {
Unspecified,
Unset,
Driver(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffAttr {
Unspecified,
Unset,
Set,
Driver(String),
}
#[derive(Debug, Clone)]
pub struct FileAttrs {
pub text: TextAttr,
pub eol: EolAttr,
pub diff_attr: DiffAttr,
pub filter_clean: Option<String>,
pub filter_smudge: Option<String>,
pub filter_process: Option<String>,
pub filter_driver_name: Option<String>,
pub filter_smudge_required: bool,
pub filter_clean_required: bool,
pub ident: bool,
pub merge: MergeAttr,
pub conflict_marker_size: Option<String>,
pub working_tree_encoding: Option<String>,
pub crlf_legacy: CrlfLegacyAttr,
pub whitespace: Option<String>,
}
impl Default for FileAttrs {
fn default() -> Self {
FileAttrs {
text: TextAttr::Unspecified,
eol: EolAttr::Unspecified,
diff_attr: DiffAttr::Unspecified,
filter_clean: None,
filter_smudge: None,
filter_process: None,
filter_driver_name: None,
filter_smudge_required: false,
filter_clean_required: false,
ident: false,
merge: MergeAttr::Unspecified,
conflict_marker_size: None,
working_tree_encoding: None,
crlf_legacy: CrlfLegacyAttr::Unspecified,
whitespace: None,
}
}
}
impl FileAttrs {
#[must_use]
pub(crate) fn effective_conflict_marker_size(&self) -> usize {
match self
.conflict_marker_size
.as_ref()
.and_then(|value| value.parse::<usize>().ok())
{
None | Some(0) => 7,
Some(n) => n,
}
}
}
#[derive(Clone)]
pub struct ConversionConfig {
pub autocrlf: AutoCrlf,
pub eol: CoreEol,
pub safecrlf: SafeCrlf,
pub command_runner: Arc<dyn CommandRunner>,
}
impl ConversionConfig {
pub fn from_config(config: &ConfigSet) -> Self {
let autocrlf = match config.get("core.autocrlf") {
Some(v) => match v.to_lowercase().as_str() {
"true" | "yes" | "on" | "1" => AutoCrlf::True,
"input" => AutoCrlf::Input,
_ => AutoCrlf::False,
},
None => AutoCrlf::False,
};
let eol = match config.get("core.eol") {
Some(v) => match v.to_lowercase().as_str() {
"crlf" => CoreEol::Crlf,
"lf" => CoreEol::Lf,
"native" => CoreEol::Native,
_ => CoreEol::Native,
},
None => CoreEol::Native,
};
let safecrlf = match config.get("core.safecrlf") {
Some(v) => match v.to_lowercase().as_str() {
"true" | "yes" | "on" | "1" => SafeCrlf::True,
"warn" => SafeCrlf::Warn,
_ => SafeCrlf::False,
},
None => SafeCrlf::Warn,
};
ConversionConfig {
autocrlf,
eol,
safecrlf,
command_runner: system_command_runner(),
}
}
#[must_use]
pub fn from_config_with_runner(
config: &ConfigSet,
command_runner: Arc<dyn CommandRunner>,
) -> Self {
let mut cfg = Self::from_config(config);
cfg.command_runner = command_runner;
cfg
}
}
impl std::fmt::Debug for ConversionConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConversionConfig")
.field("autocrlf", &self.autocrlf)
.field("eol", &self.eol)
.field("safecrlf", &self.safecrlf)
.field("command_runner", &"<CommandRunner>")
.finish()
}
}
pub use crate::attributes::AttrRule;
fn merge_parsed_into(
merged: &mut ParsedGitAttributes,
mut parsed: ParsedGitAttributes,
take_macros: bool,
) {
merged.rules.append(&mut parsed.rules);
if take_macros {
merged.macros.defs.extend(parsed.macros.defs.drain());
}
merged.warnings.append(&mut parsed.warnings);
}
fn merge_gitattributes_text(
merged: &mut ParsedGitAttributes,
content: &str,
display: &str,
attr_base: &str,
) {
if content.len() > MAX_ATTR_FILE_BYTES {
return;
}
let parsed = if attr_base.is_empty() {
parse_gitattributes_file_content(content, display)
} else {
parse_gitattributes_file_content_with_base(content, display, attr_base)
};
merge_parsed_into(merged, parsed, attr_base.is_empty());
}
pub(crate) fn config_ignorecase(config: &ConfigSet) -> bool {
config
.get_bool("core.ignorecase")
.unwrap_or(Ok(false))
.unwrap_or(false)
}
#[must_use]
pub fn load_gitattributes_parsed(work_tree: &Path) -> ParsedGitAttributes {
let mut merged = ParsedGitAttributes::default();
let root_attrs = work_tree.join(".gitattributes");
if let Ok(content) = std::fs::read_to_string(&root_attrs) {
merge_gitattributes_text(&mut merged, &content, ".gitattributes", "");
}
let info_attrs = work_tree.join(".git/info/attributes");
if let Ok(content) = std::fs::read_to_string(&info_attrs) {
merge_gitattributes_text(&mut merged, &content, "info/attributes", "");
}
merged
}
#[must_use]
pub fn load_gitattributes(work_tree: &Path) -> Vec<AttrRule> {
load_gitattributes_parsed(work_tree).rules
}
#[must_use]
pub fn parse_gitattributes_content(content: &str) -> Vec<AttrRule> {
parse_gitattributes_file_content(content, ".gitattributes").rules
}
#[must_use]
pub fn load_gitattributes_from_index(
index: &crate::index::Index,
odb: &crate::odb::Odb,
) -> Vec<AttrRule> {
crate::attributes::load_gitattributes_from_index(index, odb, Path::new("."))
.map(|p| p.rules)
.unwrap_or_default()
}
#[must_use]
pub fn load_gitattributes_for_checkout_parsed(
work_tree: &Path,
rel_path: &str,
index: &crate::index::Index,
odb: &crate::odb::Odb,
) -> ParsedGitAttributes {
let mut merged = load_gitattributes_parsed(work_tree);
if !work_tree.join(".gitattributes").exists() {
if let Some(entry) = index.get(b".gitattributes", 0) {
if let Ok(obj) = odb.read(&entry.oid) {
if let Ok(content) = String::from_utf8(obj.data) {
merge_gitattributes_text(&mut merged, &content, ".gitattributes", "");
}
}
}
}
let path = Path::new(rel_path);
if let Some(parent) = path.parent() {
let mut accum = PathBuf::new();
for comp in parent.components() {
accum.push(comp);
let ga_rel = accum.join(".gitattributes");
let ga_stored = ga_rel.to_string_lossy().replace('\\', "/");
let attr_base = Path::new(&ga_stored)
.parent()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
let wt_ga = work_tree.join(&ga_rel);
if let Ok(content) = std::fs::read_to_string(&wt_ga) {
merge_gitattributes_text(&mut merged, &content, &ga_stored, &attr_base);
} else {
let key = crate::repo_relpath::storage_bytes(&ga_stored).into_owned();
if let Some(entry) = index.get(&key, 0) {
if let Ok(obj) = odb.read(&entry.oid) {
if let Ok(content) = String::from_utf8(obj.data) {
merge_gitattributes_text(&mut merged, &content, &ga_stored, &attr_base);
}
}
}
}
}
}
merged
}
#[must_use]
pub fn load_gitattributes_for_checkout(
work_tree: &Path,
rel_path: &str,
index: &crate::index::Index,
odb: &crate::odb::Odb,
) -> Vec<AttrRule> {
load_gitattributes_for_checkout_parsed(work_tree, rel_path, index, odb).rules
}
fn config_bool_truthy(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"true" | "yes" | "on" | "1"
)
}
fn attr_path_for_match(rel_path: &str, is_dir: bool) -> String {
if is_dir && !rel_path.is_empty() && !rel_path.ends_with('/') {
format!("{rel_path}/")
} else {
rel_path.to_owned()
}
}
#[must_use]
pub fn file_attrs_from_attr_map(map: &HashMap<String, AttrValue>, config: &ConfigSet) -> FileAttrs {
let mut fa = FileAttrs::default();
for (name, value) in map {
match name.as_str() {
"text" => {
fa.text = match value {
AttrValue::Set => TextAttr::Set,
AttrValue::Unset => TextAttr::Unset,
AttrValue::Value(v) if v == "auto" => TextAttr::Auto,
AttrValue::Clear | AttrValue::Value(_) => TextAttr::Unspecified,
};
}
"eol" => {
fa.eol = match value {
AttrValue::Value(v) if v == "lf" => EolAttr::Lf,
AttrValue::Value(v) if v == "crlf" => EolAttr::Crlf,
_ => EolAttr::Unspecified,
};
}
"filter" => {
let driver = match value {
AttrValue::Unset => {
fa.filter_clean = None;
fa.filter_smudge = None;
fa.filter_process = None;
fa.filter_driver_name = None;
fa.filter_smudge_required = false;
fa.filter_clean_required = false;
continue;
}
AttrValue::Set => continue,
AttrValue::Clear => continue,
AttrValue::Value(d) => d.as_str(),
};
let clean_key = format!("filter.{driver}.clean");
let smudge_key = format!("filter.{driver}.smudge");
let process_key = format!("filter.{driver}.process");
let req_key = format!("filter.{driver}.required");
fa.filter_driver_name = Some(driver.to_owned());
fa.filter_process = config.get(&process_key).filter(|s| !s.is_empty());
if fa.filter_process.is_some() {
fa.filter_clean = None;
fa.filter_smudge = None;
} else {
fa.filter_clean = config.get(&clean_key);
fa.filter_smudge = config.get(&smudge_key);
}
let required = config
.get(&req_key)
.is_some_and(|v| config_bool_truthy(v.as_str()));
fa.filter_smudge_required = required;
fa.filter_clean_required = required;
}
"diff" => {
fa.diff_attr = match value {
AttrValue::Unset => DiffAttr::Unset,
AttrValue::Set => DiffAttr::Set,
AttrValue::Value(v) if !v.is_empty() => DiffAttr::Driver(v.clone()),
AttrValue::Clear => DiffAttr::Unspecified,
_ => DiffAttr::Unspecified,
};
}
"ident" => {
fa.ident = matches!(value, AttrValue::Set);
}
"merge" => {
fa.merge = match value {
AttrValue::Unset => MergeAttr::Unset,
AttrValue::Set => MergeAttr::Unspecified,
AttrValue::Value(v) => MergeAttr::Driver(v.clone()),
AttrValue::Clear => MergeAttr::Unspecified,
};
}
"conflict-marker-size" => match value {
AttrValue::Unset | AttrValue::Clear => fa.conflict_marker_size = None,
AttrValue::Value(v) => fa.conflict_marker_size = Some(v.clone()),
AttrValue::Set => {}
},
"working-tree-encoding" => {
if let AttrValue::Value(v) = value {
if !v.is_empty() {
fa.working_tree_encoding = Some(v.clone());
}
}
}
"crlf" => {
fa.crlf_legacy = match value {
AttrValue::Unset => CrlfLegacyAttr::Unset,
AttrValue::Value(v) if v == "input" => CrlfLegacyAttr::Input,
AttrValue::Set => CrlfLegacyAttr::Crlf,
AttrValue::Value(v) if v == "true" || v == "set" => CrlfLegacyAttr::Crlf,
_ => CrlfLegacyAttr::Unspecified,
};
}
"whitespace" => match value {
AttrValue::Unset => fa.whitespace = Some("unset".to_owned()),
AttrValue::Set => fa.whitespace = Some("set".to_owned()),
AttrValue::Value(v) if !v.is_empty() => fa.whitespace = Some(v.clone()),
_ => {}
},
_ => {}
}
}
fa
}
#[must_use]
pub fn file_attrs_from_gitattributes(
rules: &[AttrRule],
macros: &MacroTable,
rel_path: &str,
is_dir: bool,
config: &ConfigSet,
) -> FileAttrs {
let path = attr_path_for_match(rel_path, is_dir);
let map = collect_attrs_for_path(rules, macros, &path, config_ignorecase(config));
file_attrs_from_attr_map(&map, config)
}
#[must_use]
pub fn get_file_attrs(
rules: &[AttrRule],
rel_path: &str,
is_dir: bool,
config: &ConfigSet,
) -> FileAttrs {
file_attrs_from_gitattributes(rules, &MacroTable::default(), rel_path, is_dir, config)
}
#[must_use]
pub fn path_gitattribute_value(
rules: &[AttrRule],
path: &str,
is_dir: bool,
attr_name: &str,
) -> Option<String> {
path_gitattribute_values(rules, &MacroTable::default(), false, path, is_dir)
.get(attr_name)
.map(|v| v.display().to_string())
.filter(|s| s != "unspecified")
}
pub(crate) fn path_gitattribute_values(
rules: &[AttrRule],
macros: &MacroTable,
icase: bool,
path: &str,
is_dir: bool,
) -> std::collections::HashMap<String, crate::attributes::AttrValue> {
collect_attrs_for_path(rules, macros, &attr_path_for_match(path, is_dir), icase)
}
#[must_use]
pub fn attr_rule_matches(rule: &AttrRule, rel_path: &str, is_dir: bool) -> bool {
let match_path = attr_path_for_match(rel_path, is_dir);
git_attr_rule_matches(rule, &match_path, false)
}
pub fn is_binary(data: &[u8]) -> bool {
let check_len = data.len().min(8000);
data[..check_len].contains(&0)
}
const CONVERT_STAT_BITS_TXT_LF: u32 = 0x1;
const CONVERT_STAT_BITS_TXT_CRLF: u32 = 0x2;
const CONVERT_STAT_BITS_BIN: u32 = 0x4;
#[derive(Default, Clone)]
struct TextStat {
nul: u32,
lonecr: u32,
lonelf: u32,
crlf: u32,
printable: u32,
nonprintable: u32,
}
fn gather_text_stat(data: &[u8]) -> TextStat {
let mut s = TextStat::default();
let mut i = 0usize;
while i < data.len() {
let c = data[i];
if c == b'\r' {
if i + 1 < data.len() && data[i + 1] == b'\n' {
s.crlf += 1;
i += 2;
} else {
s.lonecr += 1;
i += 1;
}
continue;
}
if c == b'\n' {
s.lonelf += 1;
i += 1;
continue;
}
if c == 127 {
s.nonprintable += 1;
} else if c < 32 {
match c {
b'\t' | b'\x08' | b'\x1b' | b'\x0c' => s.printable += 1,
0 => {
s.nul += 1;
s.nonprintable += 1;
}
_ => s.nonprintable += 1,
}
} else {
s.printable += 1;
}
i += 1;
}
s
}
fn convert_is_binary(stats: &TextStat) -> bool {
stats.lonecr > 0 || stats.nul > 0 || (stats.printable >> 7) < stats.nonprintable
}
fn git_text_stat(data: &[u8]) -> TextStat {
let mut stats = gather_text_stat(data);
if !data.is_empty() && data[data.len() - 1] == 0x1a {
stats.nonprintable = stats.nonprintable.saturating_sub(1);
}
stats
}
fn will_convert_lf_to_crlf_from_stats(
stats: &TextStat,
conv: &ConversionConfig,
attrs: &FileAttrs,
) -> bool {
let has_lone_lf = stats.lonelf > 0;
let is_bin = convert_is_binary(stats);
match attrs.crlf_legacy {
CrlfLegacyAttr::Unset | CrlfLegacyAttr::Input => return false,
CrlfLegacyAttr::Crlf => {
if attrs.text == TextAttr::Unset {
return false;
}
return has_lone_lf;
}
CrlfLegacyAttr::Unspecified => {}
}
if attrs.text == TextAttr::Unset {
return false;
}
if attrs.eol != EolAttr::Unspecified {
if attrs.text == TextAttr::Auto && is_bin {
return false;
}
if attrs.eol != EolAttr::Crlf {
return false;
}
if attrs.text == TextAttr::Auto {
return auto_crlf_should_smudge_lf_to_crlf_from_stats(stats);
}
return has_lone_lf;
}
if attrs.text == TextAttr::Set {
if !output_eol_is_crlf(conv) {
return false;
}
return has_lone_lf;
}
if attrs.text == TextAttr::Auto {
if is_bin || !output_eol_is_crlf(conv) {
return false;
}
return auto_crlf_should_smudge_lf_to_crlf_from_stats(stats);
}
match conv.autocrlf {
AutoCrlf::True => {
if is_bin {
return false;
}
auto_crlf_should_smudge_lf_to_crlf_from_stats(stats)
}
AutoCrlf::Input | AutoCrlf::False => false,
}
}
fn auto_crlf_should_smudge_lf_to_crlf_from_stats(stats: &TextStat) -> bool {
if stats.lonelf == 0 {
return false;
}
if stats.lonecr > 0 || stats.crlf > 0 {
return false;
}
!convert_is_binary(stats)
}
fn gather_convert_stats(data: &[u8]) -> u32 {
if data.is_empty() {
return 0;
}
let mut stats = gather_text_stat(data);
if !data.is_empty() && data[data.len() - 1] == 0x1a {
stats.nonprintable = stats.nonprintable.saturating_sub(1);
}
let mut ret = 0u32;
if convert_is_binary(&stats) {
ret |= CONVERT_STAT_BITS_BIN;
}
if stats.crlf > 0 {
ret |= CONVERT_STAT_BITS_TXT_CRLF;
}
if stats.lonelf > 0 {
ret |= CONVERT_STAT_BITS_TXT_LF;
}
ret
}
#[must_use]
pub fn gather_convert_stats_ascii(data: &[u8]) -> &'static str {
let convert_stats = gather_convert_stats(data);
if convert_stats & CONVERT_STAT_BITS_BIN != 0 {
return "-text";
}
match convert_stats {
CONVERT_STAT_BITS_TXT_LF => "lf",
CONVERT_STAT_BITS_TXT_CRLF => "crlf",
x if x == (CONVERT_STAT_BITS_TXT_LF | CONVERT_STAT_BITS_TXT_CRLF) => "mixed",
_ => "none",
}
}
pub fn has_crlf(data: &[u8]) -> bool {
data.windows(2).any(|w| w == b"\r\n")
}
pub fn has_lone_lf(data: &[u8]) -> bool {
for i in 0..data.len() {
if data[i] == b'\n' && (i == 0 || data[i - 1] != b'\r') {
return true;
}
}
false
}
fn has_lone_cr(data: &[u8]) -> bool {
for i in 0..data.len() {
if data[i] == b'\r' && (i + 1 >= data.len() || data[i + 1] != b'\n') {
return true;
}
}
false
}
fn auto_crlf_should_smudge_lf_to_crlf(data: &[u8]) -> bool {
if !has_lone_lf(data) {
return false;
}
if has_lone_cr(data) || has_crlf(data) {
return false;
}
if is_binary(data) {
return false;
}
true
}
pub fn is_all_crlf(data: &[u8]) -> bool {
has_crlf(data) && !has_lone_lf(data)
}
pub fn is_all_lf(data: &[u8]) -> bool {
has_lone_lf(data) && !has_crlf(data)
}
#[must_use]
pub fn has_crlf_in_index_blob(data: &[u8]) -> bool {
if !data.contains(&b'\r') {
return false;
}
let st = gather_convert_stats(data);
st & CONVERT_STAT_BITS_BIN == 0 && (st & CONVERT_STAT_BITS_TXT_CRLF) != 0
}
#[must_use]
pub fn clean_uses_autocrlf_index_guard(attrs: &FileAttrs, conv: &ConversionConfig) -> bool {
if attrs.text == TextAttr::Unset || attrs.crlf_legacy == CrlfLegacyAttr::Unset {
return false;
}
if attrs.eol != EolAttr::Unspecified && attrs.text != TextAttr::Auto {
return false;
}
attrs.text == TextAttr::Auto
|| (attrs.text == TextAttr::Unspecified
&& matches!(conv.autocrlf, AutoCrlf::True | AutoCrlf::Input))
}
#[derive(Clone, Copy)]
pub struct ConvertToGitOpts<'a> {
pub index_blob: Option<&'a [u8]>,
pub renormalize: bool,
pub check_safecrlf: bool,
pub filter_process: Option<&'a FilterProcessState>,
pub command_runner: Option<&'a dyn CommandRunner>,
}
impl Default for ConvertToGitOpts<'_> {
fn default() -> Self {
Self {
index_blob: None,
renormalize: false,
check_safecrlf: true,
filter_process: None,
command_runner: None,
}
}
}
impl std::fmt::Debug for ConvertToGitOpts<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConvertToGitOpts")
.field("index_blob", &self.index_blob.map(|b| b.len()))
.field("renormalize", &self.renormalize)
.field("check_safecrlf", &self.check_safecrlf)
.field("command_runner", &self.command_runner.is_some())
.finish()
}
}
const UTF16_BE_BOM: &[u8] = &[0xFE, 0xFF];
const UTF16_LE_BOM: &[u8] = &[0xFF, 0xFE];
const UTF32_BE_BOM: &[u8] = &[0x00, 0x00, 0xFE, 0xFF];
const UTF32_LE_BOM: &[u8] = &[0xFF, 0xFE, 0x00, 0x00];
fn canonical_utf_label(label: &str) -> Option<String> {
let trimmed = label.trim();
let lower = trimmed.to_ascii_lowercase();
let rest = lower.strip_prefix("utf")?;
let rest = rest.strip_prefix('-').unwrap_or(rest);
match rest {
"8" => Some("utf-8".to_string()),
"16" => Some("utf-16".to_string()),
"16be" => Some("utf-16be".to_string()),
"16le" => Some("utf-16le".to_string()),
"16be-bom" => Some("utf-16be-bom".to_string()),
"16le-bom" => Some("utf-16le-bom".to_string()),
"32" => Some("utf-32".to_string()),
"32be" => Some("utf-32be".to_string()),
"32le" => Some("utf-32le".to_string()),
_ => None,
}
}
fn has_bom_prefix(data: &[u8], bom: &[u8]) -> bool {
data.len() >= bom.len() && &data[..bom.len()] == bom
}
fn has_prohibited_utf_bom(canon: &str, data: &[u8]) -> bool {
match canon {
"utf-16be" | "utf-16le" => {
has_bom_prefix(data, UTF16_BE_BOM) || has_bom_prefix(data, UTF16_LE_BOM)
}
"utf-32be" | "utf-32le" => {
has_bom_prefix(data, UTF32_BE_BOM) || has_bom_prefix(data, UTF32_LE_BOM)
}
_ => false,
}
}
fn is_missing_required_utf_bom(canon: &str, data: &[u8]) -> bool {
match canon {
"utf-16" => !(has_bom_prefix(data, UTF16_BE_BOM) || has_bom_prefix(data, UTF16_LE_BOM)),
"utf-32" => !(has_bom_prefix(data, UTF32_BE_BOM) || has_bom_prefix(data, UTF32_LE_BOM)),
_ => false,
}
}
fn validate_utf_bom(
canon: &str,
label: &str,
rel_path: &str,
data: &[u8],
die_on_error: bool,
) -> Result<(), String> {
if has_prohibited_utf_bom(canon, data) {
let stripped = label
.strip_prefix("utf")
.or_else(|| label.strip_prefix("UTF"));
let _utf_num = stripped
.map(|s| s.trim_start_matches('-'))
.and_then(|s| s.get(..s.len().saturating_sub(2)))
.unwrap_or("");
let body = format!("BOM is prohibited in '{rel_path}' if encoded as {label}");
if die_on_error {
return Err(body);
}
return Err(body);
}
if is_missing_required_utf_bom(canon, data) {
let _utf_num = label
.strip_prefix("utf")
.or_else(|| label.strip_prefix("UTF"))
.map(|s| s.trim_start_matches('-'))
.unwrap_or("");
let body = format!("BOM is required in '{rel_path}' if encoded as {label}");
if die_on_error {
return Err(body);
}
return Err(body);
}
Ok(())
}
fn decode_working_tree_bytes_to_utf8(
src: &[u8],
rel_path: &str,
enc_label: &str,
validate: bool,
) -> Result<Vec<u8>, ConversionError> {
let label = enc_label.trim();
if label.is_empty() {
return Ok(src.to_vec());
}
let canon = canonical_utf_label(label);
if let Some(ref c) = canon {
validate_utf_bom(c, label, rel_path, src, validate)?;
}
if canon.as_deref() == Some("utf-8") {
return Ok(src.to_vec());
}
let (iconv_from, body): (&str, &[u8]) = match canon.as_deref() {
Some("utf-16le-bom") => {
let body = if has_bom_prefix(src, UTF16_LE_BOM) {
&src[2..]
} else {
src
};
("UTF-16LE", body)
}
Some("utf-16be-bom") => {
let body = if has_bom_prefix(src, UTF16_BE_BOM) {
&src[2..]
} else {
src
};
("UTF-16BE", body)
}
Some(c) => (utf_canon_to_iconv_name(c), src),
None => {
let Some(enc) = crate::commit_encoding::resolve(label) else {
return Err(WorkTreeEncodingError::UnsupportedEncoding {
label: label.to_owned(),
}
.into());
};
if enc == UTF_8 {
return Ok(src.to_vec());
}
let (cow, _, had_errors) = enc.decode(src);
if had_errors {
return Err(ConversionError::Message(format!(
"failed to encode '{rel_path}' from {label} to UTF-8"
)));
}
return Ok(cow.into_owned().into_bytes());
}
};
decode_utf_bytes_with_encoding_rs(body, rel_path, label, iconv_from)
}
fn decode_utf_bytes_with_encoding_rs(
body: &[u8],
rel_path: &str,
label: &str,
iconv_from: &str,
) -> Result<Vec<u8>, ConversionError> {
let fail = || {
ConversionError::Message(format!(
"failed to encode '{rel_path}' from {label} to UTF-8"
))
};
match iconv_from {
"UTF-16BE" => {
let (cow, _, had_errors) = encoding_rs::UTF_16BE.decode(body);
if had_errors {
return Err(fail());
}
Ok(cow.into_owned().into_bytes())
}
"UTF-16LE" => {
let (cow, _, had_errors) = encoding_rs::UTF_16LE.decode(body);
if had_errors {
return Err(fail());
}
Ok(cow.into_owned().into_bytes())
}
"UTF-16" => {
if has_bom_prefix(body, UTF16_BE_BOM) {
decode_utf_bytes_with_encoding_rs(&body[2..], rel_path, label, "UTF-16BE")
} else if has_bom_prefix(body, UTF16_LE_BOM) {
decode_utf_bytes_with_encoding_rs(&body[2..], rel_path, label, "UTF-16LE")
} else {
Err(fail())
}
}
"UTF-32" => {
if has_bom_prefix(body, UTF32_BE_BOM) {
decode_utf32_body_to_utf8_bytes(&body[4..], rel_path, true)
} else if has_bom_prefix(body, UTF32_LE_BOM) {
decode_utf32_body_to_utf8_bytes(&body[4..], rel_path, false)
} else {
Err(fail())
}
}
"UTF-32BE" => decode_utf32_body_to_utf8_bytes(body, rel_path, true),
"UTF-32LE" => decode_utf32_body_to_utf8_bytes(body, rel_path, false),
_ => Err(fail()),
}
}
fn decode_utf32_body_to_utf8_bytes(
body: &[u8],
rel_path: &str,
big_endian: bool,
) -> Result<Vec<u8>, ConversionError> {
let fail = || {
ConversionError::Message(format!(
"failed to encode '{rel_path}' from UTF-32 to UTF-8"
))
};
if !body.len().is_multiple_of(4) {
return Err(fail());
}
let mut s = String::new();
for chunk in body.as_chunks::<4>().0 {
let cp = if big_endian {
u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
} else {
u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
};
let Some(ch) = char::from_u32(cp) else {
return Err(fail());
};
s.push(ch);
}
Ok(s.into_bytes())
}
fn utf_canon_to_iconv_name(canon: &str) -> &'static str {
match canon {
"utf-16" => "UTF-16",
"utf-16be" => "UTF-16BE",
"utf-16le" => "UTF-16LE",
"utf-32" => "UTF-32",
"utf-32be" => "UTF-32BE",
"utf-32le" => "UTF-32LE",
_ => "UTF-8",
}
}
fn encode_utf8_blob_to_working_tree_bytes(
src: &[u8],
rel_path: &str,
enc_label: &str,
) -> Result<Vec<u8>, ConversionError> {
let label = enc_label.trim();
if label.is_empty() {
return Ok(src.to_vec());
}
let canon = canonical_utf_label(label);
if canon.as_deref() == Some("utf-8") {
return Ok(src.to_vec());
}
let fail = || {
ConversionError::Message(format!(
"failed to encode '{rel_path}' from UTF-8 to {label}"
))
};
match canon.as_deref() {
Some("utf-16le-bom") => {
let body = encode_utf_with_encoding_rs(src, "utf-16le").ok_or_else(fail)?;
let mut out = UTF16_LE_BOM.to_vec();
out.extend(body);
return Ok(out);
}
Some("utf-16be-bom") => {
let body = encode_utf_with_encoding_rs(src, "utf-16be").ok_or_else(fail)?;
let mut out = UTF16_BE_BOM.to_vec();
out.extend(body);
return Ok(out);
}
Some("utf-16") => {
let body = encode_utf_with_encoding_rs(src, "utf-16le").ok_or_else(fail)?;
let mut out = UTF16_LE_BOM.to_vec();
out.extend(body);
return Ok(out);
}
Some("utf-32") => {
let body = encode_utf_with_encoding_rs(src, "utf-32le").ok_or_else(fail)?;
let mut out = UTF32_LE_BOM.to_vec();
out.extend(body);
return Ok(out);
}
Some(c) => {
return encode_utf_with_encoding_rs(src, c).ok_or_else(fail);
}
None => {}
}
let s = std::str::from_utf8(src).map_err(|_| fail())?;
let Some(enc) = crate::commit_encoding::resolve(label) else {
return Err(WorkTreeEncodingError::UnsupportedEncoding {
label: label.to_owned(),
}
.into());
};
if enc == UTF_8 {
return Ok(src.to_vec());
}
let (cow, _, had_errors) = enc.encode(s);
if had_errors {
return Err(fail());
}
Ok(cow.into_owned())
}
fn encode_utf_with_encoding_rs(src: &[u8], target: &str) -> Option<Vec<u8>> {
let s = std::str::from_utf8(src).ok()?;
let lower = target.to_ascii_lowercase();
let mut out = Vec::new();
match lower.as_str() {
"utf-16" | "utf-16be" => {
for u in s.encode_utf16() {
out.extend_from_slice(&u.to_be_bytes());
}
}
"utf-16le" => {
for u in s.encode_utf16() {
out.extend_from_slice(&u.to_le_bytes());
}
}
"utf-32" | "utf-32be" => {
for ch in s.chars() {
out.extend_from_slice(&(ch as u32).to_be_bytes());
}
}
"utf-32le" => {
for ch in s.chars() {
out.extend_from_slice(&(ch as u32).to_le_bytes());
}
}
_ => return None,
}
Some(out)
}
pub fn convert_to_git(
data: &[u8],
rel_path: &str,
conv: &ConversionConfig,
file_attrs: &FileAttrs,
) -> Result<Vec<u8>, FilterError> {
convert_to_git_with_opts(
data,
rel_path,
conv,
file_attrs,
ConvertToGitOpts::default(),
)
}
pub fn convert_to_git_with_opts(
data: &[u8],
rel_path: &str,
conv: &ConversionConfig,
file_attrs: &FileAttrs,
opts: ConvertToGitOpts<'_>,
) -> Result<Vec<u8>, FilterError> {
convert_to_git_owned(data.to_vec(), rel_path, conv, file_attrs, opts)
}
pub(crate) fn convert_to_git_owned(
data: Vec<u8>,
rel_path: &str,
conv: &ConversionConfig,
file_attrs: &FileAttrs,
opts: ConvertToGitOpts<'_>,
) -> Result<Vec<u8>, FilterError> {
let mut buf = data;
let owned_filter_state;
let filter_state = if let Some(s) = opts.filter_process {
s
} else {
owned_filter_state = FilterProcessState::new(Arc::clone(&conv.command_runner));
&owned_filter_state
};
let runner = opts.command_runner.unwrap_or(conv.command_runner.as_ref());
if let Some(ref proc_cmd) = file_attrs.filter_process {
let name = file_attrs.filter_driver_name.as_deref().unwrap_or_default();
if file_attrs.filter_clean_required {
start_required_process_filter(
filter_state,
proc_cmd,
name,
rel_path,
FilterPhase::Clean,
)?;
}
match apply_process_clean(filter_state, proc_cmd, rel_path, &buf) {
Ok(filtered) => buf = filtered,
Err(e) => {
if file_attrs.filter_clean_required {
if e.contains("expected git-filter-server") {
return Err(filter_detail(rel_path, e));
}
return Err(FilterError::ExternalFilterFailed {
driver: name.to_owned(),
path: rel_path.to_owned(),
phase: FilterPhase::Clean,
});
}
if e.starts_with("filter status: abort") {
filter_state.disable_process_filter(proc_cmd);
}
}
}
} else {
match file_attrs.filter_clean.as_ref() {
Some(clean_cmd) => match run_filter_command(runner, clean_cmd, &buf, rel_path) {
Ok(filtered) => buf = filtered,
Err(e) if file_attrs.filter_clean_required => {
let name = file_attrs.filter_driver_name.as_deref().unwrap_or_default();
return Err(e.into_required_error(name, rel_path, FilterPhase::Clean));
}
Err(_) => {}
},
None => {
if file_attrs.filter_clean_required {
let name = file_attrs.filter_driver_name.as_deref().unwrap_or_default();
return Err(FilterError::ExternalFilterFailed {
driver: name.to_owned(),
path: rel_path.to_owned(),
phase: FilterPhase::Clean,
});
}
}
}
}
if let Some(ref enc) = file_attrs.working_tree_encoding {
if enc == "set" || enc == "true" || enc == "false" {
return Err(FilterError::InvalidWorkingTreeEncoding);
}
let writing_object = opts.check_safecrlf;
match decode_working_tree_bytes_to_utf8(&buf, rel_path, enc, writing_object) {
Ok(decoded) => buf = decoded,
Err(e) if writing_object => return Err(filter_detail(rel_path, e.to_string())),
Err(_) => {}
}
}
let would_convert = would_convert_on_input(conv, file_attrs, &buf);
let mut convert_crlf_into_lf = would_convert && has_crlf(&buf);
if convert_crlf_into_lf
&& clean_uses_autocrlf_index_guard(file_attrs, conv)
&& !opts.renormalize
&& opts.index_blob.is_some_and(has_crlf_in_index_blob)
{
convert_crlf_into_lf = false;
}
if would_convert && opts.check_safecrlf {
check_safecrlf_roundtrip(conv, file_attrs, &buf, rel_path, convert_crlf_into_lf)?;
}
if convert_crlf_into_lf {
buf = crlf_to_lf(&buf);
}
Ok(buf)
}
#[must_use]
pub fn git_conversion_is_identity(conv: &ConversionConfig, attrs: &FileAttrs) -> bool {
attrs.filter_process.is_none()
&& attrs.filter_clean.is_none()
&& !attrs.filter_clean_required
&& attrs.working_tree_encoding.is_none()
&& input_eol_rule(conv, attrs) == InputEolRule::Never
}
fn would_convert_on_input(conv: &ConversionConfig, attrs: &FileAttrs, data: &[u8]) -> bool {
match input_eol_rule(conv, attrs) {
InputEolRule::Never => false,
InputEolRule::Always => true,
InputEolRule::UnlessBinary => !is_binary(data),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InputEolRule {
Never,
Always,
UnlessBinary,
}
fn input_eol_rule(conv: &ConversionConfig, attrs: &FileAttrs) -> InputEolRule {
match attrs.crlf_legacy {
CrlfLegacyAttr::Unset => return InputEolRule::Never,
CrlfLegacyAttr::Input => return InputEolRule::UnlessBinary,
CrlfLegacyAttr::Crlf => {
if attrs.text == TextAttr::Unset {
return InputEolRule::Never;
}
return InputEolRule::UnlessBinary;
}
CrlfLegacyAttr::Unspecified => {}
}
if attrs.text == TextAttr::Unset {
return InputEolRule::Never;
}
if attrs.eol != EolAttr::Unspecified {
if attrs.text == TextAttr::Auto {
return InputEolRule::UnlessBinary;
}
return InputEolRule::Always;
}
match attrs.text {
TextAttr::Set => return InputEolRule::Always,
TextAttr::Auto => return InputEolRule::UnlessBinary,
_ => {}
}
match conv.autocrlf {
AutoCrlf::True | AutoCrlf::Input => InputEolRule::UnlessBinary,
AutoCrlf::False => InputEolRule::Never,
}
}
fn eprint_safecrlf_warn_crlf_to_lf(_rel_path: &str) {}
fn eprint_safecrlf_warn_lf_to_crlf(_rel_path: &str) {}
fn check_safecrlf_roundtrip(
conv: &ConversionConfig,
file_attrs: &FileAttrs,
data: &[u8],
rel_path: &str,
convert_crlf_into_lf: bool,
) -> Result<(), FilterError> {
if conv.safecrlf == SafeCrlf::False {
return Ok(());
}
let old_stats = git_text_stat(data);
let mut new_stats = old_stats.clone();
if convert_crlf_into_lf && new_stats.crlf > 0 {
new_stats.lonelf += new_stats.crlf;
new_stats.crlf = 0;
}
if will_convert_lf_to_crlf_from_stats(&new_stats, conv, file_attrs) {
new_stats.crlf += new_stats.lonelf;
new_stats.lonelf = 0;
}
if old_stats.crlf > 0 && new_stats.crlf == 0 {
let err = FilterError::LineEndingWouldChange {
detail: format!("CRLF would be replaced by LF in {rel_path}"),
};
if conv.safecrlf == SafeCrlf::True {
return Err(err);
}
eprint_safecrlf_warn_crlf_to_lf(rel_path);
} else if old_stats.lonelf > 0 && new_stats.lonelf == 0 {
let err = FilterError::LineEndingWouldChange {
detail: format!("LF would be replaced by CRLF in {rel_path}"),
};
if conv.safecrlf == SafeCrlf::True {
return Err(err);
}
eprint_safecrlf_warn_lf_to_crlf(rel_path);
}
Ok(())
}
pub fn crlf_to_lf(data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len());
let mut i = 0;
while i < data.len() {
if i + 1 < data.len() && data[i] == b'\r' && data[i + 1] == b'\n' {
out.push(b'\n');
i += 2;
} else {
out.push(data[i]);
i += 1;
}
}
out
}
pub fn lf_to_crlf(data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len() + data.len() / 10);
let mut i = 0;
while i < data.len() {
if data[i] == b'\n' && (i == 0 || data[i - 1] != b'\r') {
out.push(b'\r');
out.push(b'\n');
} else {
out.push(data[i]);
}
i += 1;
}
out
}
#[allow(clippy::too_many_arguments)]
pub fn convert_to_worktree(
data: &[u8],
rel_path: &str,
conv: &ConversionConfig,
file_attrs: &FileAttrs,
oid_hex: Option<&str>,
smudge_meta: Option<&FilterSmudgeMeta>,
delayed_checkout: Option<&mut crate::filter_process::DelayedProcessCheckout>,
filter_process: Option<&FilterProcessState>,
) -> Result<Option<Vec<u8>>, FilterError> {
convert_to_worktree_with_runner(
data,
rel_path,
conv,
file_attrs,
oid_hex,
smudge_meta,
delayed_checkout,
filter_process,
None,
)
}
#[expect(clippy::too_many_arguments)]
pub fn convert_to_worktree_with_runner(
data: &[u8],
rel_path: &str,
conv: &ConversionConfig,
file_attrs: &FileAttrs,
oid_hex: Option<&str>,
smudge_meta: Option<&FilterSmudgeMeta>,
delayed_checkout: Option<&mut crate::filter_process::DelayedProcessCheckout>,
filter_process: Option<&FilterProcessState>,
command_runner: Option<&dyn CommandRunner>,
) -> Result<Option<Vec<u8>>, FilterError> {
let mut buf = data.to_vec();
let owned_filter_state;
let filter_state = if let Some(s) = filter_process {
s
} else {
owned_filter_state = FilterProcessState::new(Arc::clone(&conv.command_runner));
&owned_filter_state
};
let runner = command_runner.unwrap_or(conv.command_runner.as_ref());
if file_attrs.ident {
if let Some(oid) = oid_hex {
buf = expand_ident(&buf, oid);
}
}
let can_delay_smudge = delayed_checkout.is_some()
&& file_attrs.working_tree_encoding.is_none()
&& !file_attrs.ident
&& file_attrs
.filter_process
.as_deref()
.is_some_and(|c| !c.is_empty())
&& !should_convert_to_crlf(conv, file_attrs, &buf)
&& file_attrs
.filter_process
.as_deref()
.is_some_and(|c| crate::filter_process::process_filter_supports_delay(filter_state, c));
let should_convert = should_convert_to_crlf(conv, file_attrs, &buf);
if should_convert {
buf = lf_to_crlf(&buf);
}
if let Some(ref enc) = file_attrs.working_tree_encoding {
buf = encode_utf8_blob_to_working_tree_bytes(&buf, rel_path, enc)
.map_err(|e| filter_detail(rel_path, e.to_string()))?;
}
let driver = file_attrs.filter_driver_name.as_deref().unwrap_or("");
if let Some(ref proc_cmd) = file_attrs.filter_process {
if file_attrs.filter_smudge_required {
start_required_process_filter(
filter_state,
proc_cmd,
driver,
rel_path,
FilterPhase::Smudge,
)?;
}
let smudge_out = match apply_process_smudge(
filter_state,
proc_cmd,
rel_path,
&buf,
smudge_meta,
can_delay_smudge,
) {
Ok(out) => out,
Err(e) => {
if file_attrs.filter_smudge_required {
return Err(external_filter_failed(
rel_path,
driver,
FilterPhase::Smudge,
));
}
if e.starts_with("filter status: abort") {
filter_state.disable_process_filter(proc_cmd);
}
return Ok(Some(buf));
}
};
let Some(out) = smudge_out else {
let Some(q) = delayed_checkout else {
return Err(filter_detail(
rel_path,
format!("internal error: delayed smudge without checkout queue for {rel_path}"),
));
};
q.push_delayed(
proc_cmd.clone(),
rel_path.to_string(),
smudge_meta.cloned().unwrap_or_default(),
);
return Ok(None);
};
buf = out;
} else {
match file_attrs.filter_smudge.as_ref() {
Some(smudge_cmd) => match run_filter_command(runner, smudge_cmd, &buf, rel_path) {
Ok(filtered) => buf = filtered,
Err(e) if file_attrs.filter_smudge_required => {
return Err(e.into_required_error(driver, rel_path, FilterPhase::Smudge));
}
Err(_) => {}
},
None => {
if file_attrs.filter_smudge_required {
return Err(external_filter_failed(
rel_path,
driver,
FilterPhase::Smudge,
));
}
}
}
}
Ok(Some(buf))
}
pub fn convert_to_worktree_eager(
data: &[u8],
rel_path: &str,
conv: &ConversionConfig,
file_attrs: &FileAttrs,
oid_hex: Option<&str>,
smudge_meta: Option<&FilterSmudgeMeta>,
filter_process: Option<&FilterProcessState>,
) -> Result<Vec<u8>, FilterError> {
match convert_to_worktree(
data,
rel_path,
conv,
file_attrs,
oid_hex,
smudge_meta,
None,
filter_process,
)? {
Some(v) => Ok(v),
None => Err(filter_detail(
rel_path,
format!("internal error: unexpected delayed smudge for {rel_path}"),
)),
}
}
#[must_use]
pub fn worktree_conversion_is_identity(conv: &ConversionConfig, attrs: &FileAttrs) -> bool {
!attrs.ident
&& attrs.working_tree_encoding.is_none()
&& attrs.filter_smudge.is_none()
&& attrs.filter_process.is_none()
&& !attrs.filter_smudge_required
&& !crlf_output_possible(conv, attrs)
}
fn crlf_output_possible(conv: &ConversionConfig, attrs: &FileAttrs) -> bool {
match attrs.crlf_legacy {
CrlfLegacyAttr::Unset | CrlfLegacyAttr::Input => return false,
CrlfLegacyAttr::Crlf => return attrs.text != TextAttr::Unset,
CrlfLegacyAttr::Unspecified => {}
}
if attrs.text == TextAttr::Unset {
return false;
}
if attrs.eol != EolAttr::Unspecified {
return attrs.eol == EolAttr::Crlf;
}
match attrs.text {
TextAttr::Set | TextAttr::Auto => output_eol_is_crlf(conv),
_ => conv.autocrlf == AutoCrlf::True,
}
}
#[must_use]
pub fn should_convert_to_crlf(conv: &ConversionConfig, attrs: &FileAttrs, data: &[u8]) -> bool {
if !crlf_output_possible(conv, attrs) {
return false;
}
match attrs.crlf_legacy {
CrlfLegacyAttr::Unset | CrlfLegacyAttr::Input => return false,
CrlfLegacyAttr::Crlf => {
if attrs.text == TextAttr::Unset {
return false;
}
return true;
}
CrlfLegacyAttr::Unspecified => {}
}
if attrs.text == TextAttr::Unset {
return false;
}
if attrs.eol != EolAttr::Unspecified {
if attrs.text == TextAttr::Auto && is_binary(data) {
return false;
}
if attrs.eol != EolAttr::Crlf {
return false;
}
if attrs.text == TextAttr::Auto {
return auto_crlf_should_smudge_lf_to_crlf(data);
}
return true;
}
if attrs.text == TextAttr::Set {
return output_eol_is_crlf(conv);
}
if attrs.text == TextAttr::Auto {
if is_binary(data) {
return false;
}
if !output_eol_is_crlf(conv) {
return false;
}
return auto_crlf_should_smudge_lf_to_crlf(data);
}
match conv.autocrlf {
AutoCrlf::True => {
if is_binary(data) {
return false;
}
auto_crlf_should_smudge_lf_to_crlf(data)
}
AutoCrlf::Input | AutoCrlf::False => false,
}
}
fn output_eol_is_crlf(conv: &ConversionConfig) -> bool {
if conv.autocrlf == AutoCrlf::Input {
return false;
}
if conv.autocrlf == AutoCrlf::True {
return true;
}
match conv.eol {
CoreEol::Crlf => true,
CoreEol::Lf => false,
CoreEol::Native => {
cfg!(windows)
}
}
}
fn expand_ident(data: &[u8], oid: &str) -> Vec<u8> {
if !count_ident_regions(data) {
return data.to_vec();
}
let replacement = format!("$Id: {oid} $");
let mut out = Vec::with_capacity(data.len() + 60);
let mut i = 0;
while i < data.len() {
if data[i] != b'$' {
out.push(data[i]);
i += 1;
continue;
}
if i + 3 > data.len() || data[i + 1] != b'I' || data[i + 2] != b'd' {
out.push(data[i]);
i += 1;
continue;
}
let after_id = i + 3;
let ch = data.get(after_id).copied();
match ch {
Some(b'$') => {
out.extend_from_slice(replacement.as_bytes());
i = after_id + 1;
}
Some(b':') => {
let rest = &data[after_id + 1..];
let line_end = rest
.iter()
.position(|&b| b == b'\n' || b == b'\r')
.unwrap_or(rest.len());
let line = &rest[..line_end];
let Some(dollar_rel) = line.iter().position(|&b| b == b'$') else {
out.push(data[i]);
i += 1;
continue;
};
if line[..dollar_rel].contains(&b'\n') {
out.push(data[i]);
i += 1;
continue;
}
let payload = &line[..dollar_rel];
let foreign = payload.len() > 1
&& payload[1..]
.iter()
.position(|&b| b == b' ')
.is_some_and(|rel| {
let pos = 1 + rel;
pos < payload.len().saturating_sub(1)
});
if foreign {
out.push(data[i]);
i += 1;
continue;
}
out.extend_from_slice(replacement.as_bytes());
i = after_id + 1 + dollar_rel + 1;
}
_ => {
out.push(data[i]);
i += 1;
}
}
}
out
}
fn count_ident_regions(data: &[u8]) -> bool {
let mut i = 0usize;
while i < data.len() {
if data[i] != b'$' {
i += 1;
continue;
}
if i + 3 > data.len() || data[i + 1] != b'I' || data[i + 2] != b'd' {
i += 1;
continue;
}
let after = i + 3;
match data.get(after).copied() {
Some(b'$') => return true,
Some(b':') => {
let mut j = after + 1;
let mut found = false;
while j < data.len() {
match data[j] {
b'$' => {
found = true;
break;
}
b'\n' | b'\r' => break,
_ => j += 1,
}
}
if found {
return true;
}
i += 1;
}
_ => i += 1,
}
}
false
}
pub fn collapse_ident(data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len());
let mut i = 0;
while i < data.len() {
if i + 4 <= data.len() && &data[i..i + 4] == b"$Id:" {
let rest = &data[i + 4..];
let line_end = rest
.iter()
.position(|&b| b == b'\n' || b == b'\r')
.unwrap_or(rest.len());
let line = &rest[..line_end];
if let Some(end) = line.iter().position(|&b| b == b'$') {
out.extend_from_slice(b"$Id$");
i += 4 + end + 1;
continue;
}
}
out.push(data[i]);
i += 1;
}
out
}
fn sq_quote_buf(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for ch in s.chars() {
if ch == '\'' {
out.push_str("'\\''");
} else {
out.push(ch);
}
}
out.push('\'');
out
}
fn expand_filter_command(cmd: &str, rel_path: &str) -> String {
let mut out = String::with_capacity(cmd.len() + rel_path.len() + 8);
let mut chars = cmd.chars().peekable();
while let Some(c) = chars.next() {
if c == '%' {
match chars.peek() {
Some('%') => {
chars.next();
out.push('%');
}
Some('f') => {
chars.next();
out.push_str(&sq_quote_buf(rel_path));
}
_ => out.push('%'),
}
} else {
out.push(c);
}
}
out
}
pub fn run_filter(
runner: &dyn CommandRunner,
cmd: &str,
data: &[u8],
rel_path: &str,
) -> Result<Vec<u8>, FilterError> {
run_filter_command(runner, cmd, data, rel_path).map_err(|e| match e {
FilterRunError::Spawn { source, .. } | FilterRunError::Io(source) => {
filter_detail(rel_path, source.to_string())
}
FilterRunError::Exit(status) => FilterError::Failed { status },
})
}
#[derive(Debug)]
enum FilterRunError {
Spawn {
program: PathBuf,
source: std::io::Error,
},
Io(std::io::Error),
Exit(i32),
}
impl FilterRunError {
fn into_required_error(self, driver: &str, rel_path: &str, phase: FilterPhase) -> FilterError {
match self {
Self::Spawn { program, source } => {
filter_spawn_failed(driver, rel_path, phase, &program, &source)
}
Self::Io(_) | Self::Exit(_) => external_filter_failed(rel_path, driver, phase),
}
}
}
fn filter_spawn_failed(
driver: &str,
rel_path: &str,
phase: FilterPhase,
program: &Path,
source: &std::io::Error,
) -> FilterError {
FilterError::ExternalFilterSpawnFailed {
driver: driver.to_owned(),
path: rel_path.to_owned(),
phase,
program: program.display().to_string(),
kind: source.kind(),
detail: source.to_string(),
}
}
fn run_filter_command(
runner: &dyn CommandRunner,
cmd: &str,
data: &[u8],
rel_path: &str,
) -> Result<Vec<u8>, FilterRunError> {
let expanded = expand_filter_command(cmd, rel_path);
let mut spec = CommandSpec::sh_dash_c_filter(expanded);
spec.stdin = CommandStdin::Pipe(data.to_vec());
let output = runner
.spawn(&spec)
.map_err(|source| FilterRunError::Spawn {
program: spec.program.clone(),
source,
})?
.wait_with_output()
.map_err(FilterRunError::Io)?;
if !output.status.success {
return Err(FilterRunError::Exit(output.status.code.unwrap_or(1)));
}
Ok(output.stdout)
}
fn start_required_process_filter(
filter_state: &FilterProcessState,
proc_cmd: &str,
driver: &str,
rel_path: &str,
phase: FilterPhase,
) -> Result<(), FilterError> {
if filter_state.process_filter_is_disabled(proc_cmd) {
return Ok(());
}
match crate::filter_process::start_process_filter(filter_state, proc_cmd) {
Err(crate::filter_process::ProcessFilterStartError::Spawn(source)) => Err(
filter_spawn_failed(driver, rel_path, phase, Path::new("sh"), &source),
),
Err(crate::filter_process::ProcessFilterStartError::Other(_)) | Ok(()) => Ok(()),
}
}
pub type GitAttributes = Vec<AttrRule>;
#[cfg(test)]
mod tests {
use super::*;
fn conversion_matrix() -> Vec<(ConversionConfig, FileAttrs)> {
let mut out = Vec::new();
for autocrlf in [AutoCrlf::False, AutoCrlf::True, AutoCrlf::Input] {
for eol in [CoreEol::Lf, CoreEol::Crlf, CoreEol::Native] {
for text in [
TextAttr::Set,
TextAttr::Auto,
TextAttr::Unset,
TextAttr::Unspecified,
] {
for attr_eol in [EolAttr::Unspecified, EolAttr::Lf, EolAttr::Crlf] {
for crlf_legacy in [
CrlfLegacyAttr::Unspecified,
CrlfLegacyAttr::Crlf,
CrlfLegacyAttr::Input,
CrlfLegacyAttr::Unset,
] {
let conv = ConversionConfig {
autocrlf,
eol,
safecrlf: SafeCrlf::False,
command_runner: system_command_runner(),
};
let attrs = FileAttrs {
text,
eol: attr_eol,
crlf_legacy,
..FileAttrs::default()
};
out.push((conv, attrs));
}
}
}
}
}
out
}
const SAMPLES: [&[u8]; 4] = [
b"a\nb\n",
b"a\r\nb\r\n",
b"mixed\r\nends\n",
b"bin\0\r\nary\n",
];
#[test]
fn worktree_identity_implies_no_output_conversion_for_any_content() {
let mut identities = 0;
for (conv, attrs) in conversion_matrix() {
if !worktree_conversion_is_identity(&conv, &attrs) {
continue;
}
identities += 1;
for data in SAMPLES {
let out = convert_to_worktree(data, "f", &conv, &attrs, None, None, None, None)
.expect("convert")
.expect("not delayed");
assert_eq!(out, data, "{conv:?} {attrs:?} changed {data:?}");
}
}
assert!(identities > 0);
}
#[test]
fn git_identity_implies_no_input_conversion_for_any_content() {
let mut identities = 0;
for (conv, attrs) in conversion_matrix() {
if !git_conversion_is_identity(&conv, &attrs) {
continue;
}
identities += 1;
for data in SAMPLES {
let out = convert_to_git_with_opts(
data,
"f",
&conv,
&attrs,
ConvertToGitOpts {
index_blob: None,
renormalize: false,
check_safecrlf: false,
filter_process: None,
command_runner: None,
},
)
.expect("convert");
assert_eq!(out, data, "{conv:?} {attrs:?} changed {data:?}");
}
}
assert!(identities > 0);
}
#[test]
fn identity_checks_see_filters_ident_and_encoding() {
let conv = ConversionConfig::from_config(&ConfigSet::new());
assert!(worktree_conversion_is_identity(
&conv,
&FileAttrs::default()
));
assert!(git_conversion_is_identity(&conv, &FileAttrs::default()));
let with = |f: &dyn Fn(&mut FileAttrs)| {
let mut attrs = FileAttrs::default();
f(&mut attrs);
attrs
};
for attrs in [
with(&|a| a.ident = true),
with(&|a| a.working_tree_encoding = Some("UTF-16".into())),
with(&|a| a.filter_smudge = Some("cat".into())),
with(&|a| a.filter_process = Some("proc".into())),
with(&|a| a.filter_smudge_required = true),
] {
assert!(!worktree_conversion_is_identity(&conv, &attrs), "{attrs:?}");
}
for attrs in [
with(&|a| a.working_tree_encoding = Some("UTF-16".into())),
with(&|a| a.filter_clean = Some("cat".into())),
with(&|a| a.filter_process = Some("proc".into())),
with(&|a| a.filter_clean_required = true),
] {
assert!(!git_conversion_is_identity(&conv, &attrs), "{attrs:?}");
}
}
#[test]
fn test_crlf_to_lf() {
assert_eq!(crlf_to_lf(b"hello\r\nworld\r\n"), b"hello\nworld\n");
assert_eq!(crlf_to_lf(b"hello\nworld\n"), b"hello\nworld\n");
assert_eq!(crlf_to_lf(b"hello\r\n"), b"hello\n");
}
#[test]
fn test_lf_to_crlf() {
assert_eq!(lf_to_crlf(b"hello\nworld\n"), b"hello\r\nworld\r\n");
assert_eq!(lf_to_crlf(b"hello\r\nworld\r\n"), b"hello\r\nworld\r\n");
}
#[test]
fn test_has_crlf() {
assert!(has_crlf(b"hello\r\nworld"));
assert!(!has_crlf(b"hello\nworld"));
}
#[test]
fn smudge_mixed_line_endings_unchanged_with_autocrlf_true() {
let mut blob = Vec::new();
for part in [
b"Oh\n".as_slice(),
b"here\n",
b"is\n",
b"CRLF\r\n",
b"in\n",
b"text\n",
] {
blob.extend_from_slice(part);
}
let conv = ConversionConfig {
autocrlf: AutoCrlf::True,
eol: CoreEol::Lf,
safecrlf: SafeCrlf::False,
command_runner: system_command_runner(),
};
let attrs = FileAttrs::default();
let out =
convert_to_worktree_eager(&blob, "mixed", &conv, &attrs, None, None, None).unwrap();
assert_eq!(out, blob);
}
#[test]
fn smudge_lf_only_gets_crlf_with_autocrlf_true() {
let blob = b"a\nb\n";
let conv = ConversionConfig {
autocrlf: AutoCrlf::True,
eol: CoreEol::Lf,
safecrlf: SafeCrlf::False,
command_runner: system_command_runner(),
};
let attrs = FileAttrs::default();
let out = convert_to_worktree_eager(blob, "x", &conv, &attrs, None, None, None).unwrap();
assert_eq!(out, b"a\r\nb\r\n");
}
#[test]
fn test_is_binary() {
assert!(is_binary(b"hello\0world"));
assert!(!is_binary(b"hello world"));
}
#[test]
fn attr_dir_only_pattern_does_not_match_same_named_file() {
let rules = parse_gitattributes_content("dir-only-pattern/ text\n");
let rule = &rules[0];
assert!(!attr_rule_matches(rule, "parent/dir-only-pattern", false));
assert!(attr_rule_matches(rule, "dir-only-pattern", true));
}
#[test]
fn test_expand_collapse_ident() {
let data = b"$Id$";
let expanded = expand_ident(data, "abc123");
assert_eq!(expanded, b"$Id: abc123 $");
let collapsed = collapse_ident(&expanded);
assert_eq!(collapsed, b"$Id$");
}
#[test]
fn expand_ident_does_not_span_lines_for_partial_keyword() {
let data = b"$Id: NoTerminatingSymbol\n$Id: deadbeef $\n";
let expanded = expand_ident(data, "newoid");
assert_eq!(expanded, b"$Id: NoTerminatingSymbol\n$Id: newoid $\n");
}
#[test]
fn expand_ident_preserves_foreign_id_with_internal_spaces() {
let data = b"$Id: Foreign Commit With Spaces $\n";
let expanded = expand_ident(data, "abc");
assert_eq!(expanded, data);
}
#[test]
fn expand_filter_command_percent_f_quotes_path() {
let s = expand_filter_command("sh ./x.sh %f --extra", "name with 'sq'");
assert_eq!(s, "sh ./x.sh 'name with '\\''sq'\\''' --extra");
assert_eq!(expand_filter_command("a %% b", "p"), "a % b");
}
}