use sley_core::{GitError, ObjectFormat, Result};
use std::fs;
use std::path::{Path, PathBuf};
pub mod remotes;
pub mod raw_edit;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigPreambleLine {
Comment { sigil: char, text: String },
Blank,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct GitConfig {
pub preamble: Vec<ConfigPreambleLine>,
pub sections: Vec<ConfigSection>,
pub suffix: Vec<ConfigPreambleLine>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigSection {
pub name: String,
pub subsection: Option<String>,
pub preamble: Vec<ConfigPreambleLine>,
pub entries: Vec<ConfigEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigEntry {
pub preamble: Vec<ConfigPreambleLine>,
pub indent: String,
pub key: String,
pub value: Option<String>,
pub comment: Option<String>,
}
impl ConfigEntry {
pub fn new(key: impl Into<String>, value: Option<String>) -> Self {
Self {
preamble: Vec::new(),
indent: "\t".to_string(),
key: key.into(),
value,
comment: None,
}
}
}
impl ConfigSection {
pub fn new(
name: impl Into<String>,
subsection: Option<String>,
entries: Vec<ConfigEntry>,
) -> Self {
Self {
name: name.into(),
subsection,
preamble: Vec::new(),
entries,
}
}
}
impl GitConfig {
pub fn parse(bytes: &[u8]) -> Result<Self> {
let text =
std::str::from_utf8(bytes).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
ConfigParser::new(text).parse()
}
pub fn parse_collecting(bytes: &[u8]) -> Result<(Self, Option<GitError>)> {
let text =
std::str::from_utf8(bytes).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
Ok(ConfigParser::new(text).parse_collecting())
}
pub fn read(path: impl AsRef<Path>) -> Result<Self> {
Self::parse(&fs::read(path)?)
}
pub fn get(&self, section: &str, subsection: Option<&str>, key: &str) -> Option<&str> {
self.sections
.iter()
.rev()
.filter(|candidate| {
eq_ignore_ascii_case(&candidate.name, section)
&& candidate.subsection.as_deref() == subsection
})
.flat_map(|candidate| candidate.entries.iter().rev())
.find(|entry| eq_ignore_ascii_case(&entry.key, key))
.and_then(|entry| entry.value.as_deref())
}
pub fn fsck_entries(&self) -> Vec<(String, String)> {
let mut out = Vec::new();
for section in &self.sections {
if !eq_ignore_ascii_case(§ion.name, "fsck") || section.subsection.is_some() {
continue;
}
for entry in §ion.entries {
let value = entry.value.clone().unwrap_or_else(|| "true".to_string());
out.push((entry.key.clone(), value));
}
}
out
}
pub fn get_all(&self, section: &str, subsection: Option<&str>, key: &str) -> Vec<Option<&str>> {
self.sections
.iter()
.filter(|candidate| {
eq_ignore_ascii_case(&candidate.name, section)
&& candidate.subsection.as_deref() == subsection
})
.flat_map(|candidate| candidate.entries.iter())
.filter(|entry| eq_ignore_ascii_case(&entry.key, key))
.map(|entry| entry.value.as_deref())
.collect()
}
pub fn get_entry(
&self,
section: &str,
subsection: Option<&str>,
key: &str,
) -> Option<Option<&str>> {
self.sections
.iter()
.rev()
.filter(|candidate| {
eq_ignore_ascii_case(&candidate.name, section)
&& candidate.subsection.as_deref() == subsection
})
.flat_map(|candidate| candidate.entries.iter().rev())
.find(|entry| eq_ignore_ascii_case(&entry.key, key))
.map(|entry| entry.value.as_deref())
}
pub fn get_bool(&self, section: &str, subsection: Option<&str>, key: &str) -> Option<bool> {
let entry = self
.sections
.iter()
.rev()
.filter(|candidate| {
eq_ignore_ascii_case(&candidate.name, section)
&& candidate.subsection.as_deref() == subsection
})
.flat_map(|candidate| candidate.entries.iter().rev())
.find(|entry| eq_ignore_ascii_case(&entry.key, key))?;
match &entry.value {
None => Some(true),
Some(value) => parse_config_bool(value),
}
}
pub fn repository_object_format(&self) -> Result<ObjectFormat> {
self.get("extensions", None, "objectformat")
.unwrap_or("sha1")
.parse()
}
pub fn to_canonical_bytes(&self) -> Vec<u8> {
let mut out = Vec::new();
for section in &self.sections {
write_section_header(&mut out, section);
for entry in §ion.entries {
write_config_entry(&mut out, entry, b"\t");
}
}
out
}
pub fn to_preserved_bytes(&self) -> Vec<u8> {
let mut out = Vec::new();
write_preamble(&mut out, &self.preamble);
for section in &self.sections {
write_preamble(&mut out, §ion.preamble);
write_section_header(&mut out, section);
for entry in §ion.entries {
write_preamble(&mut out, &entry.preamble);
let indent = if entry.indent.is_empty() {
"\t"
} else {
&entry.indent
};
write_config_entry(&mut out, entry, indent.as_bytes());
}
}
write_preamble(&mut out, &self.suffix);
out
}
pub fn resolve_includes(
&self,
base_dir: &Path,
context: &ConfigIncludeContext,
) -> Result<GitConfig> {
let mut resolved = GitConfig::default();
splice_includes(
self,
Some(base_dir),
context,
0,
false,
&mut resolved.sections,
)?;
Ok(resolved)
}
}
fn write_preamble(out: &mut Vec<u8>, lines: &[ConfigPreambleLine]) {
for line in lines {
match line {
ConfigPreambleLine::Comment { sigil, text } => {
out.push(*sigil as u8);
out.push(b' ');
out.extend_from_slice(text.as_bytes());
out.push(b'\n');
}
ConfigPreambleLine::Blank => out.push(b'\n'),
}
}
}
fn write_section_header(out: &mut Vec<u8>, section: &ConfigSection) {
out.extend_from_slice(b"[");
out.extend_from_slice(section.name.as_bytes());
if let Some(subsection) = §ion.subsection {
out.extend_from_slice(b" \"");
out.extend_from_slice(escape_config_subsection(subsection).as_bytes());
out.extend_from_slice(b"\"");
}
out.extend_from_slice(b"]\n");
}
fn write_config_entry(out: &mut Vec<u8>, entry: &ConfigEntry, indent: &[u8]) {
out.extend_from_slice(indent);
out.extend_from_slice(entry.key.as_bytes());
if let Some(value) = &entry.value {
out.extend_from_slice(b" = ");
out.extend_from_slice(quote_config_value(value).as_bytes());
}
if let Some(comment) = &entry.comment {
out.extend_from_slice(b" # ");
out.extend_from_slice(comment.as_bytes());
}
out.push(b'\n');
}
pub const CONFIG_MAX_INCLUDE_DEPTH: usize = 10;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConfigIncludeContext {
pub git_dir: Option<PathBuf>,
pub current_branch: Option<String>,
}
impl ConfigIncludeContext {
pub fn new(git_dir: Option<PathBuf>, current_branch: Option<String>) -> Self {
Self {
git_dir,
current_branch,
}
}
}
pub fn git_dir_for_include_context(git_dir: &Path) -> PathBuf {
if git_dir.is_absolute() {
git_dir.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(git_dir))
.unwrap_or_else(|_| git_dir.to_path_buf())
}
}
pub fn load_config_with_includes(path: &Path, context: &ConfigIncludeContext) -> Result<GitConfig> {
let mut sections = Vec::new();
load_config_file(path, context, 0, false, &mut sections)?;
Ok(GitConfig {
preamble: Vec::new(),
suffix: Vec::new(),
sections,
})
}
pub fn read_repo_config(git_dir: &Path, parameters_env: Option<&str>) -> Result<GitConfig> {
let path = git_dir.join("config");
let git_dir_abs = git_dir_for_include_context(git_dir);
let context = ConfigIncludeContext::new(Some(git_dir_abs), repo_current_branch_name(git_dir));
let mut config = load_config_with_includes(&path, &context)?;
if let Ok(parameters) = injected_config_parameters(parameters_env) {
let base = match std::env::current_dir() {
Ok(path) => path,
Err(_) => PathBuf::from("."),
};
append_injected_config_sections_with_includes(&mut config, ¶meters, &context, &base)?;
}
remotes::augment_with_legacy_remote_files(&mut config, git_dir);
Ok(config)
}
pub fn repo_current_branch_name(git_dir: &Path) -> Option<String> {
let head = fs::read_to_string(git_dir.join("HEAD")).ok()?;
let target = head.trim().strip_prefix("ref:")?.trim();
target
.strip_prefix("refs/heads/")
.map(|name| name.to_string())
}
fn load_config_file(
path: &Path,
context: &ConfigIncludeContext,
depth: usize,
forbid_remote_url: bool,
out: &mut Vec<ConfigSection>,
) -> Result<()> {
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err.into()),
};
let parsed = GitConfig::parse(&bytes).map_err(|err| annotate_config_parse_path(err, path))?;
let base_dir = match path.parent() {
Some(parent) => parent,
None => Path::new("."),
};
splice_includes(
&parsed,
Some(base_dir),
context,
depth,
forbid_remote_url,
out,
)
}
fn annotate_config_parse_path(err: GitError, path: &Path) -> GitError {
match err {
GitError::InvalidFormat(message) => {
let Some(rest) = message.strip_prefix("config line ") else {
return GitError::InvalidFormat(message);
};
let Some((line, detail)) = rest.split_once(':') else {
return GitError::InvalidFormat(format!(
"config line {rest} in file {}",
path.display()
));
};
GitError::InvalidFormat(format!(
"config line {line} in file {}:{detail}",
path.display()
))
}
other => other,
}
}
fn splice_includes(
parsed: &GitConfig,
base_dir: Option<&Path>,
context: &ConfigIncludeContext,
depth: usize,
forbid_remote_url: bool,
out: &mut Vec<ConfigSection>,
) -> Result<()> {
if depth >= CONFIG_MAX_INCLUDE_DEPTH {
return Err(GitError::InvalidFormat(format!(
"exceeded maximum include depth of {CONFIG_MAX_INCLUDE_DEPTH}"
)));
}
if forbid_remote_url {
reject_remote_urls_in_config(parsed)?;
}
let mut loaded = out.to_vec();
for section in &parsed.sections {
match include_section_kind(section) {
Some(IncludeKind::Unconditional) => {
let before = out.len();
expand_include_paths(section, base_dir, context, depth, forbid_remote_url, out)?;
loaded.extend_from_slice(&out[before..]);
}
Some(IncludeKind::Conditional(condition)) => {
if hasconfig_remote_url_condition(condition) {
let mut included = Vec::new();
expand_include_paths(section, base_dir, context, depth, true, &mut included)?;
if include_condition_matches(condition, base_dir, context, &loaded, parsed) {
loaded.extend_from_slice(&included);
out.extend(included);
}
} else if include_condition_matches(condition, base_dir, context, &loaded, parsed) {
let before = out.len();
expand_include_paths(
section,
base_dir,
context,
depth,
forbid_remote_url,
out,
)?;
loaded.extend_from_slice(&out[before..]);
}
}
None => {
loaded.push(section.clone());
out.push(section.clone());
}
}
}
Ok(())
}
fn expand_include_paths(
section: &ConfigSection,
base_dir: Option<&Path>,
context: &ConfigIncludeContext,
depth: usize,
forbid_remote_url: bool,
out: &mut Vec<ConfigSection>,
) -> Result<()> {
for entry in §ion.entries {
if !eq_ignore_ascii_case(&entry.key, "path") {
continue;
}
let Some(raw) = entry.value.as_deref() else {
continue;
};
if raw.is_empty() {
continue;
}
let resolved = resolve_include_path(raw, base_dir)?;
load_config_file(&resolved, context, depth + 1, forbid_remote_url, out)?;
}
Ok(())
}
enum IncludeKind<'a> {
Unconditional,
Conditional(&'a str),
}
fn include_section_kind(section: &ConfigSection) -> Option<IncludeKind<'_>> {
if !eq_ignore_ascii_case(§ion.name, "include")
&& !eq_ignore_ascii_case(§ion.name, "includeif")
{
return None;
}
match (
eq_ignore_ascii_case(§ion.name, "include"),
§ion.subsection,
) {
(true, None) => Some(IncludeKind::Unconditional),
(false, Some(condition)) => Some(IncludeKind::Conditional(condition)),
_ => None,
}
}
fn resolve_include_path(raw: &str, base_dir: Option<&Path>) -> Result<PathBuf> {
if let Some(rest) = raw.strip_prefix("~/") {
if let Some(home) = home_dir() {
return Ok(PathBuf::from(home).join(rest));
}
return base_dir.map(|base_dir| base_dir.join(rest)).ok_or_else(|| {
GitError::InvalidFormat("relative config includes must come from files".into())
});
}
let candidate = Path::new(raw);
if candidate.is_absolute() {
Ok(candidate.to_path_buf())
} else {
base_dir
.map(|base_dir| base_dir.join(candidate))
.ok_or_else(|| {
GitError::InvalidFormat("relative config includes must come from files".into())
})
}
}
fn include_condition_matches(
condition: &str,
base_dir: Option<&Path>,
context: &ConfigIncludeContext,
loaded: &[ConfigSection],
current_file: &GitConfig,
) -> bool {
if let Some(pattern) = condition.strip_prefix("gitdir:") {
return gitdir_condition_matches(pattern, base_dir, context, false);
}
if let Some(pattern) = condition.strip_prefix("gitdir/i:") {
return gitdir_condition_matches(pattern, base_dir, context, true);
}
if let Some(pattern) = condition.strip_prefix("onbranch:") {
return match &context.current_branch {
Some(branch) => onbranch_pattern_matches(pattern, branch),
None => false,
};
}
if let Some(glob) = condition.strip_prefix("hasconfig:remote.*.url:") {
let mut sections = loaded.to_vec();
sections.extend(current_file.sections.iter().cloned());
return hasconfig_remote_url_matches(
&GitConfig {
preamble: Vec::new(),
suffix: Vec::new(),
sections,
},
glob,
);
}
false
}
fn hasconfig_remote_url_condition(condition: &str) -> bool {
condition.starts_with("hasconfig:remote.*.url:")
}
fn collect_remote_urls(config: &GitConfig) -> Vec<&str> {
config
.sections
.iter()
.filter(|section| eq_ignore_ascii_case(§ion.name, "remote"))
.flat_map(|section| {
section
.entries
.iter()
.filter(|entry| eq_ignore_ascii_case(&entry.key, "url"))
.filter_map(|entry| entry.value.as_deref())
})
.collect()
}
fn hasconfig_remote_url_matches(config: &GitConfig, glob: &str) -> bool {
collect_remote_urls(config)
.into_iter()
.any(|url| glob_match(glob, url, false))
}
fn reject_remote_urls_in_config(config: &GitConfig) -> Result<()> {
for section in &config.sections {
if !eq_ignore_ascii_case(§ion.name, "remote") || section.subsection.is_none() {
continue;
}
for entry in §ion.entries {
if eq_ignore_ascii_case(&entry.key, "url") {
return Err(GitError::InvalidFormat(
"remote URLs cannot be configured in file directly or indirectly included by includeIf.hasconfig:remote.*.url".into(),
));
}
}
}
Ok(())
}
fn gitdir_condition_matches(
pattern: &str,
base_dir: Option<&Path>,
context: &ConfigIncludeContext,
case_insensitive: bool,
) -> bool {
let Some(git_dir) = &context.git_dir else {
return false;
};
let expanded = expand_gitdir_pattern(pattern, base_dir);
let mut pattern = normalize_separators(&expanded);
if pattern.ends_with('/') {
pattern.push_str("**");
}
gitdir_match_targets(git_dir)
.into_iter()
.any(|target| glob_match(&pattern, &target, case_insensitive))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigScope {
System,
Global,
Local,
Worktree,
Command,
}
impl ConfigScope {
pub fn name(self) -> &'static str {
match self {
ConfigScope::System => "system",
ConfigScope::Global => "global",
ConfigScope::Local => "local",
ConfigScope::Worktree => "worktree",
ConfigScope::Command => "command",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigOriginKind {
File,
Blob,
Stdin,
CommandLine,
}
impl ConfigOriginKind {
pub fn name(self) -> &'static str {
match self {
ConfigOriginKind::File => "file",
ConfigOriginKind::Blob => "blob",
ConfigOriginKind::Stdin => "standard input",
ConfigOriginKind::CommandLine => "command line",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigOrigin {
pub kind: ConfigOriginKind,
pub name: String,
}
impl ConfigOrigin {
pub fn file(name: impl Into<String>) -> Self {
Self {
kind: ConfigOriginKind::File,
name: name.into(),
}
}
pub fn blob(spec: impl Into<String>) -> Self {
Self {
kind: ConfigOriginKind::Blob,
name: spec.into(),
}
}
pub fn stdin() -> Self {
Self {
kind: ConfigOriginKind::Stdin,
name: String::new(),
}
}
pub fn command_line() -> Self {
Self {
kind: ConfigOriginKind::CommandLine,
name: String::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct ConfigStackEntry {
pub section: String,
pub subsection: Option<String>,
pub key: String,
pub value: Option<String>,
pub scope: ConfigScope,
pub origin: ConfigOrigin,
pub included_from: Option<ConfigOrigin>,
}
impl ConfigStackEntry {
pub fn matches(&self, section: &str, subsection: Option<&str>, key: &str) -> bool {
eq_ignore_ascii_case(&self.section, section)
&& self.subsection.as_deref() == subsection
&& eq_ignore_ascii_case(&self.key, key)
}
}
#[derive(Debug, Clone, Default)]
pub struct ConfigStack {
pub entries: Vec<ConfigStackEntry>,
}
impl ConfigStack {
pub fn new() -> Self {
Self::default()
}
pub fn push_file(
&mut self,
path: &Path,
scope: ConfigScope,
respect_includes: bool,
context: &ConfigIncludeContext,
) -> Result<()> {
emit_config_file(
path,
&EmitConfigOptions {
scope,
context,
depth: 0,
forbid_remote_url: false,
respect_includes,
included_from: None,
},
&mut self.entries,
)
}
pub fn push_parsed(
&mut self,
parsed: &GitConfig,
origin: ConfigOrigin,
scope: ConfigScope,
respect_includes: bool,
context: &ConfigIncludeContext,
) -> Result<()> {
emit_parsed_config(
parsed,
&origin,
&EmitConfigOptions {
scope,
context,
depth: 0,
forbid_remote_url: false,
respect_includes,
included_from: None,
},
&mut self.entries,
)
}
pub fn push_parameters(&mut self, parameters: &[ConfigParameter]) {
for param in parameters {
let (section, subsection, key) = param.split_key();
self.entries.push(ConfigStackEntry {
section: section.to_string(),
subsection: subsection.map(str::to_string),
key: key.to_string(),
value: param.value.clone(),
scope: ConfigScope::Command,
origin: ConfigOrigin::command_line(),
included_from: None,
});
}
}
pub fn push_parameters_with_includes(
&mut self,
parameters: &[ConfigParameter],
context: &ConfigIncludeContext,
) -> Result<()> {
let parsed = GitConfig {
preamble: Vec::new(),
suffix: Vec::new(),
sections: injected_config_sections(parameters),
};
emit_parsed_config(
&parsed,
&ConfigOrigin::command_line(),
&EmitConfigOptions {
scope: ConfigScope::Command,
context,
depth: 0,
forbid_remote_url: false,
respect_includes: true,
included_from: None,
},
&mut self.entries,
)
}
pub fn get(
&self,
section: &str,
subsection: Option<&str>,
key: &str,
) -> Option<&ConfigStackEntry> {
self.entries
.iter()
.rev()
.find(|entry| entry.matches(section, subsection, key))
}
pub fn get_all(
&self,
section: &str,
subsection: Option<&str>,
key: &str,
) -> Vec<&ConfigStackEntry> {
self.entries
.iter()
.filter(|entry| entry.matches(section, subsection, key))
.collect()
}
pub fn get_bool(&self, section: &str, subsection: Option<&str>, key: &str) -> Option<bool> {
let entry = self.get(section, subsection, key)?;
match &entry.value {
None => Some(true),
Some(value) => parse_config_bool(value),
}
}
}
pub fn default_config_layer_paths() -> Vec<(PathBuf, ConfigScope)> {
let mut layers = Vec::new();
if let Some(system) = system_config_path() {
layers.push((system, ConfigScope::System));
}
for global in global_config_paths() {
layers.push((global, ConfigScope::Global));
}
layers
}
#[derive(Clone)]
struct EmitConfigOptions<'a> {
scope: ConfigScope,
context: &'a ConfigIncludeContext,
depth: usize,
forbid_remote_url: bool,
respect_includes: bool,
included_from: Option<ConfigOrigin>,
}
fn emit_config_file(
path: &Path,
options: &EmitConfigOptions<'_>,
out: &mut Vec<ConfigStackEntry>,
) -> Result<()> {
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err.into()),
};
let parsed = GitConfig::parse(&bytes)?;
let origin = ConfigOrigin::file(path.to_string_lossy().into_owned());
emit_parsed_config(&parsed, &origin, options, out)
}
fn emit_parsed_config(
parsed: &GitConfig,
origin: &ConfigOrigin,
options: &EmitConfigOptions<'_>,
out: &mut Vec<ConfigStackEntry>,
) -> Result<()> {
let EmitConfigOptions {
scope,
context,
depth,
forbid_remote_url,
respect_includes,
included_from,
} = options;
let scope = *scope;
let context = *context;
let depth = *depth;
let forbid_remote_url = *forbid_remote_url;
let respect_includes = *respect_includes;
if depth >= CONFIG_MAX_INCLUDE_DEPTH {
return Err(GitError::InvalidFormat(format!(
"exceeded maximum include depth of {CONFIG_MAX_INCLUDE_DEPTH}"
)));
}
if forbid_remote_url {
reject_remote_urls_in_config(parsed)?;
}
let base_dir = match origin.kind {
ConfigOriginKind::File => Some(match Path::new(&origin.name).parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
_ => PathBuf::from("."),
}),
_ => None,
};
for section in &parsed.sections {
let include_kind = if respect_includes {
include_section_kind(section)
} else {
None
};
for entry in §ion.entries {
out.push(ConfigStackEntry {
section: section.name.clone(),
subsection: section.subsection.clone(),
key: entry.key.clone(),
value: entry.value.clone(),
scope,
origin: origin.clone(),
included_from: included_from.clone(),
});
let Some(kind) = &include_kind else { continue };
if !eq_ignore_ascii_case(&entry.key, "path") {
continue;
}
let Some(raw) = entry.value.as_deref() else {
continue;
};
if raw.is_empty() {
continue;
}
let resolved = resolve_stack_include_path(raw, base_dir.as_deref())?;
match kind {
IncludeKind::Unconditional => {
emit_config_file(
&resolved,
&EmitConfigOptions {
scope,
context,
depth: depth + 1,
forbid_remote_url,
respect_includes: true,
included_from: Some(origin.clone()),
},
out,
)?;
}
IncludeKind::Conditional(condition)
if hasconfig_remote_url_condition(condition) =>
{
let mut included = Vec::new();
emit_config_file(
&resolved,
&EmitConfigOptions {
scope,
context,
depth: depth + 1,
forbid_remote_url: true,
respect_includes: true,
included_from: Some(origin.clone()),
},
&mut included,
)?;
if stack_include_condition_matches(
condition,
base_dir.as_deref(),
context,
out,
parsed,
) {
out.extend(included);
}
}
IncludeKind::Conditional(condition) => {
if stack_include_condition_matches(
condition,
base_dir.as_deref(),
context,
out,
parsed,
) {
emit_config_file(
&resolved,
&EmitConfigOptions {
scope,
context,
depth: depth + 1,
forbid_remote_url,
respect_includes: true,
included_from: Some(origin.clone()),
},
out,
)?;
}
}
}
}
}
Ok(())
}
fn resolve_stack_include_path(raw: &str, base_dir: Option<&Path>) -> Result<PathBuf> {
if let Some(rest) = raw.strip_prefix("~/")
&& let Some(home) = home_dir()
{
return Ok(PathBuf::from(home).join(rest));
}
let candidate = Path::new(raw);
if candidate.is_absolute() {
return Ok(candidate.to_path_buf());
}
let Some(base_dir) = base_dir else {
return Err(GitError::InvalidFormat(
"relative config includes must come from files".into(),
));
};
Ok(base_dir.join(candidate))
}
fn stack_include_condition_matches(
condition: &str,
base_dir: Option<&Path>,
context: &ConfigIncludeContext,
emitted: &[ConfigStackEntry],
current_file: &GitConfig,
) -> bool {
if let Some(pattern) = condition.strip_prefix("gitdir:") {
return gitdir_condition_matches(pattern, base_dir, context, false);
}
if let Some(pattern) = condition.strip_prefix("gitdir/i:") {
return gitdir_condition_matches(pattern, base_dir, context, true);
}
if let Some(pattern) = condition.strip_prefix("onbranch:") {
return match &context.current_branch {
Some(branch) => onbranch_pattern_matches(pattern, branch),
None => false,
};
}
if let Some(glob) = condition.strip_prefix("hasconfig:remote.*.url:") {
let stack_urls = emitted.iter().filter(|entry| {
eq_ignore_ascii_case(&entry.section, "remote")
&& eq_ignore_ascii_case(&entry.key, "url")
});
return stack_urls
.filter_map(|entry| entry.value.as_deref())
.chain(collect_remote_urls(current_file))
.any(|url| glob_match(glob, url, false));
}
false
}
pub fn home_dir() -> Option<String> {
match std::env::var("HOME") {
Ok(home) if !home.is_empty() => Some(home),
_ => None,
}
}
pub fn expand_user_path(path: &str) -> PathBuf {
expand_user_path_with_home(path, home_dir().as_deref())
}
fn expand_user_path_with_home(path: &str, home: Option<&str>) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = home.filter(|home| !home.is_empty()) {
return PathBuf::from(home).join(rest);
}
} else if path == "~"
&& let Some(home) = home.filter(|home| !home.is_empty())
{
return PathBuf::from(home);
}
PathBuf::from(path)
}
fn expand_gitdir_pattern(pattern: &str, base_dir: Option<&Path>) -> String {
if let Some(rest) = pattern.strip_prefix("~/") {
if let Some(home) = home_dir() {
return format!("{home}/{rest}");
}
return pattern.to_string();
}
if let Some(rest) = pattern.strip_prefix("./") {
if let Some(base_dir) = base_dir {
let base = normalize_path_for_match(base_dir);
let base = base.trim_end_matches('/');
return format!("{base}/{rest}");
}
return pattern.to_string();
}
if Path::new(pattern).is_absolute() || pattern.starts_with("**/") {
pattern.to_string()
} else {
format!("**/{pattern}")
}
}
fn normalize_path_for_match(path: &Path) -> String {
normalize_separators(&path.to_string_lossy())
}
fn gitdir_match_targets(git_dir: &Path) -> Vec<String> {
let mut targets = vec![normalize_path_for_match(git_dir)];
if let Ok(realpath) = fs::canonicalize(git_dir) {
let realpath = normalize_path_for_match(&realpath);
if !targets.iter().any(|target| target == &realpath) {
targets.push(realpath);
}
}
targets
}
fn normalize_separators(value: &str) -> String {
value.replace('\\', "/")
}
fn onbranch_pattern_matches(pattern: &str, branch: &str) -> bool {
let mut pattern = pattern.to_string();
if pattern.ends_with('/') {
pattern.push_str("**");
}
glob_match(&pattern, branch, false)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum GlobToken {
Literal(char),
AnyChar,
Star,
DoubleStar,
Class {
negated: bool,
items: Vec<ClassItem>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ClassItem {
Single(char),
Range(char, char),
}
fn glob_match(pattern: &str, text: &str, case_insensitive: bool) -> bool {
let (pattern, text) = if case_insensitive {
(pattern.to_lowercase(), text.to_lowercase())
} else {
(pattern.to_string(), text.to_string())
};
let tokens = compile_glob(&pattern);
let text_chars: Vec<char> = text.chars().collect();
glob_match_tokens(&tokens, &text_chars)
}
fn compile_glob(pattern: &str) -> Vec<GlobToken> {
let chars: Vec<char> = pattern.chars().collect();
let mut tokens = Vec::new();
let mut idx = 0;
while idx < chars.len() {
match chars[idx] {
'*' => {
if chars.get(idx + 1) == Some(&'*') {
tokens.push(GlobToken::DoubleStar);
idx += 2;
} else {
tokens.push(GlobToken::Star);
idx += 1;
}
}
'?' => {
tokens.push(GlobToken::AnyChar);
idx += 1;
}
'\\' => {
if let Some(&next) = chars.get(idx + 1) {
tokens.push(GlobToken::Literal(next));
idx += 2;
} else {
tokens.push(GlobToken::Literal('\\'));
idx += 1;
}
}
'[' => {
if let Some((token, next)) = compile_char_class(&chars, idx) {
tokens.push(token);
idx = next;
} else {
tokens.push(GlobToken::Literal('['));
idx += 1;
}
}
other => {
tokens.push(GlobToken::Literal(other));
idx += 1;
}
}
}
tokens
}
fn compile_char_class(chars: &[char], start: usize) -> Option<(GlobToken, usize)> {
let mut idx = start + 1;
let mut negated = false;
if chars.get(idx) == Some(&'!') || chars.get(idx) == Some(&'^') {
negated = true;
idx += 1;
}
let mut items = Vec::new();
let mut first = true;
while idx < chars.len() {
let current = chars[idx];
if current == ']' && !first {
return Some((GlobToken::Class { negated, items }, idx + 1));
}
first = false;
if chars.get(idx + 1) == Some(&'-')
&& chars.get(idx + 2).is_some()
&& chars.get(idx + 2) != Some(&']')
{
items.push(ClassItem::Range(current, chars[idx + 2]));
idx += 3;
} else {
items.push(ClassItem::Single(current));
idx += 1;
}
}
None
}
fn glob_match_tokens(tokens: &[GlobToken], text: &[char]) -> bool {
let Some((token, rest)) = tokens.split_first() else {
return text.is_empty();
};
match token {
GlobToken::Literal('/') => {
if text.is_empty() && rest == [GlobToken::DoubleStar] {
return true;
}
matches!(text.split_first(), Some((&ch, tail)) if ch == '/' && glob_match_tokens(rest, tail))
}
GlobToken::Literal(expected) => {
matches!(text.split_first(), Some((&ch, tail)) if ch == *expected && glob_match_tokens(rest, tail))
}
GlobToken::AnyChar => {
matches!(text.split_first(), Some((&ch, tail)) if ch != '/' && glob_match_tokens(rest, tail))
}
GlobToken::Class { negated, items } => {
matches!(text.split_first(), Some((&ch, tail))
if ch != '/' && class_matches(items, ch) != *negated && glob_match_tokens(rest, tail))
}
GlobToken::Star => {
if glob_match_tokens(rest, text) {
return true;
}
let mut consumed = 0;
while consumed < text.len() && text[consumed] != '/' {
consumed += 1;
if glob_match_tokens(rest, &text[consumed..]) {
return true;
}
}
false
}
GlobToken::DoubleStar => {
match rest.split_first() {
Some((GlobToken::Literal('/'), after_slash)) => {
if glob_match_tokens(after_slash, text) {
return true;
}
let mut consumed = 0;
while consumed < text.len() {
let ch = text[consumed];
consumed += 1;
if ch == '/' && glob_match_tokens(tokens, &text[consumed..]) {
return true;
}
}
false
}
_ => {
if glob_match_tokens(rest, text) {
return true;
}
for consumed in 1..=text.len() {
if glob_match_tokens(rest, &text[consumed..]) {
return true;
}
}
false
}
}
}
}
}
fn class_matches(items: &[ClassItem], ch: char) -> bool {
items.iter().any(|item| match item {
ClassItem::Single(value) => *value == ch,
ClassItem::Range(lo, hi) => *lo <= ch && ch <= *hi,
})
}
struct ConfigParser<'a> {
chars: std::iter::Peekable<std::str::Chars<'a>>,
line: usize,
}
impl<'a> ConfigParser<'a> {
fn new(text: &'a str) -> Self {
Self {
chars: text.chars().peekable(),
line: 1,
}
}
fn peek(&mut self) -> Option<char> {
self.chars.peek().copied()
}
fn bump(&mut self) -> Option<char> {
let ch = self.chars.next();
if ch == Some('\n') {
self.line += 1;
}
ch
}
fn err(&self, message: impl std::fmt::Display) -> GitError {
GitError::InvalidFormat(format!("config line {}: {message}", self.line))
}
fn skip_blanks(&mut self) {
while matches!(self.peek(), Some(' ') | Some('\t') | Some('\r')) {
self.bump();
}
}
fn parse(self) -> Result<GitConfig> {
match self.parse_collecting() {
(config, None) => Ok(config),
(_, Some(err)) => Err(err),
}
}
fn parse_collecting(mut self) -> (GitConfig, Option<GitError>) {
let mut config = GitConfig::default();
let mut current: Option<usize> = None;
let mut pending_preamble = Vec::new();
let mut after_section_header = false;
loop {
self.skip_blanks();
match self.peek() {
None => break,
Some('\n') => {
self.bump();
if after_section_header {
after_section_header = false;
} else {
pending_preamble.push(ConfigPreambleLine::Blank);
}
}
Some('#') | Some(';') => {
let sigil = self.bump().expect("peeked comment sigil");
pending_preamble.push(ConfigPreambleLine::Comment {
sigil,
text: self.parse_comment_text(),
});
}
Some('[') => match self.parse_section_header() {
Ok(mut section) => {
if config.sections.is_empty() {
config.preamble = pending_preamble;
section.preamble = Vec::new();
} else {
section.preamble = pending_preamble;
}
pending_preamble = Vec::new();
config.sections.push(section);
current = Some(config.sections.len() - 1);
after_section_header = true;
}
Err(err) => return (config, Some(err)),
},
Some(ch) if ch.is_ascii_alphabetic() => match self.parse_entry() {
Ok(mut entry) => {
entry.preamble = pending_preamble;
pending_preamble = Vec::new();
after_section_header = false;
let Some(idx) = current else {
return (
config,
Some(self.err("variable definition appears before a section")),
);
};
config.sections[idx].entries.push(entry);
}
Err(err) => return (config, Some(err)),
},
Some(ch) => {
return (
config,
Some(self.err(format!("unexpected character {ch:?}"))),
);
}
}
}
config.suffix = pending_preamble;
(config, None)
}
fn parse_section_header(&mut self) -> Result<ConfigSection> {
self.bump(); let mut name = String::new();
while let Some(ch) = self.peek() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '.' {
name.push(ch);
self.bump();
} else {
break;
}
}
if let Some((head, rest)) = name.split_once('.') {
let subsection = rest.to_string();
let head = head.to_string();
self.skip_blanks();
match self.bump() {
Some(']') => {}
_ => return Err(self.err("missing ']' after dotted section header")),
}
if !is_config_name(&head) {
return Err(self.err(format!("invalid section name {head}")));
}
return Ok(ConfigSection {
name: head.to_ascii_lowercase(),
subsection: Some(subsection.to_ascii_lowercase()),
preamble: Vec::new(),
entries: Vec::new(),
});
}
if !is_config_name(&name) {
return Err(self.err(format!("invalid section name {name}")));
}
match self.peek() {
Some(']') => {
self.bump();
Ok(ConfigSection {
name,
subsection: None,
preamble: Vec::new(),
entries: Vec::new(),
})
}
Some(' ') | Some('\t') => {
self.skip_blanks();
if self.peek() != Some('"') {
return Err(self.err("expected quoted subsection name"));
}
let subsection = self.parse_subsection_name()?;
self.skip_blanks();
match self.bump() {
Some(']') => {}
_ => return Err(self.err("missing ']' after subsection name")),
}
Ok(ConfigSection {
name,
subsection: Some(subsection),
preamble: Vec::new(),
entries: Vec::new(),
})
}
_ => Err(self.err("malformed section header")),
}
}
fn parse_subsection_name(&mut self) -> Result<String> {
self.bump(); let mut out = String::new();
loop {
match self.bump() {
None | Some('\n') => return Err(self.err("unterminated subsection name")),
Some('"') => return Ok(out),
Some('\\') => match self.bump() {
None | Some('\n') => {
return Err(self.err("unterminated subsection name"));
}
Some(other) => out.push(other),
},
Some(other) => out.push(other),
}
}
}
fn parse_entry(&mut self) -> Result<ConfigEntry> {
let mut indent = String::new();
while matches!(self.peek(), Some(' ') | Some('\t')) {
indent.push(self.bump().expect("peeked whitespace"));
}
if indent.is_empty() {
indent.push('\t');
}
let mut key = String::new();
while let Some(ch) = self.peek() {
if ch.is_ascii_alphanumeric() || ch == '-' {
key.push(ch);
self.bump();
} else {
break;
}
}
if !is_config_name(&key) {
return Err(self.err(format!("invalid variable name {key}")));
}
self.skip_blanks();
match self.peek() {
None => Ok(ConfigEntry {
preamble: Vec::new(),
indent,
key,
value: None,
comment: None,
}),
Some('\n') => {
self.bump();
Ok(ConfigEntry {
preamble: Vec::new(),
indent,
key,
value: None,
comment: None,
})
}
Some('=') => {
self.bump();
let (value, comment) = self.parse_value()?;
Ok(ConfigEntry {
preamble: Vec::new(),
indent,
key,
value: Some(value),
comment,
})
}
Some(ch) => Err(self.err(format!("expected '=' after variable name, found {ch:?}"))),
}
}
fn parse_value(&mut self) -> Result<(String, Option<String>)> {
let mut out = String::new();
let mut comment = None;
let mut trailing_ws = 0usize;
let mut leading = true;
let mut in_quotes = false;
loop {
match self.peek() {
None => break,
Some('\n') if !in_quotes => {
self.bump();
break;
}
Some('\n') => return Err(self.err("newline inside quoted value")),
Some('"') => {
self.bump();
in_quotes = !in_quotes;
leading = false;
}
Some('\\') => {
self.bump();
match self.bump() {
Some('\n') => {}
Some('\r') if self.peek() == Some('\n') => {
self.bump();
}
Some('n') => {
out.push('\n');
trailing_ws = 0;
leading = false;
}
Some('t') => {
out.push('\t');
trailing_ws = 0;
leading = false;
}
Some('b') => {
out.push('\u{0008}');
trailing_ws = 0;
leading = false;
}
Some('"') => {
out.push('"');
trailing_ws = 0;
leading = false;
}
Some('\\') => {
out.push('\\');
trailing_ws = 0;
leading = false;
}
Some(other) => {
return Err(self.err(format!("invalid escape sequence \\{other}")));
}
None => break,
}
}
Some('#') | Some(';') if !in_quotes => {
self.bump();
comment = Some(self.parse_comment_text());
break;
}
Some(ch @ (' ' | '\t')) if !in_quotes => {
self.bump();
if leading {
} else {
out.push(ch);
trailing_ws += 1;
}
}
Some('\r') if !in_quotes => {
self.bump();
match self.peek() {
Some('\n') | None => {
}
_ if !leading => {
out.push('\r');
trailing_ws = 0;
leading = false;
}
_ => {}
}
}
Some(ch) => {
self.bump();
out.push(ch);
trailing_ws = 0;
leading = false;
}
}
}
if in_quotes {
return Err(self.err("unterminated quoted value"));
}
out.truncate(out.len() - trailing_ws);
Ok((out, comment))
}
fn parse_comment_text(&mut self) -> String {
while matches!(self.peek(), Some(' ') | Some('\t')) {
self.bump();
}
let mut comment = String::new();
while let Some(ch) = self.peek() {
if ch == '\n' {
self.bump();
break;
}
comment.push(ch);
self.bump();
}
comment
}
}
pub(crate) fn quote_config_value(value: &str) -> String {
let needs_quotes = value.starts_with(' ')
|| value.ends_with(' ')
|| value.contains('\r')
|| value.bytes().any(|byte| matches!(byte, b'#' | b';'));
let mut out = String::new();
if needs_quotes {
out.push('"');
}
for ch in value.chars() {
match ch {
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
other => out.push(other),
}
}
if needs_quotes {
out.push('"');
}
out
}
fn escape_config_subsection(value: &str) -> String {
let mut out = String::new();
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
other => out.push(other),
}
}
out
}
pub fn parse_config_bool(value: &str) -> Option<bool> {
let trimmed = value.trim();
if trimmed.eq_ignore_ascii_case("true")
|| trimmed.eq_ignore_ascii_case("yes")
|| trimmed.eq_ignore_ascii_case("on")
|| trimmed == "1"
{
return Some(true);
}
if trimmed.eq_ignore_ascii_case("false")
|| trimmed.eq_ignore_ascii_case("no")
|| trimmed.eq_ignore_ascii_case("off")
|| trimmed == "0"
|| trimmed.is_empty()
{
return Some(false);
}
parse_config_int(trimmed).map(|number| number != 0)
}
pub fn parse_config_int(value: &str) -> Option<i64> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let (digits, multiplier) = match trimmed.as_bytes().last().copied() {
Some(b'k' | b'K') => (&trimmed[..trimmed.len() - 1], 1024_i64),
Some(b'm' | b'M') => (&trimmed[..trimmed.len() - 1], 1024_i64 * 1024),
Some(b'g' | b'G') => (&trimmed[..trimmed.len() - 1], 1024_i64 * 1024 * 1024),
_ => (trimmed, 1_i64),
};
parse_c_long(digits)?.checked_mul(multiplier)
}
fn parse_c_long(text: &str) -> Option<i64> {
let (negative, rest) = match text.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, text.strip_prefix('+').unwrap_or(text)),
};
if rest.is_empty() {
return None;
}
let magnitude = if let Some(hex) = rest.strip_prefix("0x").or_else(|| rest.strip_prefix("0X")) {
i64::from_str_radix(hex, 16).ok()?
} else if rest.len() > 1 && rest.starts_with('0') {
i64::from_str_radix(rest, 8).ok()?
} else {
rest.parse::<i64>().ok()?
};
if negative {
magnitude.checked_neg()
} else {
Some(magnitude)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigBoolOrInt {
Bool(bool),
Int(i64),
}
pub fn parse_config_bool_or_int(value: &str) -> Option<ConfigBoolOrInt> {
let trimmed = value.trim();
if trimmed.eq_ignore_ascii_case("true")
|| trimmed.eq_ignore_ascii_case("yes")
|| trimmed.eq_ignore_ascii_case("on")
{
return Some(ConfigBoolOrInt::Bool(true));
}
if trimmed.eq_ignore_ascii_case("false")
|| trimmed.eq_ignore_ascii_case("no")
|| trimmed.eq_ignore_ascii_case("off")
|| trimmed.is_empty()
{
return Some(ConfigBoolOrInt::Bool(false));
}
parse_config_int(trimmed).map(ConfigBoolOrInt::Int)
}
fn is_config_name(value: &str) -> bool {
!value.is_empty()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
}
fn eq_ignore_ascii_case(left: &str, right: &str) -> bool {
left.eq_ignore_ascii_case(right)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigParameter {
pub canonical_key: String,
pub value: Option<String>,
}
impl ConfigParameter {
pub fn split_key(&self) -> (&str, Option<&str>, &str) {
let first = self
.canonical_key
.find('.')
.expect("canonical key has a section");
let last = self
.canonical_key
.rfind('.')
.expect("canonical key has a key");
let section = &self.canonical_key[..first];
let key = &self.canonical_key[last + 1..];
let subsection = if first == last {
None
} else {
Some(&self.canonical_key[first + 1..last])
};
(section, subsection, key)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigParameterError {
EmptyKey,
NoSection(String),
NoVariableName(String),
InvalidKey(String),
InvalidKeyNewline(String),
BogusParameter(String),
BogusEnvFormat,
BogusCount,
TooManyEntries,
MissingKey(String),
MissingValue(String),
}
impl ConfigParameterError {
pub fn message(&self) -> String {
match self {
Self::EmptyKey => "empty config key".to_string(),
Self::NoSection(key) => format!("key does not contain a section: {key}"),
Self::NoVariableName(key) => format!("key does not contain variable name: {key}"),
Self::InvalidKey(key) => format!("invalid key: {key}"),
Self::InvalidKeyNewline(key) => format!("invalid key (newline): {key}"),
Self::BogusParameter(text) => format!("bogus config parameter: {text}"),
Self::BogusEnvFormat => "bogus format in GIT_CONFIG_PARAMETERS".to_string(),
Self::BogusCount => "bogus count in GIT_CONFIG_COUNT".to_string(),
Self::TooManyEntries => "too many entries in GIT_CONFIG_COUNT".to_string(),
Self::MissingKey(name) => format!("missing config key {name}"),
Self::MissingValue(name) => format!("missing config value {name}"),
}
}
}
fn is_key_char(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'-'
}
pub fn canonicalize_config_key(key: &str) -> std::result::Result<String, ConfigParameterError> {
let bytes = key.as_bytes();
let Some(last_dot) = key.rfind('.') else {
return Err(ConfigParameterError::NoSection(key.to_string()));
};
if last_dot == 0 {
return Err(ConfigParameterError::NoSection(key.to_string()));
}
if last_dot + 1 == bytes.len() {
return Err(ConfigParameterError::NoVariableName(key.to_string()));
}
let baselen = last_dot;
let mut out = Vec::with_capacity(bytes.len());
let mut dot = false;
for (i, &c) in bytes.iter().enumerate() {
let mut c = c;
if c == b'.' {
dot = true;
}
if !dot || i > baselen {
if !is_key_char(c) || (i == baselen + 1 && !c.is_ascii_alphabetic()) {
return Err(ConfigParameterError::InvalidKey(key.to_string()));
}
c = c.to_ascii_lowercase();
} else if c == b'\n' {
return Err(ConfigParameterError::InvalidKeyNewline(key.to_string()));
}
out.push(c);
}
Ok(String::from_utf8(out).expect("ascii-lowercased valid utf-8 stays valid utf-8"))
}
fn parse_config_parameter(
text: &str,
) -> std::result::Result<ConfigParameter, ConfigParameterError> {
let (key, value) = match text.split_once('=') {
Some((key, value)) => (key, Some(value.to_string())),
None => (text, None),
};
if key.is_empty() {
return Err(ConfigParameterError::BogusParameter(text.to_string()));
}
let canonical_key = canonicalize_config_key(key)?;
Ok(ConfigParameter {
canonical_key,
value,
})
}
fn parse_config_pair(
key: &str,
value: Option<String>,
) -> std::result::Result<ConfigParameter, ConfigParameterError> {
if key.is_empty() {
return Err(ConfigParameterError::EmptyKey);
}
let canonical_key = canonicalize_config_key(key)?;
Ok(ConfigParameter {
canonical_key,
value,
})
}
fn sq_dequote_step(bytes: &[u8], pos: usize) -> Option<(String, usize)> {
if bytes.get(pos) != Some(&b'\'') {
return None;
}
let mut out = Vec::new();
let mut src = pos;
loop {
src += 1; let Some(&c) = bytes.get(src) else {
return None;
};
if c != b'\'' {
out.push(c);
continue;
}
src += 1; match bytes.get(src).copied() {
None => {
return Some((String::from_utf8_lossy(&out).into_owned(), src));
}
Some(b'\\') => {
let escaped = bytes.get(src + 1).copied();
let resumes = bytes.get(src + 2).copied() == Some(b'\'');
if matches!(escaped, Some(b'\'') | Some(b'!')) && resumes {
out.push(escaped.expect("escaped byte present"));
src += 2; continue;
}
return Some((String::from_utf8_lossy(&out).into_owned(), src));
}
Some(_) => {
return Some((String::from_utf8_lossy(&out).into_owned(), src));
}
}
}
}
fn parse_config_env_list(
env: &str,
) -> std::result::Result<Vec<ConfigParameter>, ConfigParameterError> {
let bytes = env.as_bytes();
let mut out = Vec::new();
let mut cur = 0;
while cur < bytes.len() {
let Some((key, next)) = sq_dequote_step(bytes, cur) else {
return Err(ConfigParameterError::BogusEnvFormat);
};
cur = next;
if cur >= bytes.len() || bytes[cur].is_ascii_whitespace() {
out.push(parse_config_parameter(&key)?);
} else if bytes[cur] == b'=' {
cur += 1;
let value = match bytes.get(cur) {
Some(&b'\'') => {
let Some((value, next)) = sq_dequote_step(bytes, cur) else {
return Err(ConfigParameterError::BogusEnvFormat);
};
if next < bytes.len() && !bytes[next].is_ascii_whitespace() {
return Err(ConfigParameterError::BogusEnvFormat);
}
cur = next;
Some(value)
}
None => None,
Some(b) if b.is_ascii_whitespace() => None,
Some(_) => return Err(ConfigParameterError::BogusEnvFormat),
};
out.push(parse_config_pair(&key, value)?);
} else {
return Err(ConfigParameterError::BogusEnvFormat);
}
while cur < bytes.len() && bytes[cur].is_ascii_whitespace() {
cur += 1;
}
}
Ok(out)
}
fn parse_strtoul_count(value: &str) -> std::result::Result<Option<u64>, ConfigParameterError> {
let bytes = value.as_bytes();
let mut i = 0;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i == bytes.len() {
return Ok(None);
}
let negative = match bytes[i] {
b'+' => {
i += 1;
false
}
b'-' => {
i += 1;
true
}
_ => false,
};
let digits_start = i;
let mut acc: u64 = 0;
while i < bytes.len() && bytes[i].is_ascii_digit() {
acc = acc.wrapping_mul(10).wrapping_add((bytes[i] - b'0') as u64);
i += 1;
}
if i == digits_start {
return Err(ConfigParameterError::BogusCount);
}
if i != bytes.len() {
return Err(ConfigParameterError::BogusCount);
}
if negative {
return Ok(Some(acc.wrapping_neg()));
}
Ok(Some(acc))
}
fn env_count_parameters() -> std::result::Result<Vec<ConfigParameter>, ConfigParameterError> {
let Some(count) = std::env::var_os("GIT_CONFIG_COUNT") else {
return Ok(Vec::new());
};
let count = count.to_string_lossy();
let count = parse_strtoul_count(&count)?.unwrap_or(0);
if count > i32::MAX as u64 {
return Err(ConfigParameterError::TooManyEntries);
}
let mut out = Vec::with_capacity(count as usize);
for index in 0..count {
let key_name = format!("GIT_CONFIG_KEY_{index}");
let value_name = format!("GIT_CONFIG_VALUE_{index}");
let Some(key) = std::env::var_os(&key_name) else {
return Err(ConfigParameterError::MissingKey(key_name));
};
let Some(value) = std::env::var_os(&value_name) else {
return Err(ConfigParameterError::MissingValue(value_name));
};
out.push(parse_config_pair(
&key.to_string_lossy(),
Some(value.to_string_lossy().into_owned()),
)?);
}
Ok(out)
}
pub fn injected_config_parameters(
parameters_env: Option<&str>,
) -> std::result::Result<Vec<ConfigParameter>, ConfigParameterError> {
let mut out = env_count_parameters()?;
if let Some(env) = parameters_env.filter(|env| !env.is_empty()) {
out.extend(parse_config_env_list(env)?);
}
Ok(out)
}
pub fn injected_config_sections(parameters: &[ConfigParameter]) -> Vec<ConfigSection> {
parameters
.iter()
.map(|param| {
let (section, subsection, key) = param.split_key();
ConfigSection::new(
section.to_string(),
subsection.map(str::to_string),
vec![ConfigEntry::new(key.to_string(), param.value.clone())],
)
})
.collect()
}
pub fn append_injected_config_sections_with_includes(
config: &mut GitConfig,
parameters: &[ConfigParameter],
context: &ConfigIncludeContext,
_base_dir: &Path,
) -> Result<()> {
let injected = GitConfig {
preamble: Vec::new(),
suffix: Vec::new(),
sections: injected_config_sections(parameters),
};
splice_includes(&injected, None, context, 0, false, &mut config.sections)
}
pub fn sq_quote(src: &str) -> String {
let mut out = String::with_capacity(src.len() + 2);
out.push('\'');
for ch in src.chars() {
if ch == '\'' || ch == '!' {
out.push('\'');
out.push('\\');
out.push(ch);
out.push('\'');
} else {
out.push(ch);
}
}
out.push('\'');
out
}
pub fn load_effective_config(
common_git_dir: &Path,
context: &ConfigIncludeContext,
) -> Result<GitConfig> {
let mut sections = Vec::new();
for path in effective_config_paths(common_git_dir) {
load_config_file(&path, context, 0, false, &mut sections)?;
}
Ok(GitConfig {
preamble: Vec::new(),
suffix: Vec::new(),
sections,
})
}
pub fn load_pre_dispatch_config(
common_git_dir: Option<&Path>,
context: &ConfigIncludeContext,
) -> Result<GitConfig> {
let mut sections = Vec::new();
for path in pre_dispatch_config_paths(common_git_dir) {
load_config_file(&path, context, 0, false, &mut sections)?;
}
Ok(GitConfig {
preamble: Vec::new(),
suffix: Vec::new(),
sections,
})
}
fn pre_dispatch_config_paths(common_git_dir: Option<&Path>) -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(system) = system_config_path() {
paths.push(system);
}
paths.extend(global_config_paths());
if let Some(common_git_dir) = common_git_dir {
paths.push(common_git_dir.join("config"));
}
paths
}
fn effective_config_paths(common_git_dir: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(system) = system_config_path() {
paths.push(system);
}
paths.extend(global_config_paths());
paths.push(common_git_dir.join("config"));
paths
}
fn system_config_path() -> Option<PathBuf> {
if env_bool("GIT_CONFIG_NOSYSTEM") {
return None;
}
match non_empty_env("GIT_CONFIG_SYSTEM") {
Some(path) => Some(PathBuf::from(path)),
None => Some(PathBuf::from("/etc/gitconfig")),
}
}
fn global_config_paths() -> Vec<PathBuf> {
if let Some(global) = non_empty_env("GIT_CONFIG_GLOBAL") {
return vec![PathBuf::from(global)];
}
let mut paths = Vec::new();
if let Some(xdg) = xdg_config_path() {
paths.push(xdg);
}
if let Some(home) = home_dir() {
paths.push(PathBuf::from(home).join(".gitconfig"));
}
paths
}
fn xdg_config_path() -> Option<PathBuf> {
if let Some(xdg) = non_empty_env("XDG_CONFIG_HOME") {
return Some(PathBuf::from(xdg).join("git").join("config"));
}
home_dir().map(|home| {
PathBuf::from(home)
.join(".config")
.join("git")
.join("config")
})
}
fn non_empty_env(name: &str) -> Option<String> {
match std::env::var(name) {
Ok(value) if !value.is_empty() => Some(value),
_ => None,
}
}
fn env_bool(name: &str) -> bool {
match std::env::var(name) {
Ok(value) => parse_config_bool(&value).unwrap_or(!value.is_empty()),
Err(_) => false,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn canonicalize_config_key_lowercases_section_and_variable() {
assert_eq!(
canonicalize_config_key("Foo.Bar").unwrap(),
"foo.bar".to_string()
);
assert_eq!(
canonicalize_config_key("foo.CamelCase").unwrap(),
"foo.camelcase".to_string()
);
assert_eq!(
canonicalize_config_key("V.A.r").unwrap(),
"v.A.r".to_string()
);
assert_eq!(
canonicalize_config_key("a.b c.d").unwrap(),
"a.b c.d".to_string()
);
assert_eq!(
canonicalize_config_key("key.ambiguous=section.whatever").unwrap(),
"key.ambiguous=section.whatever".to_string()
);
}
#[test]
fn canonicalize_config_key_rejects_invalid_keys() {
assert_eq!(
canonicalize_config_key("name"),
Err(ConfigParameterError::NoSection("name".into()))
);
assert_eq!(
canonicalize_config_key(".a"),
Err(ConfigParameterError::NoSection(".a".into()))
);
assert_eq!(
canonicalize_config_key("a."),
Err(ConfigParameterError::NoVariableName("a.".into()))
);
assert_eq!(
canonicalize_config_key("a.0b"),
Err(ConfigParameterError::InvalidKey("a.0b".into()))
);
assert_eq!(
canonicalize_config_key("a b.c"),
Err(ConfigParameterError::InvalidKey("a b.c".into()))
);
}
#[test]
fn parse_config_parameter_splits_first_equals() {
let p = parse_config_parameter("section.foo=value with = in it").unwrap();
assert_eq!(p.canonical_key, "section.foo");
assert_eq!(p.value.as_deref(), Some("value with = in it"));
let p = parse_config_parameter("foo.flag").unwrap();
assert_eq!(p.canonical_key, "foo.flag");
assert_eq!(p.value, None);
let p = parse_config_parameter("foo.empty=").unwrap();
assert_eq!(p.value.as_deref(), Some(""));
assert_eq!(
parse_config_parameter("=foo"),
Err(ConfigParameterError::BogusParameter("=foo".into()))
);
}
#[test]
fn split_key_round_trips_section_subsection_key() {
let p = ConfigParameter {
canonical_key: "a.b c.d".to_string(),
value: Some("v".to_string()),
};
assert_eq!(p.split_key(), ("a", Some("b c"), "d"));
let p = ConfigParameter {
canonical_key: "foo.bar".to_string(),
value: None,
};
assert_eq!(p.split_key(), ("foo", None, "bar"));
let p = ConfigParameter {
canonical_key: "key.ambiguous=section.whatever".to_string(),
value: Some("value".to_string()),
};
assert_eq!(
p.split_key(),
("key", Some("ambiguous=section"), "whatever")
);
}
#[test]
fn parse_env_list_handles_old_and_new_style() {
let params = parse_config_env_list("'key.one=foo' 'key.two=bar'").unwrap();
assert_eq!(params.len(), 2);
assert_eq!(params[0].canonical_key, "key.one");
assert_eq!(params[0].value.as_deref(), Some("foo"));
assert_eq!(params[1].value.as_deref(), Some("bar"));
let params = parse_config_env_list("'key.one'='foo'").unwrap();
assert_eq!(params[0].canonical_key, "key.one");
assert_eq!(params[0].value.as_deref(), Some("foo"));
let params = parse_config_env_list("'key.flag'=").unwrap();
assert_eq!(params[0].value, None);
let params = parse_config_env_list("'key.a=section.b=value'").unwrap();
assert_eq!(params[0].canonical_key, "key.a");
assert_eq!(params[0].value.as_deref(), Some("section.b=value"));
let params = parse_config_env_list("'key.a=section.b'='value'").unwrap();
assert_eq!(params[0].canonical_key, "key.a=section.b");
assert_eq!(params[0].value.as_deref(), Some("value"));
}
#[test]
fn parse_env_list_dequote_escape_resumes_quoting() {
let params = parse_config_env_list("'env.one=one'\\''' 'env.two=two'").unwrap();
assert_eq!(params.len(), 2);
assert_eq!(params[0].canonical_key, "env.one");
assert_eq!(params[0].value.as_deref(), Some("one'"));
assert_eq!(params[1].value.as_deref(), Some("two"));
}
#[test]
fn parse_env_list_rejects_bogus_format() {
assert_eq!(
parse_config_env_list("'env.one=one'\\' 'env.two=two'"),
Err(ConfigParameterError::BogusEnvFormat)
);
assert_eq!(
parse_config_env_list("''"),
Err(ConfigParameterError::BogusParameter(String::new()))
);
assert_eq!(
parse_config_env_list("notquoted"),
Err(ConfigParameterError::BogusEnvFormat)
);
}
#[test]
fn strtoul_count_matches_git_edge_cases() {
assert_eq!(parse_strtoul_count("1"), Ok(Some(1)));
assert_eq!(parse_strtoul_count("0"), Ok(Some(0)));
assert_eq!(parse_strtoul_count(""), Ok(None));
assert_eq!(parse_strtoul_count(" "), Ok(None));
assert_eq!(
parse_strtoul_count("10a"),
Err(ConfigParameterError::BogusCount)
);
assert_eq!(
parse_strtoul_count("+"),
Err(ConfigParameterError::BogusCount)
);
assert!(parse_strtoul_count("-1").unwrap().unwrap() > i32::MAX as u64);
}
#[test]
fn sq_quote_round_trips_through_dequote() {
for value in ["plain", "with space", "with'quote", "with!bang", "a'b!c"] {
let quoted = sq_quote(value);
let bytes = quoted.as_bytes();
let (dequoted, next) = sq_dequote_step(bytes, 0).expect("sq_quote output must dequote");
assert_eq!(dequoted, value, "round-trip failed for {value:?}");
assert_eq!(next, bytes.len(), "consumed whole word for {value:?}");
}
}
#[test]
fn injected_sections_become_highest_precedence_entries() {
let params = vec![
ConfigParameter {
canonical_key: "sec.var".to_string(),
value: Some("val".to_string()),
},
ConfigParameter {
canonical_key: "sec.var".to_string(),
value: Some("VAL".to_string()),
},
];
let mut config = GitConfig::default();
config.sections.extend(injected_config_sections(¶ms));
assert_eq!(config.get("sec", None, "VAR"), Some("VAL"));
assert_eq!(
config.get_all("SEC", None, "var"),
vec![Some("val"), Some("VAL")]
);
}
#[test]
fn config_parses_sections_values_and_comments() {
let config = GitConfig::parse(
br#"
[core]
filemode = true
bare = false ; comment
[remote "origin"]
url = "https://example.invalid/repo.git"
fetch = +refs/heads/*:refs/remotes/origin/*
[feature]
enabled
"#,
)
.expect("test operation should succeed");
assert_eq!(config.get_bool("core", None, "filemode"), Some(true));
assert_eq!(config.get_bool("core", None, "bare"), Some(false));
assert_eq!(
config.get("remote", Some("origin"), "url"),
Some("https://example.invalid/repo.git")
);
assert_eq!(config.get_bool("feature", None, "enabled"), Some(true));
}
#[test]
fn config_reports_repository_object_format() {
let config = GitConfig::parse(b"[extensions]\n\tobjectformat = sha256\n")
.expect("test operation should succeed");
assert_eq!(
config
.repository_object_format()
.expect("test operation should succeed"),
ObjectFormat::Sha256
);
}
#[test]
fn expand_user_path_expands_tilde_prefix() {
assert_eq!(
expand_user_path_with_home("~/templates", Some("/tmp/sley-home")),
PathBuf::from("/tmp/sley-home/templates")
);
assert_eq!(
expand_user_path_with_home("~/templates", None),
PathBuf::from("~/templates")
);
}
#[test]
fn config_canonical_writer_round_trips() {
let config = GitConfig {
preamble: Vec::new(),
suffix: Vec::new(),
sections: vec![ConfigSection::new(
"remote",
Some("origin repo".into()),
vec![ConfigEntry::new(
"url",
Some("https://example.invalid/repo.git".into()),
)],
)],
};
let parsed =
GitConfig::parse(&config.to_canonical_bytes()).expect("test operation should succeed");
assert_eq!(parsed, config);
}
fn parse_core_x(input: &str) -> Option<String> {
GitConfig::parse(input.as_bytes())
.expect("test operation should succeed")
.get("core", None, "x")
.map(str::to_string)
}
#[test]
fn config_section_name_is_case_insensitive() {
let config =
GitConfig::parse(b"[Core]\n\tBar = value\n").expect("test operation should succeed");
assert_eq!(config.get("core", None, "bar"), Some("value"));
assert_eq!(config.get("CORE", None, "BAR"), Some("value"));
assert_eq!(config.sections[0].name, "Core");
assert_eq!(config.sections[0].entries[0].key, "Bar");
}
#[test]
fn config_subsection_name_is_case_sensitive() {
let config = GitConfig::parse(b"[remote \"Origin\"]\n\turl = x\n")
.expect("test operation should succeed");
assert_eq!(config.get("remote", Some("Origin"), "url"), Some("x"));
assert_eq!(config.get("remote", Some("origin"), "url"), None);
}
#[test]
fn config_subsection_accepts_escaped_quote_and_backslash() {
let config = GitConfig::parse(b"[remote \"with\\\"quote\"]\n\turl = x\n")
.expect("test operation should succeed");
assert_eq!(
config.sections[0].subsection.as_deref(),
Some("with\"quote")
);
assert_eq!(config.get("remote", Some("with\"quote"), "url"), Some("x"));
let config = GitConfig::parse(b"[remote \"a\\\\b\"]\n\turl = y\n")
.expect("test operation should succeed");
assert_eq!(config.sections[0].subsection.as_deref(), Some("a\\b"));
}
#[test]
fn config_subsection_unknown_escape_keeps_literal_char() {
let config = GitConfig::parse(b"[remote \"a\\nb\"]\n\turl = x\n")
.expect("test operation should succeed");
assert_eq!(config.sections[0].subsection.as_deref(), Some("anb"));
}
#[test]
fn config_dotted_subsection_is_case_insensitive() {
let config =
GitConfig::parse(b"[core.Sub]\n\tbar = x\n").expect("test operation should succeed");
assert_eq!(config.sections[0].name, "core");
assert_eq!(config.sections[0].subsection.as_deref(), Some("sub"));
assert_eq!(config.get("core", Some("sub"), "bar"), Some("x"));
assert_eq!(config.get("core", Some("Sub"), "bar"), None);
}
#[test]
fn config_dotted_subsection_keeps_inner_dots() {
let config =
GitConfig::parse(b"[a.b.c]\n\tk = v\n").expect("test operation should succeed");
assert_eq!(config.sections[0].name, "a");
assert_eq!(config.sections[0].subsection.as_deref(), Some("b.c"));
}
#[test]
fn config_bare_variable_is_boolean_true() {
let config = GitConfig::parse(b"[core]\n\tflag\n").expect("test operation should succeed");
assert_eq!(config.sections[0].entries[0].value, None);
assert_eq!(config.get_bool("core", None, "flag"), Some(true));
assert_eq!(config.get("core", None, "flag"), None);
}
#[test]
fn config_explicit_empty_value_is_boolean_false() {
let config = GitConfig::parse(b"[core]\n\tx =\n").expect("test operation should succeed");
assert_eq!(config.sections[0].entries[0].value.as_deref(), Some(""));
assert_eq!(config.get_bool("core", None, "x"), Some(false));
}
#[test]
fn config_value_unquoted_trims_surrounding_whitespace() {
assert_eq!(
parse_core_x("[core]\n\tx = hello world \n").as_deref(),
Some("hello world")
);
}
#[test]
fn config_value_quotes_preserve_spaces() {
assert_eq!(
parse_core_x("[core]\n\tx = \" spaced \"\n").as_deref(),
Some(" spaced ")
);
}
#[test]
fn config_value_mixes_quoted_and_unquoted_runs() {
assert_eq!(
parse_core_x("[core]\n\tx = a\" b \"c\n").as_deref(),
Some("a b c")
);
assert_eq!(
parse_core_x("[core]\n\tx = \"ab\" cd\n").as_deref(),
Some("ab cd")
);
assert_eq!(
parse_core_x("[core]\n\tx = a\"\"b\n").as_deref(),
Some("ab")
);
}
#[test]
fn config_value_processes_escapes_in_unquoted_and_quoted() {
assert_eq!(
parse_core_x("[core]\n\tx = a\\tb\\nc\n").as_deref(),
Some("a\tb\nc")
);
assert_eq!(
parse_core_x("[core]\n\tx = \"a\\tb\"\n").as_deref(),
Some("a\tb")
);
assert_eq!(
parse_core_x("[core]\n\tx = a\\bb\n").as_deref(),
Some("a\u{8}b")
);
assert_eq!(
parse_core_x("[core]\n\tx = a\\\"b\n").as_deref(),
Some("a\"b")
);
assert_eq!(
parse_core_x("[core]\n\tx = a\\\\b\n").as_deref(),
Some("a\\b")
);
}
#[test]
fn config_value_rejects_unknown_escape() {
assert!(GitConfig::parse(b"[core]\n\tx = a\\zb\n").is_err());
assert!(GitConfig::parse(b"[core]\n\tx = \"a\\zb\"\n").is_err());
}
#[test]
fn config_value_line_continuation_joins_lines() {
assert_eq!(
parse_core_x("[core]\n\tx = a\\\n b\n").as_deref(),
Some("a b")
);
}
#[test]
fn config_value_continuation_inside_quotes() {
assert_eq!(
parse_core_x("[core]\n\tx = \"a\\\n b\"\n").as_deref(),
Some("a b")
);
}
#[test]
fn config_value_inline_comments_stripped_outside_quotes() {
assert_eq!(
parse_core_x("[core]\n\tx = val ; comment\n").as_deref(),
Some("val")
);
assert_eq!(
parse_core_x("[core]\n\tx = val # comment\n").as_deref(),
Some("val")
);
assert_eq!(
parse_core_x("[core]\n\tx = \"a#b\"\n").as_deref(),
Some("a#b")
);
assert_eq!(
parse_core_x("[core]\n\tx = \"ab\" ; c\n").as_deref(),
Some("ab")
);
}
#[test]
fn config_bare_key_with_inline_comment_is_error() {
assert!(GitConfig::parse(b"[core]\n\tflag ; comment\n").is_err());
assert!(GitConfig::parse(b"[core]\n\tflag # comment\n").is_err());
}
#[test]
fn config_unterminated_quote_is_error() {
assert!(GitConfig::parse(b"[core]\n\tx = \"ab\n").is_err());
}
#[test]
fn config_trailing_backslash_at_eof_is_tolerated() {
assert_eq!(parse_core_x("[core]\n\tx = a\\").as_deref(), Some("a"));
}
#[test]
fn config_handles_crlf_line_endings() {
assert_eq!(parse_core_x("[core]\r\n\tx = y\r\n").as_deref(), Some("y"));
}
#[test]
fn config_value_preserves_trailing_cr() {
assert_eq!(
parse_core_x("[core]\n\tx = \"bar\r\"\n").as_deref(),
Some("bar\r")
);
let config =
GitConfig::parse(b"[core]\n\tx = \"bar\r\"\n").expect("test operation should succeed");
assert_eq!(
String::from_utf8(config.to_canonical_bytes()).expect("utf8"),
"[core]\n\tx = \"bar\r\"\n"
);
let config = GitConfig {
sections: vec![ConfigSection::new(
String::from("core"),
None,
vec![ConfigEntry::new(
String::from("foo"),
Some(format!("bar{}", '\r')),
)],
)],
..GitConfig::default()
};
assert_eq!(
String::from_utf8(config.to_canonical_bytes()).expect("utf8"),
"[core]\n\tfoo = \"bar\r\"\n"
);
}
#[test]
fn config_no_spaces_around_equals() {
assert_eq!(parse_core_x("[core]\n\tx=y\n").as_deref(), Some("y"));
}
#[test]
fn config_multi_valued_keys_preserve_order_and_duplicates() {
let config = GitConfig::parse(b"[core]\n\tx = 1\n\tx = 2\n\tx = 1\n")
.expect("test operation should succeed");
assert_eq!(
config.get_all("core", None, "x"),
vec![Some("1"), Some("2"), Some("1")]
);
assert_eq!(config.get("core", None, "x"), Some("1"));
}
#[test]
fn config_get_all_spans_multiple_sections_in_order() {
let config = GitConfig::parse(b"[core]\n\tx = a\n[other]\n\ty = z\n[core]\n\tx = b\n")
.expect("test operation should succeed");
assert_eq!(
config.get_all("core", None, "x"),
vec![Some("a"), Some("b")]
);
}
#[test]
fn config_rejects_value_before_section() {
assert!(GitConfig::parse(b"\tx = y\n").is_err());
}
#[test]
fn config_rejects_invalid_names() {
assert!(GitConfig::parse(b"[core]\n\tx_y = 1\n").is_err());
assert!(GitConfig::parse(b"[a_b]\n\tx = 1\n").is_err());
}
#[test]
fn config_variable_name_must_start_with_letter() {
assert!(GitConfig::parse(b"[core]\n\t1x = y\n").is_err());
assert!(GitConfig::parse(b"[core]\n\t-x = y\n").is_err());
assert_eq!(parse_core_x("[core]\n\tx = ok\n").as_deref(), Some("ok"));
let config =
GitConfig::parse(b"[core]\n\tx1-y = z\n").expect("test operation should succeed");
assert_eq!(config.get("core", None, "x1-y"), Some("z"));
}
#[test]
fn config_section_name_may_start_with_digit() {
let config =
GitConfig::parse(b"[1core]\n\tx = y\n").expect("test operation should succeed");
assert_eq!(config.get("1core", None, "x"), Some("y"));
}
#[test]
fn config_comments_and_blank_lines_are_preserved() {
let source = b"# top\n; also\n\n[core]\n\n\tx = y # inline\n# trailing\n";
let config = GitConfig::parse(source).expect("test operation should succeed");
assert_eq!(config.get("core", None, "x"), Some("y"));
assert_eq!(
config.preamble,
vec![
ConfigPreambleLine::Comment {
sigil: '#',
text: "top".into(),
},
ConfigPreambleLine::Comment {
sigil: ';',
text: "also".into(),
},
ConfigPreambleLine::Blank,
]
);
assert_eq!(
config.sections[0].entries[0].preamble,
vec![ConfigPreambleLine::Blank]
);
assert_eq!(
config.sections[0].entries[0].comment.as_deref(),
Some("inline")
);
assert_eq!(
config.suffix,
vec![ConfigPreambleLine::Comment {
sigil: '#',
text: "trailing".into(),
}]
);
let preserved = config.to_preserved_bytes();
let reparsed = GitConfig::parse(&preserved).expect("test operation should succeed");
assert_eq!(reparsed, config);
assert_eq!(preserved, source);
}
#[test]
fn parse_config_bool_keywords() {
for truthy in ["true", "TRUE", "yes", "Yes", "on", "ON", "1"] {
assert_eq!(parse_config_bool(truthy), Some(true), "{truthy}");
}
for falsy in ["false", "FALSE", "no", "No", "off", "OFF", "0", ""] {
assert_eq!(parse_config_bool(falsy), Some(false), "{falsy}");
}
}
#[test]
fn parse_config_bool_accepts_integers() {
assert_eq!(parse_config_bool("5"), Some(true));
assert_eq!(parse_config_bool("-3"), Some(true));
assert_eq!(parse_config_bool("0"), Some(false));
assert_eq!(parse_config_bool("0x10"), Some(true));
assert_eq!(parse_config_bool("foo"), None);
}
#[test]
fn parse_config_int_units_and_bases() {
assert_eq!(parse_config_int("1k"), Some(1024));
assert_eq!(parse_config_int("1K"), Some(1024));
assert_eq!(parse_config_int("1m"), Some(1024 * 1024));
assert_eq!(parse_config_int("1M"), Some(1024 * 1024));
assert_eq!(parse_config_int("2g"), Some(2 * 1024 * 1024 * 1024));
assert_eq!(parse_config_int("1G"), Some(1024 * 1024 * 1024));
assert_eq!(parse_config_int("5"), Some(5));
assert_eq!(parse_config_int("-5"), Some(-5));
assert_eq!(parse_config_int("0x10"), Some(16));
assert_eq!(parse_config_int("010"), Some(8));
}
#[test]
fn parse_config_int_rejects_invalid() {
assert_eq!(parse_config_int(""), None);
assert_eq!(parse_config_int("foo"), None);
assert_eq!(parse_config_int("1 k"), None);
assert_eq!(parse_config_int("1.5"), None);
assert_eq!(parse_config_int("9999999999999999999g"), None);
}
#[test]
fn parse_config_bool_or_int_typing() {
assert_eq!(
parse_config_bool_or_int("yes"),
Some(ConfigBoolOrInt::Bool(true))
);
assert_eq!(
parse_config_bool_or_int("off"),
Some(ConfigBoolOrInt::Bool(false))
);
assert_eq!(
parse_config_bool_or_int(""),
Some(ConfigBoolOrInt::Bool(false))
);
assert_eq!(parse_config_bool_or_int("5"), Some(ConfigBoolOrInt::Int(5)));
assert_eq!(parse_config_bool_or_int("0"), Some(ConfigBoolOrInt::Int(0)));
assert_eq!(parse_config_bool_or_int("1"), Some(ConfigBoolOrInt::Int(1)));
assert_eq!(
parse_config_bool_or_int("1k"),
Some(ConfigBoolOrInt::Int(1024))
);
assert_eq!(parse_config_bool_or_int("foo"), None);
}
#[test]
fn config_canonical_value_quoting_matches_git() {
let cases = [
("simple", "simple"),
("a b c", "a b c"), (" lead", "\" lead\""), ("trail ", "\"trail \""), ("a#b", "\"a#b\""), ("a;b", "\"a;b\""), ("a\"b", "a\\\"b"), ("a\\b", "a\\\\b"), ("a\tb", "a\\tb"), ("a\nb", "a\\nb"), ];
for (value, expected) in cases {
let config = GitConfig {
preamble: Vec::new(),
suffix: Vec::new(),
sections: vec![ConfigSection::new(
"core",
None,
vec![ConfigEntry::new("x", Some(value.to_string()))],
)],
};
let bytes = config.to_canonical_bytes();
let text = String::from_utf8(bytes).expect("test operation should succeed");
let expected_line = format!("\tx = {expected}\n");
assert!(
text.contains(&expected_line),
"value {value:?} serialized to {text:?}, expected to contain {expected_line:?}"
);
}
}
#[test]
fn config_subsection_header_only_escapes_quote_and_backslash() {
let config = GitConfig {
preamble: Vec::new(),
suffix: Vec::new(),
sections: vec![ConfigSection::new(
"remote",
Some("a\"b\\c".into()),
vec![ConfigEntry::new("url", Some("x".into()))],
)],
};
let text =
String::from_utf8(config.to_canonical_bytes()).expect("test operation should succeed");
assert!(
text.starts_with("[remote \"a\\\"b\\\\c\"]\n"),
"unexpected header: {text:?}"
);
}
#[test]
fn config_round_trip_is_stable_for_tricky_values() {
let values = [
"simple",
"a b c",
" leading and trailing ",
"with#hash",
"with;semi",
"with\"quote",
"with\\backslash",
"with\ttab",
"with\nnewline",
" # ; \" \\ \t mixed ",
"",
];
for value in values {
let original = GitConfig {
preamble: Vec::new(),
suffix: Vec::new(),
sections: vec![ConfigSection::new(
"core",
Some("a b\"c".into()),
vec![
ConfigEntry::new("x", Some(value.to_string())),
ConfigEntry::new("flag", None),
],
)],
};
let serialized = original.to_canonical_bytes();
let reparsed = GitConfig::parse(&serialized).expect("test operation should succeed");
assert_eq!(reparsed, original, "value {value:?} did not round-trip");
assert_eq!(
reparsed.to_canonical_bytes(),
serialized,
"value {value:?} is not a serialization fixpoint"
);
}
}
#[test]
fn config_round_trip_preserves_multi_value_order() {
let original = GitConfig {
preamble: Vec::new(),
suffix: Vec::new(),
sections: vec![ConfigSection::new(
"core",
None,
vec![
ConfigEntry::new("x", Some("first".into())),
ConfigEntry::new("x", Some("second".into())),
ConfigEntry::new("x", Some("first".into())),
],
)],
};
let reparsed = GitConfig::parse(&original.to_canonical_bytes())
.expect("test operation should succeed");
assert_eq!(reparsed, original);
assert_eq!(
reparsed.get_all("core", None, "x"),
vec![Some("first"), Some("second"), Some("first")]
);
}
fn unique_temp_dir(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time before unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("sley-{name}-{}-{nanos}", std::process::id()))
}
fn unique_include_dir(tag: &str) -> PathBuf {
let dir = unique_temp_dir(tag);
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
#[test]
fn glob_matcher_handles_stars_classes_and_double_star() {
assert!(glob_match("foo*", "foobar", false));
assert!(glob_match("*bar", "foobar", false));
assert!(!glob_match("foo*", "foo/bar", false));
assert!(glob_match("f?o", "foo", false));
assert!(!glob_match("f?o", "f/o", false));
assert!(glob_match("[a-c]oo", "boo", false));
assert!(!glob_match("[a-c]oo", "zoo", false));
assert!(glob_match("[!a-c]oo", "zoo", false));
assert!(glob_match("/home/**", "/home/user/work/.git", false));
assert!(glob_match("/home/**", "/home", false));
assert!(glob_match("**/foo/.git", "/a/b/foo/.git", false));
assert!(glob_match("**/foo/.git", "/foo/.git", false));
assert!(glob_match("a/**/b", "a/b", false));
assert!(glob_match("a/**/b", "a/x/y/b", false));
assert!(!glob_match("a/**/b", "a/xb", false));
assert!(glob_match("/Home/**", "/home/user/.git", true));
assert!(!glob_match("/Home/**", "/home/user/.git", false));
}
#[test]
fn config_include_unconditional_merges_and_overrides() {
let dir = unique_include_dir("inc-uncond");
let main = dir.join("config");
let extra = dir.join("extra.cfg");
fs::write(
&main,
format!(
"[core]\n\tfilemode = false\n[include]\n\tpath = {}\n",
extra.display()
),
)
.expect("test operation should succeed");
fs::write(&extra, "[core]\n\tfilemode = true\n\tbig = yes\n")
.expect("test operation should succeed");
let ctx = ConfigIncludeContext::default();
let config = load_config_with_includes(&main, &ctx).expect("test operation should succeed");
assert_eq!(config.get_bool("core", None, "filemode"), Some(true));
assert_eq!(config.get_bool("core", None, "big"), Some(true));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn config_include_relative_path_resolves_against_including_file() {
let dir = unique_include_dir("inc-rel");
let sub = dir.join("sub");
fs::create_dir_all(&sub).expect("test operation should succeed");
let main = dir.join("config");
fs::write(&main, "[include]\n\tpath = sub/child.cfg\n")
.expect("test operation should succeed");
fs::write(sub.join("child.cfg"), "[user]\n\temail = a@b.c\n")
.expect("test operation should succeed");
let ctx = ConfigIncludeContext::default();
let config = load_config_with_includes(&main, &ctx).expect("test operation should succeed");
assert_eq!(config.get("user", None, "email"), Some("a@b.c"));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn config_include_missing_file_is_ignored() {
let dir = unique_include_dir("inc-missing");
let main = dir.join("config");
fs::write(
&main,
"[core]\n\tfilemode = true\n[include]\n\tpath = does-not-exist.cfg\n",
)
.expect("test operation should succeed");
let ctx = ConfigIncludeContext::default();
let config = load_config_with_includes(&main, &ctx).expect("test operation should succeed");
assert_eq!(config.get_bool("core", None, "filemode"), Some(true));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn config_include_if_gitdir_match_and_non_match() {
let dir = unique_include_dir("inc-gitdir");
let work = dir.join("work");
fs::create_dir_all(&work).expect("test operation should succeed");
let main = dir.join("config");
let work_git = work.join(".git");
fs::write(
&main,
format!(
"[includeIf \"gitdir:{}/\"]\n\tpath = matched.cfg\n",
work.display()
),
)
.expect("test operation should succeed");
fs::write(dir.join("matched.cfg"), "[user]\n\tname = work\n")
.expect("test operation should succeed");
let matching = ConfigIncludeContext::new(Some(work_git.clone()), None);
let config =
load_config_with_includes(&main, &matching).expect("test operation should succeed");
assert_eq!(config.get("user", None, "name"), Some("work"));
let other = ConfigIncludeContext::new(Some(dir.join("other/.git")), None);
let config =
load_config_with_includes(&main, &other).expect("test operation should succeed");
assert_eq!(config.get("user", None, "name"), None);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn config_include_if_gitdir_unanchored_matches_path_tail() {
let dir = unique_include_dir("inc-gitdir-unanchored");
let work = dir.join("foo");
fs::create_dir_all(&work).expect("test operation should succeed");
let main = dir.join("config");
fs::write(&main, "[includeIf \"gitdir:foo/\"]\n\tpath = matched.cfg\n")
.expect("test operation should succeed");
fs::write(dir.join("matched.cfg"), "[user]\n\tname = unanchored\n")
.expect("test operation should succeed");
let ctx = ConfigIncludeContext::new(Some(work.join(".git")), None);
let config = load_config_with_includes(&main, &ctx).expect("test operation should succeed");
assert_eq!(config.get("user", None, "name"), Some("unanchored"));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn config_include_if_gitdir_case_insensitive() {
let dir = unique_include_dir("inc-gitdir-i");
let main = dir.join("config");
fs::write(
&main,
"[includeIf \"gitdir/i:/SOME/Path/**\"]\n\tpath = ci.cfg\n",
)
.expect("test operation should succeed");
fs::write(dir.join("ci.cfg"), "[user]\n\tname = ci\n")
.expect("test operation should succeed");
let ctx = ConfigIncludeContext::new(Some(PathBuf::from("/some/path/repo/.git")), None);
let config = load_config_with_includes(&main, &ctx).expect("test operation should succeed");
assert_eq!(config.get("user", None, "name"), Some("ci"));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn config_include_if_onbranch_match() {
let dir = unique_include_dir("inc-onbranch");
let main = dir.join("config");
fs::write(
&main,
"[includeIf \"onbranch:feature/*\"]\n\tpath = feat.cfg\n",
)
.expect("test operation should succeed");
fs::write(dir.join("feat.cfg"), "[user]\n\tname = feature\n")
.expect("test operation should succeed");
let on = ConfigIncludeContext::new(None, Some("feature/login".into()));
let config = load_config_with_includes(&main, &on).expect("test operation should succeed");
assert_eq!(config.get("user", None, "name"), Some("feature"));
let off = ConfigIncludeContext::new(None, Some("main".into()));
let config = load_config_with_includes(&main, &off).expect("test operation should succeed");
assert_eq!(config.get("user", None, "name"), None);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn command_line_include_absolute_paths_are_spliced() {
let dir = unique_include_dir("inc-cmdline-abs");
let include = dir.join("cmd.cfg");
fs::write(&include, "[user]\n\tname = command\n").expect("test operation should succeed");
let params = vec![ConfigParameter {
canonical_key: "include.path".into(),
value: Some(include.display().to_string()),
}];
let mut stack = ConfigStack::new();
stack
.push_parameters_with_includes(¶ms, &ConfigIncludeContext::default())
.expect("test operation should succeed");
assert!(stack.get("include", None, "path").is_some());
assert_eq!(
stack
.get("user", None, "name")
.and_then(|entry| entry.value.as_deref()),
Some("command")
);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn command_line_include_relative_paths_require_file_origin() {
let params = vec![ConfigParameter {
canonical_key: "include.path".into(),
value: Some("relative.cfg".into()),
}];
let mut stack = ConfigStack::new();
let err = stack
.push_parameters_with_includes(¶ms, &ConfigIncludeContext::default())
.expect_err("relative command-line include should fail");
match err {
GitError::InvalidFormat(message) => {
assert_eq!(message, "relative config includes must come from files");
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn config_include_recursion_depth_limit() {
let dir = unique_include_dir("inc-depth");
let total = CONFIG_MAX_INCLUDE_DEPTH + 3;
for i in 0..total {
let path = dir.join(format!("c{i}.cfg"));
let next = dir.join(format!("c{}.cfg", i + 1));
fs::write(
&path,
format!(
"[s{i}]\n\tk = v{i}\n[include]\n\tpath = {}\n",
next.display()
),
)
.expect("test operation should succeed");
}
let entry = dir.join("c0.cfg");
let ctx = ConfigIncludeContext::default();
let err = load_config_with_includes(&entry, &ctx).expect_err("test operation should fail");
assert!(matches!(err, GitError::InvalidFormat(_)), "got {err:?}");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn config_resolve_includes_on_parsed_value() {
let dir = unique_include_dir("inc-parsed");
let extra = dir.join("extra.cfg");
fs::write(&extra, "[user]\n\temail = parsed@x.y\n").expect("test operation should succeed");
let parsed =
GitConfig::parse(format!("[include]\n\tpath = {}\n", extra.display()).as_bytes())
.expect("test operation should succeed");
assert_eq!(parsed.get("user", None, "email"), None);
let resolved = parsed
.resolve_includes(&dir, &ConfigIncludeContext::default())
.expect("test operation should succeed");
assert_eq!(resolved.get("user", None, "email"), Some("parsed@x.y"));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn config_nested_include_resolves_within_depth() {
let dir = unique_include_dir("inc-nested");
let main = dir.join("config");
let mid = dir.join("mid.cfg");
let leaf = dir.join("leaf.cfg");
fs::write(&main, format!("[include]\n\tpath = {}\n", mid.display()))
.expect("test operation should succeed");
fs::write(&mid, format!("[include]\n\tpath = {}\n", leaf.display()))
.expect("test operation should succeed");
fs::write(&leaf, "[deep]\n\tvalue = ok\n").expect("test operation should succeed");
let ctx = ConfigIncludeContext::default();
let config = load_config_with_includes(&main, &ctx).expect("test operation should succeed");
assert_eq!(config.get("deep", None, "value"), Some("ok"));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn config_include_if_hasconfig_remote_url_match_and_non_match() {
let dir = unique_include_dir("inc-hasconfig");
let include_this = dir.join("include-this");
let dont_include = dir.join("dont-include-that");
fs::write(&include_this, "[user]\n\tthis = this-is-included\n")
.expect("test operation should succeed");
fs::write(&dont_include, "[user]\n\tthat = that-is-not-included\n")
.expect("test operation should succeed");
let main = dir.join("config");
fs::write(
&main,
format!(
"[includeIf \"hasconfig:remote.*.url:foourl\"]\n\tpath = {}\n\
[includeIf \"hasconfig:remote.*.url:barurl\"]\n\tpath = {}\n\
[remote \"foo\"]\n\turl = foourl\n",
include_this.display(),
dont_include.display()
),
)
.expect("test operation should succeed");
let ctx = ConfigIncludeContext::default();
let config = load_config_with_includes(&main, &ctx).expect("test operation should succeed");
assert_eq!(config.get("user", None, "this"), Some("this-is-included"));
assert_eq!(config.get("user", None, "that"), None);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn config_include_if_hasconfig_respects_order_within_file() {
let dir = unique_include_dir("inc-hasconfig-order");
let include_file = dir.join("include-two-three");
fs::write(
&include_file,
"[user]\n\ttwo = included-config\n\tthree = included-config\n",
)
.expect("test operation should succeed");
let main = dir.join("config");
fs::write(
&main,
format!(
"[remote \"foo\"]\n\turl = foourl\n\
[user]\n\tone = main-config\n\ttwo = main-config\n\
[includeIf \"hasconfig:remote.*.url:foourl\"]\n\tpath = {}\n\
[user]\n\tthree = main-config\n",
include_file.display()
),
)
.expect("test operation should succeed");
let ctx = ConfigIncludeContext::default();
let config = load_config_with_includes(&main, &ctx).expect("test operation should succeed");
assert_eq!(config.get("user", None, "one"), Some("main-config"));
assert_eq!(config.get("user", None, "two"), Some("included-config"));
assert_eq!(config.get("user", None, "three"), Some("main-config"));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn config_include_if_hasconfig_remote_url_globs() {
let dir = unique_include_dir("inc-hasconfig-globs");
let write_user = |name: &str, key: &str| {
fs::write(dir.join(name), format!("[user]\n\t{key} = yes\n"))
.expect("test operation should succeed");
};
write_user("double-star-start", "dss");
write_user("double-star-end", "dse");
write_user("double-star-middle", "dsm");
write_user("single-star-middle", "ssm");
write_user("no", "no");
let main = dir.join("config");
fs::write(
&main,
format!(
"[remote \"foo\"]\n\turl = https://foo/bar/baz\n\
[includeIf \"hasconfig:remote.*.url:**/baz\"]\n\tpath = {}\n\
[includeIf \"hasconfig:remote.*.url:**/nomatch\"]\n\tpath = {}\n\
[includeIf \"hasconfig:remote.*.url:https:/**\"]\n\tpath = {}\n\
[includeIf \"hasconfig:remote.*.url:nomatch:/**\"]\n\tpath = {}\n\
[includeIf \"hasconfig:remote.*.url:https:/**/baz\"]\n\tpath = {}\n\
[includeIf \"hasconfig:remote.*.url:https:/**/nomatch\"]\n\tpath = {}\n\
[includeIf \"hasconfig:remote.*.url:https://*/bar/baz\"]\n\tpath = {}\n\
[includeIf \"hasconfig:remote.*.url:https://*/baz\"]\n\tpath = {}\n",
dir.join("double-star-start").display(),
dir.join("no").display(),
dir.join("double-star-end").display(),
dir.join("no").display(),
dir.join("double-star-middle").display(),
dir.join("no").display(),
dir.join("single-star-middle").display(),
dir.join("no").display(),
),
)
.expect("test operation should succeed");
let ctx = ConfigIncludeContext::default();
let config = load_config_with_includes(&main, &ctx).expect("test operation should succeed");
assert_eq!(config.get("user", None, "dss"), Some("yes"));
assert_eq!(config.get("user", None, "dse"), Some("yes"));
assert_eq!(config.get("user", None, "dsm"), Some("yes"));
assert_eq!(config.get("user", None, "ssm"), Some("yes"));
assert_eq!(config.get("user", None, "no"), None);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn config_include_if_hasconfig_forbids_remote_url_in_included_file() {
let dir = unique_include_dir("inc-hasconfig-forbid");
let include_file = dir.join("include-with-url");
fs::write(&include_file, "[remote \"bar\"]\n\turl = barurl\n")
.expect("test operation should succeed");
let main = dir.join("config");
fs::write(
&main,
format!(
"[remote \"foo\"]\n\turl = foourl\n\
[includeIf \"hasconfig:remote.*.url:foourl\"]\n\tpath = {}\n",
include_file.display()
),
)
.expect("test operation should succeed");
let ctx = ConfigIncludeContext::default();
let err = load_config_with_includes(&main, &ctx).expect_err("test operation should fail");
assert!(
matches!(err, GitError::InvalidFormat(ref message) if message.contains(
"remote URLs cannot be configured in file directly or indirectly included by includeIf.hasconfig:remote.*.url"
)),
"got {err:?}"
);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn config_include_if_unknown_hasconfig_does_not_match() {
let dir = unique_include_dir("inc-hasconfig-unknown");
let include_file = dir.join("extra.cfg");
fs::write(&include_file, "[user]\n\tname = included\n")
.expect("test operation should succeed");
let main = dir.join("config");
fs::write(
&main,
format!(
"[includeIf \"hasconfig:core.repositoryformatversion:0\"]\n\tpath = {}\n",
include_file.display()
),
)
.expect("test operation should succeed");
let ctx = ConfigIncludeContext::default();
let config = load_config_with_includes(&main, &ctx).expect("test operation should succeed");
assert_eq!(config.get("user", None, "name"), None);
fs::remove_dir_all(&dir).ok();
}
fn merge_layers(layers: &[&str]) -> GitConfig {
let mut sections = Vec::new();
for layer in layers {
sections.extend(
GitConfig::parse(layer.as_bytes())
.expect("test operation should succeed")
.sections,
);
}
GitConfig {
preamble: Vec::new(),
suffix: Vec::new(),
sections,
}
}
#[test]
fn effective_config_paths_are_ordered_system_global_repo() {
let repo = Path::new("/tmp/sley-effective-paths/repo");
let paths = effective_config_paths(repo);
assert_eq!(
paths.last(),
Some(&repo.join("config")),
"repository config must be the highest-precedence (last) layer"
);
let repo_index = paths
.iter()
.position(|path| path == &repo.join("config"))
.expect("repo config present");
assert_eq!(repo_index, paths.len() - 1);
}
#[test]
fn effective_merge_semantics_are_last_layer_wins() {
let config = merge_layers(&[
"[user]\n\tname = System\n[layer]\n\tsystem = yes\n",
"[user]\n\tname = Global\n[layer]\n\tglobal = yes\n",
"[user]\n\tname = Repo\n[layer]\n\trepo = yes\n",
]);
assert_eq!(config.get("user", None, "name"), Some("Repo"));
assert_eq!(
config.get_all("user", None, "name"),
vec![Some("System"), Some("Global"), Some("Repo")]
);
assert_eq!(config.get("layer", None, "system"), Some("yes"));
assert_eq!(config.get("layer", None, "global"), Some("yes"));
assert_eq!(config.get("layer", None, "repo"), Some("yes"));
}
#[test]
fn env_bool_matches_git_env_bool_semantics() {
assert_eq!(parse_config_bool("1"), Some(true));
assert_eq!(parse_config_bool("true"), Some(true));
assert_eq!(parse_config_bool("yes"), Some(true));
assert_eq!(parse_config_bool("0"), Some(false));
assert_eq!(parse_config_bool("false"), Some(false));
assert_eq!(parse_config_bool(""), Some(false));
}
}