use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Mutex, OnceLock};
use crate::Error;
pub type Aliases = HashMap<String, String>;
pub fn load_aliases(cwd: &Path) -> Result<Aliases, Error> {
let canon = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
if let Some(cached) = aliases_cache().lock().unwrap().get(&canon) {
return Ok(cached.clone());
}
let entries = list_aliases_entries(cwd, "insteadof")?;
let aliases = build_aliases(&entries);
aliases_cache()
.lock()
.unwrap()
.insert(canon, aliases.clone());
Ok(aliases)
}
pub fn load_push_aliases(cwd: &Path) -> Result<Aliases, Error> {
let canon = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
if let Some(cached) = push_aliases_cache().lock().unwrap().get(&canon) {
return Ok(cached.clone());
}
let entries = list_aliases_entries(cwd, "pushinsteadof")?;
let aliases = build_aliases(&entries);
push_aliases_cache()
.lock()
.unwrap()
.insert(canon, aliases.clone());
Ok(aliases)
}
pub fn rewrite(cwd: &Path, url: &str) -> Result<String, Error> {
let aliases = load_aliases(cwd)?;
Ok(apply(&aliases, url))
}
pub fn apply(aliases: &Aliases, url: &str) -> String {
let mut best: Option<&str> = None;
for alias in aliases.keys() {
if !url.starts_with(alias.as_str()) {
continue;
}
if best.is_none_or(|b| alias.len() > b.len()) {
best = Some(alias);
}
}
match best {
Some(alias) => format!("{}{}", aliases[alias], &url[alias.len()..]),
None => url.to_owned(),
}
}
struct InsteadOf {
base: String,
alias: String,
}
fn list_aliases_entries(cwd: &Path, suffix: &str) -> Result<Vec<InsteadOf>, Error> {
let regex = format!(r"^url\..*\.{suffix}$");
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.args(["config", "--includes", "--null", "--get-regexp", ®ex])
.output()?;
match out.status.code() {
Some(0) => {}
Some(1) => return Ok(Vec::new()),
_ => {
return Err(Error::Failed(format!(
"git config --get-regexp {suffix} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
}
let dot_suffix = format!(".{suffix}");
let mut entries = Vec::new();
for record in out.stdout.split(|&b| b == 0) {
if record.is_empty() {
continue;
}
let s = std::str::from_utf8(record)
.map_err(|e| Error::Failed(format!("non-utf8 {suffix} entry: {e}")))?;
let (key, value) = match s.split_once('\n') {
Some(kv) => kv,
None => continue,
};
let trimmed = match key.strip_prefix("url.") {
Some(s) => s,
None => continue,
};
let base = match trimmed.strip_suffix(dot_suffix.as_str()) {
Some(s) => s,
None => continue,
};
entries.push(InsteadOf {
base: base.to_owned(),
alias: value.to_owned(),
});
}
Ok(entries)
}
fn build_aliases(entries: &[InsteadOf]) -> Aliases {
let mut map = Aliases::new();
let mut warned: std::collections::HashSet<String> = Default::default();
for entry in entries {
if let Some(existing) = map.get(&entry.alias) {
if existing != &entry.base && warned.insert(entry.alias.clone()) {
eprintln!(
"warning: Multiple 'url.*.insteadof' keys with the same alias: {:?}",
entry.alias
);
}
continue;
}
map.insert(entry.alias.clone(), entry.base.clone());
}
map
}
static ALIASES_CACHE: OnceLock<Mutex<HashMap<PathBuf, Aliases>>> = OnceLock::new();
static PUSH_ALIASES_CACHE: OnceLock<Mutex<HashMap<PathBuf, Aliases>>> = OnceLock::new();
fn aliases_cache() -> &'static Mutex<HashMap<PathBuf, Aliases>> {
ALIASES_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn push_aliases_cache() -> &'static Mutex<HashMap<PathBuf, Aliases>> {
PUSH_ALIASES_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apply_returns_input_when_no_alias_matches() {
let aliases = Aliases::new();
assert_eq!(
apply(&aliases, "https://github.com/foo"),
"https://github.com/foo"
);
}
#[test]
fn apply_rewrites_simple_prefix() {
let mut aliases = Aliases::new();
aliases.insert("alias:".into(), "http://actual-url/".into());
assert_eq!(apply(&aliases, "alias:rest"), "http://actual-url/rest");
}
#[test]
fn apply_picks_longest_match() {
let mut aliases = Aliases::new();
aliases.insert("alias".into(), "http://wrong-url/".into());
aliases.insert("alias:".into(), "http://actual-url/".into());
assert_eq!(apply(&aliases, "alias:rest"), "http://actual-url/rest");
}
#[test]
fn apply_does_not_rewrite_non_prefix() {
let mut aliases = Aliases::new();
aliases.insert("alias:".into(), "http://actual-url/".into());
assert_eq!(apply(&aliases, "badalias:rest"), "badalias:rest");
}
#[test]
fn build_aliases_does_not_warn_on_duplicate_same_value() {
let entries = vec![
InsteadOf {
base: "https://host.example/domain/".into(),
alias: "git@host.example:domain/".into(),
},
InsteadOf {
base: "https://host.example/domain/".into(),
alias: "git@host.example:domain/".into(),
},
];
let map = build_aliases(&entries);
assert_eq!(map.len(), 1);
assert_eq!(
map["git@host.example:domain/"],
"https://host.example/domain/"
);
}
#[test]
fn build_aliases_keeps_first_base_on_conflict() {
let entries = vec![
InsteadOf {
base: "http://actual-url/".into(),
alias: "alias:".into(),
},
InsteadOf {
base: "http://dupe-url".into(),
alias: "alias:".into(),
},
];
let map = build_aliases(&entries);
assert_eq!(map["alias:"], "http://actual-url/");
}
#[test]
fn build_aliases_handles_multiple_distinct_aliases() {
let entries = vec![
InsteadOf {
base: "http://actual-url/".into(),
alias: "alias:".into(),
},
InsteadOf {
base: "http://actual-url/".into(),
alias: "alias2:".into(),
},
];
let map = build_aliases(&entries);
assert_eq!(map["alias:"], "http://actual-url/");
assert_eq!(map["alias2:"], "http://actual-url/");
}
}