use std::fmt;
use std::path::{Path, PathBuf};
use bstr::ByteSlice as _;
use gix_config::File;
use gix_config::file::Metadata;
use crate::{Error, Result};
#[must_use]
pub fn global_attributes_file(config: &File) -> Option<PathBuf> {
global_attributes_file_for(
config,
gix_path::env::home_dir().as_deref(),
std::env::var_os("XDG_CONFIG_HOME").as_deref(),
)
}
#[must_use]
fn global_attributes_file_for(
config: &File,
home: Option<&Path>,
xdg_config_home: Option<&std::ffi::OsStr>,
) -> Option<PathBuf> {
let Ok(value) = config.raw_value("core.attributesFile") else {
if let Some(xdg) = xdg_config_home
&& !xdg.is_empty()
{
return Some(PathBuf::from(xdg).join("git").join("attributes"));
}
return Some(home?.join(".config").join("git").join("attributes"));
};
if value.is_empty() {
return None;
}
gix_config::Path::from(value)
.interpolate(gix_config::path::interpolate::Context {
home_dir: home,
..Default::default()
})
.ok()
}
pub fn open_local(path: &Path) -> Result<File> {
read_optional(path, gix_config::Source::Local)
}
fn read_optional(path: &Path, source: gix_config::Source) -> Result<File> {
if !path.exists() {
return Ok(File::new(Metadata::from(source)));
}
File::from_path_no_includes(path.to_path_buf(), source)
.map_err(|err| Error::Config(format!("could not read {}: {err}", path.display())))
}
pub fn open_full(git_dir: &Path, common_dir: &Path) -> Result<File> {
let broken =
|err: &dyn fmt::Display| Error::Config(format!("could not read git configuration: {err}"));
let mut local = read_optional(&common_dir.join("config"), gix_config::Source::Local)?;
let worktree = get(&local, "extensions.worktreeConfig")
.is_some_and(|value| is_true(&value))
.then(|| {
read_optional(
&git_dir.join("config.worktree"),
gix_config::Source::Worktree,
)
})
.transpose()?;
let home = gix_path::env::home_dir();
let options = gix_config::file::init::Options {
includes: gix_config::file::includes::Options::follow(
gix_config::path::interpolate::Context {
home_dir: home.as_deref(),
..Default::default()
},
gix_config::file::includes::conditional::Context {
git_dir: Some(git_dir),
branch_name: None,
},
),
..Default::default()
};
let mut config = File::from_globals().map_err(|err| broken(&err))?;
config
.resolve_includes(options)
.map_err(|err| broken(&err))?;
local
.resolve_includes(options)
.map_err(|err| broken(&err))?;
config.append(local).map_err(|err| broken(&err))?;
if let Some(mut worktree) = worktree {
worktree
.resolve_includes(options)
.map_err(|err| broken(&err))?;
config.append(worktree).map_err(|err| broken(&err))?;
}
config
.append(File::from_environment_overrides().map_err(|err| broken(&err))?)
.map_err(|err| broken(&err))?;
if let Some(overrides) = cli_overrides(std::env::var_os(CLI_OVERRIDE_ENV).as_deref()) {
config.append(overrides).map_err(|err| broken(&err))?;
}
Ok(config)
}
const CLI_OVERRIDE_ENV: &str = "GIT_CONFIG_PARAMETERS";
fn cli_overrides(raw: Option<&std::ffi::OsStr>) -> Option<File> {
let words = split_quoted(raw?.to_str()?)?;
let mut file = File::new(Metadata::from(gix_config::Source::Cli));
let mut any = false;
for word in words {
let (key, value) = match word.split_once('=') {
Some((key, value)) => (key.to_string(), value.to_string()),
None => (word, "true".to_string()),
};
if gix_config::AsKey::try_as_key(&key.as_str()).is_none() {
continue;
}
if file.set_raw_value(key.as_str(), value.as_str()).is_ok() {
any = true;
}
}
any.then_some(file)
}
fn split_quoted(line: &str) -> Option<Vec<String>> {
let mut words = Vec::new();
let mut current = String::new();
let mut started = false;
let mut quoted = false;
let mut chars = line.chars();
while let Some(character) = chars.next() {
match character {
'\'' => {
quoted = !quoted;
started = true;
}
'\\' if !quoted => {
current.push(chars.next()?);
started = true;
}
character if character.is_whitespace() && !quoted => {
if started {
words.push(std::mem::take(&mut current));
started = false;
}
}
character => {
current.push(character);
started = true;
}
}
}
if quoted {
return None;
}
if started {
words.push(current);
}
Some(words)
}
pub fn save_local(path: &Path, config: &File) -> Result<()> {
crate::util::atomic::write(path, &config.to_bstring())
}
pub fn set(config: &mut File, key: &str, value: &str) -> Result<()> {
config
.set_raw_value(key, value)
.map(|_| ())
.map_err(|err| Error::Config(format!("could not set {key}: {err}")))
}
pub fn unset(config: &mut File, key: &str) -> Result<()> {
let (section_key, name) = key
.rsplit_once('.')
.ok_or_else(|| Error::Config(format!("`{key}` is not a dotted configuration key")))?;
if let Ok(mut section) = config.section_mut_by_key(section_key) {
while section.remove(name).is_some() {}
}
Ok(())
}
#[must_use]
pub fn get(config: &File, key: &str) -> Option<String> {
gix_config::AsKey::try_as_key(&key)?;
if let Ok(value) = config.raw_value(key) {
return Some(value.to_string());
}
let (section_key, name) = key.rsplit_once('.')?;
let (section, subsection) = match section_key.split_once('.') {
Some((section, subsection)) => (section, Some(subsection.as_bytes().as_bstr())),
None => (section_key, None),
};
let present = config
.sections_by_name(section)?
.filter(|section| section.header().subsection_name() == subsection)
.any(|section| section.value_names().any(|value_name| value_name == name));
present.then(|| "true".to_string())
}
#[must_use]
pub fn is_true(value: &str) -> bool {
matches!(
value.to_ascii_lowercase().as_str(),
"true" | "yes" | "on" | "1"
)
}
#[cfg(test)]
mod tests {
use std::ffi::OsStr;
use super::*;
use tempfile::TempDir;
#[test]
fn the_global_attributes_file_resolves_where_git_resolves_it() {
let home = Path::new("/home/user");
let resolve = |contents: &str, xdg: Option<&str>| {
let file = File::try_from(contents).expect("the fixture is valid configuration");
global_attributes_file_for(&file, Some(home), xdg.map(std::ffi::OsStr::new))
};
assert_eq!(
resolve("[core]\n", None),
Some(home.join(".config").join("git").join("attributes")),
"unset must fall back to the XDG default, which is where git looks"
);
assert_eq!(
resolve("[core]\n", Some("/xdg")),
Some(Path::new("/xdg").join("git").join("attributes")),
"XDG_CONFIG_HOME must win over the $HOME/.config default"
);
assert_eq!(
resolve("[core]\n", Some("")),
Some(home.join(".config").join("git").join("attributes")),
"git treats an empty XDG_CONFIG_HOME as unset, so this must too"
);
assert_eq!(
resolve("[core]\n\tattributesFile = ~/attrs\n", None),
Some(home.join("attrs")),
"`~/` must be expanded, exactly as git expands `core.excludesFile`"
);
assert_eq!(
resolve(
"[core]\n\tattributesFile = /elsewhere/attrs\n",
Some("/xdg")
),
Some(PathBuf::from("/elsewhere/attrs")),
"an absolute path must be taken as written, XDG or no XDG"
);
assert_eq!(
resolve("[core]\n\tattributesFile = \n", Some("/xdg")),
None,
"an empty value turns the file off — and does **not** fall back to XDG"
);
}
#[test]
fn an_empty_value_is_false_to_git_and_must_be_false_here() {
let dir = TempDir::new().expect("temporary directory");
let path = dir.path().join("config");
std::fs::write(
&path,
"[filter \"git-xcrypt\"]\n\trequired = \n[core]\n\tautocrlf = \n",
)
.expect("writing must succeed");
let config = open_local(&path).expect("valid config");
for key in ["filter.git-xcrypt.required", "core.autocrlf"] {
let value = get(&config, key).unwrap_or_else(|| panic!("{key} must read as present"));
assert!(
!is_true(&value),
"{key} = `{value}` was taken for true, which git does not"
);
}
}
#[test]
fn overrides_from_the_command_line_are_read_the_way_git_writes_them() {
let measured = r"'alias.showenv'=''\!'printenv GIT_CONFIG_PARAMETERS' 'core.autocrlf'='true' 'core.eol'='lf' 'user.name'='a b'\''c'";
let config = cli_overrides(Some(OsStr::new(measured))).expect("the line names four keys");
assert_eq!(get(&config, "core.autocrlf").as_deref(), Some("true"));
assert_eq!(get(&config, "core.eol").as_deref(), Some("lf"));
assert_eq!(get(&config, "user.name").as_deref(), Some("a b'c"));
assert_eq!(
get(&config, "alias.showenv").as_deref(),
Some("!printenv GIT_CONFIG_PARAMETERS"),
"a value may hold spaces and an escaped bang; splitting on either \
would invent a key nobody typed"
);
let old = cli_overrides(Some(OsStr::new("'core.autocrlf=input'"))).expect("one key");
assert_eq!(get(&old, "core.autocrlf").as_deref(), Some("input"));
let bare = cli_overrides(Some(OsStr::new("'core.autocrlf'"))).expect("one key");
assert_eq!(get(&bare, "core.autocrlf").as_deref(), Some("true"));
let empty = cli_overrides(Some(OsStr::new("'core.autocrlf'=''"))).expect("one key");
let value = get(&empty, "core.autocrlf").expect("present");
assert!(!is_true(&value), "`-c core.autocrlf=` is false to git");
}
#[test]
fn anything_unreadable_leaves_the_configuration_files_to_speak() {
assert!(cli_overrides(None).is_none(), "unset means no overrides");
assert!(cli_overrides(Some(OsStr::new(""))).is_none());
assert!(cli_overrides(Some(OsStr::new(" "))).is_none());
assert!(
cli_overrides(Some(OsStr::new("'core.autocrlf'='true"))).is_none(),
"an unterminated quote drops the whole variable: half a word could \
name a key nobody asked for"
);
assert!(
cli_overrides(Some(OsStr::new("notdotted=1"))).is_none(),
"a word that is not a dotted key is skipped, not guessed at"
);
let mixed =
cli_overrides(Some(OsStr::new("notdotted=1 'core.eol'='crlf'"))).expect("one key");
assert_eq!(get(&mixed, "core.eol").as_deref(), Some("crlf"));
assert!(
!mixed.to_bstring().to_str_lossy().contains("notdotted"),
"a word that names no section must not reach the configuration"
);
}
#[test]
fn a_key_that_names_no_section_has_no_value_and_does_not_panic() {
let config = File::try_from("[core]\n\tautocrlf = true\n")
.expect("the fixture is valid configuration");
for key in ["notdotted", "", "."] {
assert_eq!(
get(&config, key),
None,
"`{key}` cannot name a value, and answering that must not cost a panic"
);
}
assert_eq!(get(&config, "core.autocrlf").as_deref(), Some("true"));
}
}