use std::fs;
use std::path::{Path, PathBuf};
use crate::git::repo::{ATTRIBUTES_FILE, CONFIG_FILE, DRIVER, KEY_ENVELOPE_DIR, git_spelling};
use crate::rules::declaration::Config;
use crate::{Error, Result};
const BEGIN: &str = "# >>> git-xcrypt >>>";
const END: &str = "# <<< git-xcrypt <<<";
pub const CATCH_ALL: &str = "* filter=git-xcrypt";
#[must_use]
pub fn render_lines(config: &Config, rendering: Rendering) -> Vec<String> {
let fold = match rendering {
Rendering::Global => return vec![GLOBAL_LINE.to_string()],
Rendering::PerPattern { fold_case } => fold_case,
};
let mut lines: Vec<String> = Vec::new();
let mut suppressed: Vec<String> = Vec::new();
for pattern in config.patterns() {
for spelling in translate(pattern.source, fold) {
if pattern.negated {
lines.push(format!("{spelling} !text !diff"));
} else {
lines.push(format!("{spelling} filter={DRIVER} -text diff={DRIVER}"));
if pattern.suppress_diff && !suppressed.contains(&spelling) {
suppressed.push(spelling);
}
}
}
}
let mut seen: Vec<&String> = Vec::new();
let mut deduplicated: Vec<String> = Vec::new();
for line in lines.iter().rev() {
if !seen.contains(&line) {
seen.push(line);
deduplicated.push(line.clone());
}
}
deduplicated.reverse();
deduplicated.extend(
suppressed
.into_iter()
.map(|pattern| format!("{pattern} -diff")),
);
deduplicated.extend(bootstrap_exclusions(config, fold));
deduplicated
}
fn bootstrap_exclusions(config: &Config, fold: bool) -> Vec<String> {
let mut lines = Vec::new();
let reached = |path: &str| config.decide_ignoring_exclusions(path.as_bytes()).encrypt;
if reached(ATTRIBUTES_FILE) || reached(&format!("sub/{ATTRIBUTES_FILE}")) {
lines.push(format!("**/{} !text !diff", folded(ATTRIBUTES_FILE, fold)));
}
if reached(CONFIG_FILE) {
lines.push(format!("/{} !text !diff", folded(CONFIG_FILE, fold)));
}
if reached(&format!("{KEY_ENVELOPE_DIR}/recipient")) {
lines.push(format!(
"/{}/** !text !diff",
folded(KEY_ENVELOPE_DIR, fold)
));
}
lines
}
fn translate(pattern: &str, fold: bool) -> Vec<String> {
let directory_only = pattern.ends_with('/');
let core = pattern.strip_suffix('/').unwrap_or(pattern);
if core.trim_matches('/').is_empty() {
return Vec::new();
}
let anchored = core.contains('/');
let mut spellings = Vec::with_capacity(2);
if !directory_only {
spellings.push(spell(&folded(&guard(core.to_string(), anchored), fold)));
}
spellings.push(spell(&folded(
&guard(
if anchored {
format!("{core}/**")
} else {
format!("**/{core}/**")
},
anchored,
),
fold,
)));
spellings
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rendering {
Global,
PerPattern { fold_case: bool },
}
const GLOBAL_LINE: &str = "* -text diff=git-xcrypt";
#[must_use]
pub fn render_lines_as_written(path: &std::path::Path, config: &Config) -> Vec<String> {
for rendering in ACCEPTED {
let lines = render_lines(config, rendering);
if desired(path, &lines).is_ok_and(|(existing, wanted)| existing == wanted) {
return lines;
}
}
render_lines(config, Rendering::Global)
}
pub const ACCEPTED: [Rendering; 3] = [
Rendering::Global,
Rendering::PerPattern { fold_case: false },
Rendering::PerPattern { fold_case: true },
];
fn folded(pattern: &str, fold: bool) -> String {
if fold {
fold_case(pattern)
} else {
pattern.to_string()
}
}
fn fold_case(pattern: &str) -> String {
let mut out = String::with_capacity(pattern.len() * 4);
let mut index = 0;
while index < pattern.len() {
let character = pattern[index..]
.chars()
.next()
.expect("index sits on a character boundary");
if character == '\\' {
match pattern[index + 1..].chars().next() {
Some(escaped) if escaped.is_ascii_alphabetic() => {
push_either_case(&mut out, escaped);
index += 1 + escaped.len_utf8();
}
Some(escaped) => {
out.push('\\');
out.push(escaped);
index += 1 + escaped.len_utf8();
}
None => {
out.push('\\');
index += 1;
}
}
continue;
}
if character == '[' {
if let Some((folded, after)) = fold_class(pattern, index) {
out.push_str(&folded);
index = after;
continue;
}
out.push('[');
index += 1;
continue;
}
if character.is_ascii_alphabetic() {
push_either_case(&mut out, character);
} else {
out.push(character);
}
index += character.len_utf8();
}
out
}
fn push_either_case(out: &mut String, letter: char) {
out.push('[');
out.push(letter.to_ascii_lowercase());
out.push(letter.to_ascii_uppercase());
out.push(']');
}
fn flip_case(letter: char) -> char {
if letter.is_ascii_lowercase() {
letter.to_ascii_uppercase()
} else {
letter.to_ascii_lowercase()
}
}
fn fold_class(pattern: &str, start: usize) -> Option<(String, usize)> {
let bytes = pattern.as_bytes();
let mut index = start + 1;
let mut body = String::new();
let mut extra = String::new();
if matches!(bytes.get(index), Some(b'!' | b'^')) {
body.push(char::from(bytes[index]));
index += 1;
}
if bytes.get(index) == Some(&b']') {
body.push(']');
index += 1;
}
while index < bytes.len() {
if bytes[index] == b']' {
let (kept, trailing_dash) = if body.ends_with('-') && !body.ends_with("\\-") {
(&body[..body.len() - 1], true)
} else {
(body.as_str(), false)
};
let mut out = String::with_capacity(body.len() + extra.len() + 2);
out.push('[');
out.push_str(kept);
out.push_str(&extra);
if trailing_dash {
out.push('-');
}
out.push(']');
return Some((out, index + 1));
}
if bytes[index] == b'[' && bytes.get(index + 1) == Some(&b':') {
let end = index + pattern[index..].find(":]")? + 2;
body.push_str(&pattern[index..end]);
match &pattern[index + 2..end - 2] {
"upper" => extra.push_str("[:lower:]"),
"lower" => extra.push_str("[:upper:]"),
_ => {}
}
index = end;
continue;
}
if bytes[index] == b'\\' {
let escaped = pattern[index + 1..].chars().next()?;
body.push('\\');
body.push(escaped);
if escaped.is_ascii_alphabetic() {
extra.push('\\');
extra.push(flip_case(escaped));
}
index += 1 + escaped.len_utf8();
continue;
}
let low = pattern[index..]
.chars()
.next()
.expect("index sits on a character boundary");
let after_low = index + low.len_utf8();
if bytes.get(after_low) == Some(&b'-')
&& bytes.get(after_low + 1).is_some_and(|byte| *byte != b']')
{
let high = pattern[after_low + 1..].chars().next()?;
body.push(low);
body.push('-');
body.push(high);
if low.is_ascii_alphabetic()
&& high.is_ascii_alphabetic()
&& low.is_ascii_lowercase() == high.is_ascii_lowercase()
{
extra.push(flip_case(low));
extra.push('-');
extra.push(flip_case(high));
}
index = after_low + 1 + high.len_utf8();
continue;
}
body.push(low);
if low.is_ascii_alphabetic() {
extra.push(flip_case(low));
}
index = after_low;
}
None
}
const MACRO_PREFIX: &str = "[attr]";
fn guard(spelling: String, anchored: bool) -> String {
if !spelling.starts_with(MACRO_PREFIX) && !spelling.starts_with('!') {
return spelling;
}
if anchored {
format!("/{spelling}")
} else {
format!("**/{spelling}")
}
}
fn spell(pattern: &str) -> String {
if !pattern.contains(char::is_whitespace)
&& !pattern.starts_with('"')
&& !pattern.starts_with('#')
{
return pattern.to_string();
}
let mut quoted = String::with_capacity(pattern.len() + 2);
quoted.push('"');
for character in pattern.chars() {
match character {
'"' | '\\' => {
quoted.push('\\');
quoted.push(character);
}
'\t' => quoted.push_str("\\t"),
'\r' => quoted.push_str("\\r"),
'\n' => quoted.push_str("\\n"),
other => quoted.push(other),
}
}
quoted.push('"');
quoted
}
#[must_use]
pub fn render_section(extra_lines: &[String]) -> String {
render_section_with(extra_lines, "\n")
}
#[must_use]
pub fn render_section_with(extra_lines: &[String], ending: &str) -> String {
let mut out = String::new();
out.push_str(BEGIN);
out.push_str(ending);
out.push_str(CATCH_ALL);
out.push_str(ending);
for line in extra_lines {
out.push_str(line);
out.push_str(ending);
}
out.push_str(END);
out.push_str(ending);
out
}
fn line_ending_of(contents: &str) -> &'static str {
let sample = marker_line(contents, BEGIN)
.map(|(begin, after)| &contents[begin..after])
.or_else(|| contents.split_inclusive('\n').next())
.unwrap_or_default();
if sample.ends_with("\r\n") {
"\r\n"
} else {
"\n"
}
}
#[must_use]
pub fn has_section(contents: &str) -> bool {
contents.contains(BEGIN)
}
fn marker_line(contents: &str, marker: &str) -> Option<(usize, usize)> {
let mut offset = 0;
for line in contents.split_inclusive('\n') {
let text = line.strip_suffix('\n').unwrap_or(line);
let text = text.strip_suffix('\r').unwrap_or(text);
if text == marker {
return Some((offset, offset + line.len()));
}
offset += line.len();
}
None
}
pub fn upsert(contents: &str, section: &str) -> Result<String> {
let Some((begin, _)) = marker_line(contents, BEGIN) else {
if marker_line(contents, END).is_some() {
return Err(Error::Config(format!(
"{ATTRIBUTES}: found the closing git-xcrypt marker without the opening one; \
fix it by hand so nothing of yours is lost",
ATTRIBUTES = crate::git::repo::ATTRIBUTES_FILE
)));
}
let mut out = contents.to_string();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(section);
return Ok(out);
};
let Some((_, after_end)) = marker_line(&contents[begin..], END) else {
return Err(Error::Config(format!(
"{ATTRIBUTES}: the git-xcrypt section is opened but never closed; \
fix it by hand so nothing of yours is lost",
ATTRIBUTES = crate::git::repo::ATTRIBUTES_FILE
)));
};
let rest = &contents[begin + after_end..];
if marker_line(rest, BEGIN).is_some() || marker_line(rest, END).is_some() {
return Err(Error::Config(format!(
"{ATTRIBUTES}: it carries more than one git-xcrypt section. Only the first \
would be kept up to date, and git takes the last matching line, so the \
stale copy would win. Delete all but one by hand, then run \
`git-xcrypt sync`.",
ATTRIBUTES = crate::git::repo::ATTRIBUTES_FILE
)));
}
let mut out = String::with_capacity(contents.len() + section.len());
out.push_str(&contents[..begin]);
out.push_str(section);
out.push_str(rest);
Ok(out)
}
pub fn read(path: &Path) -> Result<String> {
match fs::read_to_string(path) {
Ok(text) => Ok(text),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(err) if err.kind() == std::io::ErrorKind::InvalidData => Err(Error::Config(format!(
"{}: not valid UTF-8, so the managed section cannot be edited safely; \
fix the file by hand",
path.display()
))),
Err(err) => Err(Error::Io(err)),
}
}
pub fn desired(path: &Path, extra_lines: &[String]) -> Result<(String, String)> {
let existing = read(path)?;
let section = render_section_with(extra_lines, line_ending_of(&existing));
let updated = upsert(&existing, §ion)?;
Ok((existing, updated))
}
pub fn write_section(path: &Path, extra_lines: &[String]) -> Result<bool> {
let (existing, updated) = desired(path, extra_lines)?;
if updated == existing {
return Ok(false);
}
crate::util::atomic::write(path, updated.as_bytes())?;
Ok(true)
}
pub fn catch_all_present(path: &Path) -> Result<bool> {
Ok(read(path)?.lines().any(|line| line.trim_end() == CATCH_ALL))
}
pub fn foreign_lines_touching(path: &Path, axes: &[&str]) -> Result<Vec<String>> {
let text = read(path)?;
let mut inside = false;
let mut found = Vec::new();
for line in text.lines() {
let trimmed = line.trim_end().trim_end_matches('\r');
if trimmed == BEGIN {
inside = true;
continue;
}
if trimmed == END {
inside = false;
continue;
}
if inside || trimmed.is_empty() || trimmed.trim_start().starts_with('#') {
continue;
}
let attributes = trimmed
.split_once(char::is_whitespace)
.map(|(_, rest)| rest);
if attributes.is_some_and(|rest| {
rest.split_whitespace().any(|token| {
axes.iter().any(|axis| {
let bare = token
.strip_prefix('-')
.or_else(|| token.strip_prefix('!'))
.unwrap_or(token);
bare == *axis || bare.starts_with(&format!("{axis}="))
})
})
}) {
found.push(trimmed.trim().to_string());
}
}
Ok(found)
}
fn collect_attribute_files(root: &Path, out: &mut Vec<PathBuf>) {
let mut pending = vec![root.to_path_buf()];
while let Some(directory) = pending.pop() {
let file = directory.join(crate::git::repo::ATTRIBUTES_FILE);
if fs::symlink_metadata(&file).is_ok_and(|metadata| metadata.is_file()) {
out.push(file);
}
let Ok(entries) = fs::read_dir(&directory) else {
continue;
};
for entry in entries.flatten() {
let Ok(kind) = entry.file_type() else {
continue;
};
if kind.is_symlink() {
continue;
}
if kind.is_dir() && entry.file_name() != std::ffi::OsStr::new(".git") {
pending.push(entry.path());
}
}
}
}
#[must_use]
pub fn attribute_files_under(work_tree: &Path) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = Vec::new();
collect_attribute_files(work_tree, &mut files);
files.sort_by_key(|path| (path.components().count(), path.clone()));
files
}
#[derive(Debug, Clone)]
pub struct StagedAttributes {
pub path: PathBuf,
pub contents: Vec<u8>,
}
#[must_use]
pub fn staged_fallbacks(
work_tree: &Path,
index_path: &Path,
common_dir: &Path,
hash: gix_hash::Kind,
ignore_case: bool,
) -> Vec<StagedAttributes> {
let Ok(crate::git::index::Listed::Read(entries)) = crate::git::index::list(index_path, hash)
else {
return Vec::new();
};
let wanted = crate::git::repo::ATTRIBUTES_FILE.as_bytes();
let named = |name: &[u8]| {
if ignore_case {
name.eq_ignore_ascii_case(wanted)
} else {
name == wanted
}
};
use gix_object::Find as _;
let basename_of = |entry: &crate::git::index::Tracked| -> Vec<u8> {
entry
.path
.rsplit(|&byte| byte == b'/')
.next()
.unwrap_or(&entry.path)
.to_vec()
};
let mut candidates: Vec<&crate::git::index::Tracked> = entries
.iter()
.filter(|entry| entry.holds_content() && named(&basename_of(entry)))
.collect();
candidates.sort_by_key(|entry| basename_of(entry) != wanted);
let mut seen: Vec<Vec<u8>> = Vec::new();
let mut objects = None;
let mut buffer = Vec::new();
let mut found = Vec::new();
for entry in candidates {
if ignore_case {
let folded = entry.path.to_ascii_lowercase();
if seen.contains(&folded) {
continue;
}
seen.push(folded);
}
let absolute = work_tree.join(crate::git::repo::working_tree_path(&entry.path));
if fs::symlink_metadata(&absolute).is_ok_and(|metadata| metadata.is_file()) {
continue;
}
if objects.is_none() {
objects = Some(crate::git::history::objects(common_dir, hash).ok());
}
let Some(Some(store)) = objects.as_ref() else {
return Vec::new();
};
let Ok(id) = gix_hash::oid::try_from_bytes(&entry.id) else {
continue;
};
if let Ok(Some(data)) = store.try_find(id, &mut buffer) {
found.push(StagedAttributes {
path: absolute,
contents: data.data.to_vec(),
});
}
}
found
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilterAttribute {
Ours,
Foreign(String),
Set,
Unset,
Unspecified,
}
impl FilterAttribute {
#[must_use]
pub fn is_ours(&self) -> bool {
matches!(self, Self::Ours)
}
#[must_use]
pub fn as_check_attr(&self) -> &str {
match self {
Self::Ours => DRIVER,
Self::Foreign(value) => value,
Self::Set => "set",
Self::Unset => "unset",
Self::Unspecified => "unspecified",
}
}
}
impl std::fmt::Display for FilterAttribute {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_check_attr())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Culprit {
pub source: Option<PathBuf>,
pub line: usize,
pub pattern: String,
pub assignment: String,
}
impl std::fmt::Display for Culprit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.source {
Some(source) => write!(
f,
"{}:{}: {} {}",
git_spelling(source),
self.line,
self.pattern,
self.assignment
),
None => write!(f, "{} {}", self.pattern, self.assignment),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EolConversion {
Off,
On(Culprit),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeclaredEol {
Unspecified,
Lf,
Crlf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolution {
pub filter: FilterAttribute,
pub conversion: EolConversion,
pub eol: DeclaredEol,
}
impl Resolution {
#[must_use]
pub fn expands_on_checkout(
&self,
autocrlf: Option<&str>,
core_eol: Option<&str>,
) -> Option<&Culprit> {
let EolConversion::On(culprit) = &self.conversion else {
return None;
};
let writes_crlf = match self.eol {
DeclaredEol::Crlf => true,
DeclaredEol::Lf => false,
DeclaredEol::Unspecified => crate::rules::eol::git_writes_crlf(autocrlf, core_eol),
};
writes_crlf.then_some(culprit)
}
}
type Resolved = (gix_attributes::State, Culprit);
fn spell_assignment(assignment: gix_attributes::AssignmentRef<'_>) -> String {
use gix_attributes::StateRef;
let name = assignment.name.as_str();
match assignment.state {
StateRef::Set => name.to_string(),
StateRef::Unset => format!("-{name}"),
StateRef::Unspecified => format!("!{name}"),
StateRef::Value(value) => format!("{name}={}", value.as_bstr()),
}
}
enum CrlfAction<'a> {
Convert(&'a Culprit),
ConvertAsInput(&'a Culprit),
Binary,
Auto,
}
fn crlf_action(resolved: Option<&Resolved>) -> Option<CrlfAction<'_>> {
use gix_attributes::State;
match resolved {
Some((State::Set, culprit)) => Some(CrlfAction::Convert(culprit)),
Some((State::Unset, _)) => Some(CrlfAction::Binary),
Some((State::Value(value), culprit)) => match value.as_ref().as_bstr() {
value if value == "auto" => Some(CrlfAction::Auto),
value if value == "input" => Some(CrlfAction::ConvertAsInput(culprit)),
_ => None,
},
Some((State::Unspecified, _)) | None => None,
}
}
fn converts(
text: Option<&Resolved>,
crlf: Option<&Resolved>,
eol: Option<&Resolved>,
) -> EolConversion {
use gix_attributes::State;
match crlf_action(text).or_else(|| crlf_action(crlf)) {
Some(CrlfAction::Convert(culprit) | CrlfAction::ConvertAsInput(culprit)) => {
return EolConversion::On(culprit.clone());
}
Some(CrlfAction::Binary | CrlfAction::Auto) => return EolConversion::Off,
None => {}
}
match eol {
Some((State::Value(value), culprit)) => {
let value = value.as_ref().as_bstr();
if value == "lf" || value == "crlf" {
EolConversion::On(culprit.clone())
} else {
EolConversion::Off
}
}
_ => EolConversion::Off,
}
}
pub struct AttributeResolver {
search: gix_attributes::Search,
outcome: gix_attributes::search::Outcome,
case: gix_glob::pattern::Case,
work_tree: PathBuf,
info: PathBuf,
global: Option<PathBuf>,
staged: Vec<StagedAttributes>,
on_disk: Vec<PathBuf>,
probed: std::collections::HashSet<Vec<u8>>,
}
impl AttributeResolver {
#[must_use]
pub fn new(
work_tree: &Path,
common_dir: &Path,
global: Option<&Path>,
ignore_case: bool,
staged: Vec<StagedAttributes>,
) -> Self {
let info = common_dir.join("info").join("attributes");
let (search, outcome) = Self::assemble(work_tree, global, &staged, &[], &info);
Self {
search,
outcome,
case: if ignore_case {
gix_glob::pattern::Case::Fold
} else {
gix_glob::pattern::Case::Sensitive
},
work_tree: work_tree.to_path_buf(),
info,
global: global.map(Path::to_path_buf),
staged,
on_disk: Vec::new(),
probed: std::collections::HashSet::new(),
}
}
fn assemble(
work_tree: &Path,
global: Option<&Path>,
staged: &[StagedAttributes],
on_disk: &[PathBuf],
info: &Path,
) -> (gix_attributes::Search, gix_attributes::search::Outcome) {
let mut collection = gix_attributes::search::MetadataCollection::default();
let mut buf: Vec<u8> = Vec::new();
let mut search = gix_attributes::Search::new_globals(
global
.map(Path::to_path_buf)
.into_iter()
.collect::<Vec<_>>(),
&mut buf,
&mut collection,
)
.unwrap_or_default();
let mut tree_sources: Vec<(&Path, Option<&[u8]>)> = on_disk
.iter()
.map(|path| (path.as_path(), None))
.chain(
staged
.iter()
.map(|fallback| (fallback.path.as_path(), Some(&fallback.contents[..]))),
)
.collect();
tree_sources.sort_by_key(|(path, _)| (path.components().count(), path.to_path_buf()));
for (source, contents) in tree_sources {
let is_root = source.parent() == Some(work_tree);
match contents {
None => {
let _ = search.add_patterns_file(
source.to_path_buf(),
true,
Some(work_tree),
&mut buf,
&mut collection,
is_root,
);
}
Some(contents) => search.add_patterns_buffer(
contents,
source.to_path_buf(),
Some(work_tree),
&mut collection,
is_root,
),
}
}
let _ = search.add_patterns_file(
info.to_path_buf(),
true,
None,
&mut buf,
&mut collection,
true,
);
let mut outcome = gix_attributes::search::Outcome::default();
outcome.initialize_with_selection(&collection, ["filter", "text", "eol", "crlf"]);
(search, outcome)
}
fn probe_ancestors(&mut self, relative_path: &[u8]) {
if relative_path.first() == Some(&b'/') {
return;
}
let mut discovered = false;
let root: &[u8] = b"";
let ancestors = std::iter::once(root).chain(
relative_path
.iter()
.enumerate()
.filter(|&(_, &byte)| byte == b'/')
.map(|(at, _)| &relative_path[..at]),
);
for directory in ancestors {
if self.probed.contains(directory) {
continue;
}
self.probed.insert(directory.to_vec());
let file = self
.work_tree
.join(crate::git::repo::working_tree_path(directory))
.join(ATTRIBUTES_FILE);
if fs::symlink_metadata(&file).is_ok_and(|metadata| metadata.is_file()) {
self.on_disk.push(file);
discovered = true;
}
}
if discovered {
let (search, outcome) = Self::assemble(
&self.work_tree,
self.global.as_deref(),
&self.staged,
&self.on_disk,
&self.info,
);
self.search = search;
self.outcome = outcome;
}
}
pub fn resolve(&mut self, relative_path: &[u8]) -> Resolution {
use gix_attributes::State;
self.probe_ancestors(relative_path);
self.outcome.reset();
self.search.pattern_matching_relative_path(
bstr::BStr::new(relative_path),
self.case,
Some(false),
&mut self.outcome,
);
let mut found = self.outcome.iter_selected().map(|matched| {
(
matched.assignment.state.to_owned(),
Culprit {
source: matched.location.source.map(Path::to_path_buf),
line: matched.location.sequence_number,
pattern: matched.pattern.to_string(),
assignment: spell_assignment(matched.assignment),
},
)
});
let filter = found.next();
let text = found.next();
let eol = found.next();
let crlf = found.next();
let checked_in_as_input = matches!(
crlf_action(text.as_ref()).or_else(|| crlf_action(crlf.as_ref())),
Some(CrlfAction::ConvertAsInput(_))
);
Resolution {
filter: filter.map_or(FilterAttribute::Unspecified, |(state, _)| match state {
State::Value(value) => {
let value = value.as_ref().as_bstr().to_string();
if value == DRIVER {
FilterAttribute::Ours
} else {
FilterAttribute::Foreign(value)
}
}
State::Set => FilterAttribute::Set,
State::Unset => FilterAttribute::Unset,
State::Unspecified => FilterAttribute::Unspecified,
}),
conversion: converts(text.as_ref(), crlf.as_ref(), eol.as_ref()),
eol: {
let declared = match eol.as_ref().map(|(state, _)| state) {
Some(State::Value(value)) => match value.as_ref().as_bstr() {
value if value == "lf" => DeclaredEol::Lf,
value if value == "crlf" => DeclaredEol::Crlf,
_ => DeclaredEol::Unspecified,
},
_ => DeclaredEol::Unspecified,
};
match declared {
DeclaredEol::Unspecified if checked_in_as_input => DeclaredEol::Lf,
declared => declared,
}
},
}
}
}
#[must_use]
pub fn driver_keys() -> [String; 2] {
[
format!("filter.{DRIVER}.process"),
format!("filter.{DRIVER}.required"),
]
}
#[cfg(test)]
mod tests {
use super::*;
fn lines(config: &str) -> Vec<String> {
render_lines(
&Config::parse(config).expect("the test configuration must parse"),
Rendering::PerPattern { fold_case: true },
)
}
#[test]
fn a_directory_pattern_covers_the_subtree_at_any_depth() {
assert_eq!(
lines("secrets/\n"),
["**/[sS][eE][cC][rR][eE][tT][sS]/** filter=git-xcrypt -text diff=git-xcrypt"]
);
}
#[test]
fn folding_leaves_every_other_construct_meaning_what_it_meant() {
let rows: &[(&str, &str, &str)] = &[
("a plain name", "secrets", "[sS][eE][cC][rR][eE][tT][sS]"),
("digits and punctuation", "a1-_.b", "[aA]1-_.[bB]"),
("wildcards are untouched", "*.e?v", "*.[eE]?[vV]"),
("a glob escape on a letter", "\\a", "[aA]"),
("a glob escape on a metacharacter", "\\*x", "\\*[xX]"),
("a character class gains its counterpart", "[ab]", "[abAB]"),
("a range gains its counterpart range", "[a-z]", "[a-zA-Z]"),
(
"a mixed class keeps its non-letters",
"[a-z_0-9]",
"[a-z_0-9A-Z]",
),
("a negated class folds too", "[!ab]", "[!abAB]"),
("…including the other spelling of it", "[^a]", "[^aA]"),
("a `]` first is a member", "[]a]", "[]aA]"),
("a `-` last is a member", "[a-]", "[aA-]"),
("a `-` after a range is a member", "[a-z-]", "[a-zA-Z-]"),
("an escaped `-` last stays put", "[a\\-]", "[a\\-A]"),
(
"a POSIX class is named, not spelled",
"[[:alpha:]]",
"[[:alpha:]]",
),
(
"…and its neighbours still fold",
"[[:digit:]x]",
"[[:digit:]xX]",
),
(
"the upper class gains the lower",
"[[:upper:]]",
"[[:upper:][:lower:]]",
),
(
"the lower class gains the upper",
"[[:lower:]]",
"[[:lower:][:upper:]]",
),
(
"…negated too, so it keeps refusing every letter",
"[![:upper:]]",
"[![:upper:][:lower:]]",
),
("an escape inside a class", "[\\a\\]]", "[\\a\\]\\A]"),
("an unterminated class is a literal", "[ab", "[[aA][bB]"),
(
"nothing outside ASCII is touched",
"\u{142}\u{105}ka",
"\u{142}\u{105}[kK][aA]",
),
];
for (label, pattern, expected) in rows {
assert_eq!(
fold_case(pattern),
*expected,
"{label}: `{pattern}` folded wrongly"
);
}
}
#[test]
fn a_macro_opening_is_still_recognised_before_anything_is_folded() {
assert_eq!(
lines("[attr]odd.env\n"),
[
"**/[attrATTR][oO][dD][dD].[eE][nN][vV] filter=git-xcrypt -text diff=git-xcrypt",
"**/[attrATTR][oO][dD][dD].[eE][nN][vV]/** filter=git-xcrypt -text diff=git-xcrypt",
]
);
}
struct Source<'a> {
path: &'static str,
body: &'a str,
}
fn agrees_with_git(sources: &[Source<'_>], path: &str, global: Option<&str>) {
use std::process::Command;
let dir = tempfile::TempDir::new().expect("temporary directory");
let root = dir.path();
assert!(
Command::new("git")
.args(["init", "-q"])
.current_dir(root)
.status()
.expect("git must be on PATH")
.success(),
"git init failed"
);
for source in sources {
let target = root.join(source.path);
fs::create_dir_all(target.parent().expect("a parent")).expect("directories");
fs::write(&target, source.body).expect("writing an attributes file");
}
let global_path = global.map(|body| {
let path = root.join("global-attributes");
fs::write(&path, body).expect("writing the global attributes file");
assert!(
Command::new("git")
.args(["config", "core.attributesFile"])
.arg(&path)
.current_dir(root)
.status()
.expect("git")
.success()
);
path
});
let target = root.join(path);
fs::create_dir_all(target.parent().expect("a parent")).expect("directories");
fs::write(&target, b"content\n").expect("writing the subject file");
let ask = |attribute: &str| -> String {
let output = Command::new("git")
.args(["check-attr", attribute, "--", path])
.current_dir(root)
.output()
.expect("git check-attr");
String::from_utf8(output.stdout)
.expect("check-attr prints text")
.rsplit(": ")
.next()
.expect("check-attr always prints a value")
.trim()
.to_string()
};
let mut resolver = AttributeResolver::new(
root,
&root.join(".git"),
global_path.as_deref(),
false,
Vec::new(),
);
let ours = resolver.resolve(path.as_bytes());
assert_eq!(
ours.filter.as_check_attr(),
ask("filter"),
"git and git-xcrypt disagree about `filter` for {path}"
);
let (text, eol, crlf) = (ask("text"), ask("eol"), ask("crlf"));
let action = |value: &str| match value {
"set" | "input" => Some(true),
"unset" | "auto" => Some(false),
_ => None,
};
let converts = action(&text)
.or_else(|| action(&crlf))
.unwrap_or(matches!(eol.as_str(), "lf" | "crlf"));
assert_eq!(
matches!(ours.conversion, EolConversion::On(_)),
converts,
"git says text={text} eol={eol} crlf={crlf} for {path}, and \
git-xcrypt read the stack differently"
);
}
#[test]
fn the_filter_attribute_is_resolved_exactly_as_git_resolves_it() {
let catch_all = "# >>> git-xcrypt >>>\n* filter=git-xcrypt\n# <<< git-xcrypt <<<\n";
agrees_with_git(
&[Source {
path: ".gitattributes",
body: catch_all,
}],
"secrets/db.env",
None,
);
agrees_with_git(
&[Source {
path: ".gitattributes",
body: &format!("{catch_all}secrets/** -filter\n"),
}],
"secrets/db.env",
None,
);
agrees_with_git(
&[Source {
path: ".gitattributes",
body: &format!("{catch_all}secrets/** -text\nsecrets/** text=input\n"),
}],
"secrets/db.env",
None,
);
agrees_with_git(
&[Source {
path: ".gitattributes",
body: &format!("{catch_all}secrets/** text=junk\nsecrets/** crlf\n"),
}],
"secrets/db.env",
None,
);
agrees_with_git(
&[Source {
path: ".gitattributes",
body: &format!("{catch_all}secrets/** -crlf\nsecrets/** eol=lf\n"),
}],
"secrets/db.env",
None,
);
agrees_with_git(
&[
Source {
path: ".gitattributes",
body: catch_all,
},
Source {
path: "secrets/.gitattributes",
body: "* -filter\n",
},
],
"secrets/db.env",
None,
);
agrees_with_git(
&[
Source {
path: ".gitattributes",
body: catch_all,
},
Source {
path: ".git/info/attributes",
body: "secrets/** -filter\n",
},
],
"secrets/db.env",
None,
);
agrees_with_git(
&[
Source {
path: ".gitattributes",
body: catch_all,
},
Source {
path: "secrets/.gitattributes",
body: "* -filter\n",
},
Source {
path: ".git/info/attributes",
body: "secrets/** filter=git-xcrypt\n",
},
],
"secrets/db.env",
None,
);
agrees_with_git(
&[Source {
path: ".gitattributes",
body: &format!("{catch_all}* text\n**/[[:upper:][:lower:]][dD][iI][rR]/** -text\n"),
}],
"xdir/db.env",
None,
);
agrees_with_git(
&[Source {
path: ".gitattributes",
body: &format!("{catch_all}*.psd filter=lfs\n"),
}],
"secrets/db.env",
None,
);
agrees_with_git(
&[Source {
path: ".gitattributes",
body: &format!("{catch_all}*.env filter=lfs\n"),
}],
"secrets/db.env",
None,
);
agrees_with_git(
&[Source {
path: ".gitattributes",
body: &format!("[attr]plain -filter\n{catch_all}secrets/** plain\n"),
}],
"secrets/db.env",
None,
);
agrees_with_git(
&[Source {
path: ".gitattributes",
body: &format!("{catch_all}secrets/** !filter\n"),
}],
"secrets/db.env",
None,
);
agrees_with_git(
&[Source {
path: ".gitattributes",
body: catch_all,
}],
"secrets/db.env",
Some("* -filter\n"),
);
agrees_with_git(&[], "secrets/db.env", Some("* filter=git-xcrypt\n"));
}
#[test]
fn the_order_paths_are_resolved_in_never_changes_an_answer() {
use std::process::Command;
let sources = [
Source {
path: ".gitattributes",
body: "* filter=git-xcrypt\n*.env text\n",
},
Source {
path: "a/.gitattributes",
body: "*.env -text\n",
},
Source {
path: "a/b/.gitattributes",
body: "*.env text\n",
},
Source {
path: ".git/info/attributes",
body: "a/b/deep.env -text\n",
},
];
let paths = [
"a/b/deep.env",
"top.env",
"a/mid.env",
"a/c/side.env",
"notes.txt",
];
let permutations: [[usize; 5]; 3] = [[0, 1, 2, 3, 4], [4, 1, 2, 3, 0], [3, 0, 4, 2, 1]];
let dir = tempfile::TempDir::new().expect("temporary directory");
let root = dir.path();
assert!(
Command::new("git")
.args(["init", "-q"])
.current_dir(root)
.status()
.expect("git must be on PATH")
.success(),
"git init failed"
);
for source in &sources {
let target = root.join(source.path);
fs::create_dir_all(target.parent().expect("a parent")).expect("directories");
fs::write(&target, source.body).expect("writing an attributes file");
}
let global = root.join("global-attributes");
fs::write(&global, "*.env -filter\n*.txt eol=crlf\n")
.expect("writing the global attributes file");
for path in paths {
let target = root.join(path);
fs::create_dir_all(target.parent().expect("a parent")).expect("directories");
fs::write(&target, b"content\n").expect("writing a subject file");
}
assert!(
Command::new("git")
.args(["config", "core.attributesFile"])
.arg(&global)
.current_dir(root)
.status()
.expect("git")
.success()
);
let ask = |attribute: &str, path: &str| -> String {
let output = Command::new("git")
.args(["check-attr", attribute, "--", path])
.current_dir(root)
.output()
.expect("git check-attr");
String::from_utf8(output.stdout)
.expect("check-attr prints text")
.rsplit(": ")
.next()
.expect("check-attr always prints a value")
.trim()
.to_string()
};
for ignore_case in [false, true] {
assert!(
Command::new("git")
.args([
"config",
"core.ignorecase",
if ignore_case { "true" } else { "false" }
])
.current_dir(root)
.status()
.expect("git")
.success()
);
for permutation in permutations {
let mut resolver = AttributeResolver::new(
root,
&root.join(".git"),
Some(&global),
ignore_case,
Vec::new(),
);
for at in permutation {
let path = paths[at];
let ours = resolver.resolve(path.as_bytes());
assert_eq!(
ours.filter.as_check_attr(),
ask("filter", path),
"`filter` for {path} depends on the discovery order \
{permutation:?} (core.ignorecase={ignore_case})"
);
let (text, eol_answer, crlf) =
(ask("text", path), ask("eol", path), ask("crlf", path));
let action = |value: &str| match value {
"set" | "input" => Some(true),
"unset" | "auto" => Some(false),
_ => None,
};
let converts = action(&text)
.or_else(|| action(&crlf))
.unwrap_or(matches!(eol_answer.as_str(), "lf" | "crlf"));
assert_eq!(
matches!(ours.conversion, EolConversion::On(_)),
converts,
"conversion for {path} depends on the discovery order \
{permutation:?} (core.ignorecase={ignore_case})"
);
let eol = match ours.eol {
DeclaredEol::Lf => "lf",
DeclaredEol::Crlf => "crlf",
DeclaredEol::Unspecified => "unspecified",
};
assert_eq!(
eol, eol_answer,
"`eol` for {path} depends on the discovery order \
{permutation:?} (core.ignorecase={ignore_case})"
);
}
}
}
}
#[test]
fn a_staged_fallback_keeps_its_place_whatever_the_discovery_order() {
use std::process::Command;
let dir = tempfile::TempDir::new().expect("temporary directory");
let root = dir.path();
assert!(
Command::new("git")
.args(["init", "-q"])
.current_dir(root)
.status()
.expect("git must be on PATH")
.success(),
"git init failed"
);
fs::write(root.join(".gitattributes"), "*.env text\n").expect("root attributes");
fs::create_dir_all(root.join("a/b")).expect("directories");
fs::write(root.join("a/b/.gitattributes"), "*.env text\n").expect("deep attributes");
let staged = vec![StagedAttributes {
path: root.join("a/.gitattributes"),
contents: b"*.env -text\n".to_vec(),
}];
for order in [["a/b/deep.env", "a/mid.env"], ["a/mid.env", "a/b/deep.env"]] {
let mut resolver =
AttributeResolver::new(root, &root.join(".git"), None, false, staged.clone());
for path in order {
let ours = resolver.resolve(path.as_bytes());
let expected_conversion = match path {
"a/b/deep.env" => true,
"a/mid.env" => false,
_ => unreachable!(),
};
assert_eq!(
matches!(ours.conversion, EolConversion::On(_)),
expected_conversion,
"the staged fallback lost its place for {path} when paths \
were resolved in the order {order:?}"
);
}
}
}
}