use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Mutex, OnceLock};
use crate::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigScope {
Global,
Local,
System,
}
impl ConfigScope {
fn flag(self) -> &'static str {
match self {
Self::Global => "--global",
Self::Local => "--local",
Self::System => "--system",
}
}
}
pub fn get(cwd: &Path, scope: ConfigScope, key: &str) -> Result<Option<String>, Error> {
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.args(["config", "--includes", scope.flag(), "--get", key])
.output()?;
match out.status.code() {
Some(0) => Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_owned())),
Some(1) | Some(128) | Some(129) => Ok(None),
_ => Err(Error::Failed(
String::from_utf8_lossy(&out.stderr).trim().to_owned(),
)),
}
}
fn get_any_scope(cwd: &Path, key: &str) -> Result<Option<String>, Error> {
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.args(["config", "--includes", "--get", key])
.output()?;
match out.status.code() {
Some(0) => Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_owned())),
Some(1) | Some(128) => Ok(None),
_ => Err(Error::Failed(
String::from_utf8_lossy(&out.stderr).trim().to_owned(),
)),
}
}
pub fn get_from_file(cwd: &Path, file: &Path, key: &str) -> Result<Option<String>, Error> {
if !cwd.join(file).is_file() {
return Ok(None);
}
let file_arg = format!("--file={}", file.display());
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.args(["config", "--includes", &file_arg, "--get", key])
.output()?;
match out.status.code() {
Some(0) => Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_owned())),
Some(1) => Ok(None),
_ => Err(Error::Failed(
String::from_utf8_lossy(&out.stderr).trim().to_owned(),
)),
}
}
pub fn get_effective(cwd: &Path, key: &str) -> Result<Option<String>, Error> {
if let Some(v) = get_any_scope(cwd, key)? {
return Ok(Some(v));
}
get_from_lfsconfig(cwd, key)
}
pub fn get_from_lfsconfig(cwd: &Path, key: &str) -> Result<Option<String>, Error> {
let entries = load_lfsconfig(cwd)?;
Ok(entries
.get(&fold_key(key))
.and_then(|vs| vs.last().cloned()))
}
pub fn set(cwd: &Path, scope: ConfigScope, key: &str, value: &str) -> Result<(), Error> {
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.args(["config", scope.flag(), key, value])
.output()?;
if out.status.success() {
Ok(())
} else {
Err(Error::Failed(
String::from_utf8_lossy(&out.stderr).trim().to_owned(),
))
}
}
pub fn unset(cwd: &Path, scope: ConfigScope, key: &str) -> Result<(), Error> {
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.args(["config", scope.flag(), "--unset", key])
.output()?;
match out.status.code() {
Some(0) => Ok(()),
Some(5) => Ok(()),
_ => Err(Error::Failed(
String::from_utf8_lossy(&out.stderr).trim().to_owned(),
)),
}
}
const SAFE_KEYS: &[&str] = &[
"lfs.allowincompletepush",
"lfs.fetchexclude",
"lfs.fetchinclude",
"lfs.gitprotocol",
"lfs.locksverify",
"lfs.pushurl",
"lfs.skipdownloaderrors",
"lfs.url",
];
fn is_safe_key(key: &str) -> bool {
let parts: Vec<&str> = key.split('.').collect();
if parts.len() == 4 && parts[0] == "lfs" && parts[1] == "extension" && parts[3] == "priority" {
return true;
}
if parts.len() >= 3 && parts[0] == "remote" && *parts.last().unwrap() == "lfsurl" {
return true;
}
if parts.len() >= 3 && *parts.last().unwrap() == "access" {
return true;
}
SAFE_KEYS.contains(&key)
}
fn fold_key(key: &str) -> String {
let parts: Vec<&str> = key.split('.').collect();
if parts.len() < 3 {
return key.to_lowercase();
}
let last = parts.len() - 1;
let middle = parts[1..last].join(".");
format!(
"{}.{}.{}",
parts[0].to_lowercase(),
middle,
parts[last].to_lowercase(),
)
}
type LfsConfigEntries = HashMap<String, Vec<String>>;
static LFSCONFIG_CACHE: OnceLock<Mutex<HashMap<PathBuf, LfsConfigEntries>>> = OnceLock::new();
fn lfsconfig_cache() -> &'static Mutex<HashMap<PathBuf, LfsConfigEntries>> {
LFSCONFIG_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn load_lfsconfig(cwd: &Path) -> Result<LfsConfigEntries, Error> {
let root = repo_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
let canon = root.canonicalize().unwrap_or_else(|_| root.clone());
if let Some(cached) = lfsconfig_cache().lock().unwrap().get(&canon) {
return Ok(cached.clone());
}
let bare = is_bare(cwd);
let mut entries = None;
if !bare && root.join(".lfsconfig").is_file() {
entries = Some(read_lfsconfig_file(&root)?);
}
if entries.is_none() && !bare {
entries = read_lfsconfig_blob(cwd, ":.lfsconfig")?;
}
if entries.is_none() {
entries = read_lfsconfig_blob(cwd, "HEAD:.lfsconfig")?;
}
let entries = entries.unwrap_or_default();
let (safe, ignored) = filter_safe(entries);
if !ignored.is_empty() {
eprintln!("warning: These unsafe '.lfsconfig' keys were ignored:");
eprintln!();
for key in &ignored {
eprintln!(" {key}");
}
}
lfsconfig_cache()
.lock()
.unwrap()
.insert(canon, safe.clone());
Ok(safe)
}
fn read_lfsconfig_file(root: &Path) -> Result<LfsConfigEntries, Error> {
let out = Command::new("git")
.arg("-C")
.arg(root)
.args(["config", "--includes", "--file=.lfsconfig", "--list"])
.output()?;
if !out.status.success() {
return Err(Error::Failed(format!(
"git config --file=.lfsconfig --list failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(parse_list_output(&out.stdout))
}
fn read_lfsconfig_blob(cwd: &Path, revision: &str) -> Result<Option<LfsConfigEntries>, Error> {
let blob_arg = format!("--blob={revision}");
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.args(["config", "--includes", &blob_arg, "--list"])
.output()?;
match out.status.code() {
Some(0) => Ok(Some(parse_list_output(&out.stdout))),
_ => Ok(None),
}
}
fn is_bare(cwd: &Path) -> bool {
Command::new("git")
.arg("-C")
.arg(cwd)
.args(["rev-parse", "--is-bare-repository"])
.output()
.ok()
.filter(|o| o.status.success())
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
}
fn repo_root(cwd: &Path) -> Option<PathBuf> {
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_owned();
if s.is_empty() {
return None;
}
Some(PathBuf::from(s))
}
fn parse_list_output(bytes: &[u8]) -> LfsConfigEntries {
let s = String::from_utf8_lossy(bytes);
let mut entries: LfsConfigEntries = HashMap::new();
for line in s.lines() {
if let Some((k, v)) = line.split_once('=') {
entries.entry(k.to_owned()).or_default().push(v.to_owned());
}
}
entries
}
fn filter_safe(entries: LfsConfigEntries) -> (LfsConfigEntries, Vec<String>) {
let mut safe = LfsConfigEntries::new();
let mut ignored = Vec::new();
let mut keys: Vec<String> = entries.keys().cloned().collect();
keys.sort();
for k in keys {
let values = entries.get(&k).cloned().unwrap_or_default();
if is_safe_key(&k) {
safe.insert(k, values);
} else {
ignored.push(k);
}
}
(safe, ignored)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn init_repo() -> TempDir {
let tmp = TempDir::new().unwrap();
let status = Command::new("git")
.args(["init", "--quiet"])
.arg(tmp.path())
.status()
.unwrap();
assert!(status.success());
tmp
}
#[test]
fn get_unset_key_returns_none() {
let tmp = init_repo();
let v = get(tmp.path(), ConfigScope::Local, "filter.lfs.clean").unwrap();
assert_eq!(v, None);
}
#[test]
fn set_then_get_round_trips() {
let tmp = init_repo();
set(
tmp.path(),
ConfigScope::Local,
"filter.lfs.clean",
"git-lfs clean -- %f",
)
.unwrap();
let v = get(tmp.path(), ConfigScope::Local, "filter.lfs.clean").unwrap();
assert_eq!(v.as_deref(), Some("git-lfs clean -- %f"));
}
#[test]
fn unset_removes_key() {
let tmp = init_repo();
set(
tmp.path(),
ConfigScope::Local,
"filter.lfs.required",
"true",
)
.unwrap();
unset(tmp.path(), ConfigScope::Local, "filter.lfs.required").unwrap();
let v = get(tmp.path(), ConfigScope::Local, "filter.lfs.required").unwrap();
assert_eq!(v, None);
}
#[test]
fn unset_missing_key_is_ok() {
let tmp = init_repo();
unset(tmp.path(), ConfigScope::Local, "never.was.set").unwrap();
}
#[test]
fn safe_key_classification() {
assert!(is_safe_key("lfs.url"));
assert!(is_safe_key("lfs.fetchinclude"));
assert!(is_safe_key("lfs.locksverify"));
assert!(is_safe_key("lfs.http://example.com/repo.git.access"));
assert!(is_safe_key("lfs.https://host.access"));
assert!(is_safe_key("remote.origin.lfsurl"));
assert!(!is_safe_key("remote.origin.url"));
assert!(!is_safe_key("remote.origin.pushurl"));
assert!(is_safe_key("lfs.extension.foo.priority"));
assert!(!is_safe_key("lfs.extension.foo.clean"));
assert!(!is_safe_key("lfs.extension.foo.smudge"));
assert!(!is_safe_key("core.askpass"));
assert!(!is_safe_key("credential.helper"));
assert!(!is_safe_key("lfs.concurrenttransfers"));
}
#[test]
fn fold_key_lowercases_first_and_last_only() {
assert_eq!(fold_key("LFS.URL"), "lfs.url");
assert_eq!(
fold_key("LFS.http://Example.com.ACCESS"),
"lfs.http://Example.com.access"
);
assert_eq!(fold_key("Section.Key"), "section.key");
}
#[test]
fn parse_list_handles_values_with_equals() {
let raw = b"lfs.url=http://example.com/path?x=1\nremote.origin.lfsurl=http://a\n";
let parsed = parse_list_output(raw);
assert_eq!(
parsed["lfs.url"],
vec!["http://example.com/path?x=1".to_owned()]
);
assert_eq!(parsed["remote.origin.lfsurl"], vec!["http://a".to_owned()]);
}
#[test]
fn parse_list_collects_repeated_keys_in_order() {
let raw = b"url.http://a/.insteadof=alias\nurl.http://b/.insteadof=alias\n";
let parsed = parse_list_output(raw);
assert_eq!(parsed["url.http://a/.insteadof"], vec!["alias".to_owned()]);
assert_eq!(parsed["url.http://b/.insteadof"], vec!["alias".to_owned()]);
}
#[test]
fn lfsconfig_falls_back_to_head_blob_when_no_working_tree_file() {
let tmp = init_repo();
let path = tmp.path();
let _ = Command::new("git")
.arg("-C")
.arg(path)
.args(["config", "user.name", "test"])
.status();
let _ = Command::new("git")
.arg("-C")
.arg(path)
.args(["config", "user.email", "test@example.com"])
.status();
std::fs::write(
path.join(".lfsconfig"),
"[lfs]\n\turl = http://from-head/\n",
)
.unwrap();
let _ = Command::new("git")
.arg("-C")
.arg(path)
.args(["add", ".lfsconfig"])
.status();
let _ = Command::new("git")
.arg("-C")
.arg(path)
.args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
.status();
std::fs::remove_file(path.join(".lfsconfig")).unwrap();
let entries = read_lfsconfig_blob(path, "HEAD:.lfsconfig")
.unwrap()
.unwrap();
assert_eq!(
entries.get("lfs.url").and_then(|v| v.last().cloned()),
Some("http://from-head/".to_owned())
);
}
#[test]
fn read_lfsconfig_blob_missing_returns_none() {
let tmp = init_repo();
assert!(
read_lfsconfig_blob(tmp.path(), ":.lfsconfig")
.unwrap()
.is_none()
);
assert!(
read_lfsconfig_blob(tmp.path(), "HEAD:.lfsconfig")
.unwrap()
.is_none()
);
}
#[test]
fn filter_safe_partitions_keys() {
let mut entries = LfsConfigEntries::new();
entries.insert("lfs.url".into(), vec!["http://x".into()]);
entries.insert("core.askpass".into(), vec!["unsafe".into()]);
entries.insert("lfs.extension.e.priority".into(), vec!["1".into()]);
entries.insert("lfs.extension.e.clean".into(), vec!["bad".into()]);
let (safe, ignored) = filter_safe(entries);
assert!(safe.contains_key("lfs.url"));
assert!(safe.contains_key("lfs.extension.e.priority"));
assert!(!safe.contains_key("core.askpass"));
assert!(!safe.contains_key("lfs.extension.e.clean"));
assert_eq!(ignored, vec!["core.askpass", "lfs.extension.e.clean"]);
}
}