use std::path::Path;
pub trait PathDepResolver: Send + Sync {
fn resolve(&self, abs_dir: &Path) -> Option<(String, String)>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct GitCliResolver;
impl PathDepResolver for GitCliResolver {
fn resolve(&self, abs_dir: &Path) -> Option<(String, String)> {
let raw_url = git_remote_url(abs_dir)?;
let url = normalize_to_https(&raw_url)?;
let rev = git_head_rev(abs_dir)?;
Some((url, rev))
}
}
fn git_remote_url(dir: &Path) -> Option<String> {
let out = std::process::Command::new("git")
.args(["-C", dir.to_str()?, "config", "--get", "remote.origin.url"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?;
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn git_head_rev(dir: &Path) -> Option<String> {
let out = std::process::Command::new("git")
.args(["-C", dir.to_str()?, "rev-parse", "HEAD"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?;
let trimmed = s.trim();
if trimmed.len() == 40 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
Some(trimmed.to_string())
} else {
None
}
}
pub fn normalize_to_https(raw: &str) -> Option<String> {
let r = raw.trim();
if let Some(rest) = r.strip_prefix("git@github.com:") {
return Some(format!("https://github.com/{}", strip_dot_git(rest)));
}
if let Some(rest) = r.strip_prefix("ssh://git@github.com/") {
return Some(format!("https://github.com/{}", strip_dot_git(rest)));
}
if let Some(rest) = r.strip_prefix("https://github.com/") {
return Some(format!("https://github.com/{}", strip_dot_git(rest)));
}
None
}
fn strip_dot_git(s: &str) -> &str {
s.trim_end_matches(".git").trim_end_matches('/')
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
pub struct MockResolver {
pub mappings: std::collections::HashMap<std::path::PathBuf, (String, String)>,
}
impl PathDepResolver for MockResolver {
fn resolve(&self, abs_dir: &Path) -> Option<(String, String)> {
self.mappings.get(abs_dir).cloned()
}
}
#[test]
fn normalize_to_https_recognizes_ssh_short_form() {
assert_eq!(
normalize_to_https("git@github.com:pleme-io/gen.git").as_deref(),
Some("https://github.com/pleme-io/gen"),
);
}
#[test]
fn normalize_to_https_recognizes_ssh_full_form() {
assert_eq!(
normalize_to_https("ssh://git@github.com/pleme-io/tatara/").as_deref(),
Some("https://github.com/pleme-io/tatara"),
);
}
#[test]
fn normalize_to_https_passthrough_for_canonical() {
assert_eq!(
normalize_to_https("https://github.com/pleme-io/shikumi").as_deref(),
Some("https://github.com/pleme-io/shikumi"),
);
}
#[test]
fn normalize_to_https_strips_dot_git() {
assert_eq!(
normalize_to_https("https://github.com/pleme-io/sui.git").as_deref(),
Some("https://github.com/pleme-io/sui"),
);
}
#[test]
fn normalize_to_https_rejects_non_github() {
assert_eq!(normalize_to_https("https://gitlab.com/x/y"), None);
assert_eq!(normalize_to_https("git@bitbucket.org:x/y"), None);
}
#[test]
fn mock_resolver_returns_mapped_value() {
let mut m = MockResolver::default();
m.mappings.insert(
std::path::PathBuf::from("/code/gen"),
("https://github.com/pleme-io/gen".into(), "abc123".into()),
);
assert_eq!(
m.resolve(Path::new("/code/gen")),
Some(("https://github.com/pleme-io/gen".into(), "abc123".into())),
);
assert_eq!(m.resolve(Path::new("/unknown")), None);
}
}