use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum VcsError {
#[error("invalid commit hash {0:?} (must be 7-40 hex characters)")]
InvalidCommit(String),
#[error("commit {0:?} not found in repository (run `cljrs deps fetch`)")]
CommitNotFound(String),
#[error("path {0:?} not found at commit {1:?}")]
PathNotFound(String, String),
#[error("git subprocess error: {0}")]
Io(#[from] std::io::Error),
#[error("git output is not valid UTF-8")]
Utf8,
#[error("no git repository found at or above {0:?}")]
NoRepo(PathBuf),
#[error("commit {commit:?} has no valid signature: {reason}")]
SignatureVerificationFailed { commit: String, reason: String },
}
pub type VcsResult<T> = Result<T, VcsError>;
pub fn is_valid_commit_hash(s: &str) -> bool {
(7..=40).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit())
}
pub fn find_repo_root(start: &Path) -> Option<PathBuf> {
let dir: &Path = if start.is_file() {
start.parent()?
} else {
start
};
let output = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.arg("rev-parse")
.arg("--show-toplevel")
.output()
.ok()?;
if output.status.success() {
let s = String::from_utf8(output.stdout).ok()?;
Some(PathBuf::from(s.trim()))
} else {
None
}
}
pub fn get_file_at_commit(repo_root: &Path, rel_path: &str, commit: &str) -> VcsResult<String> {
if !is_valid_commit_hash(commit) {
return Err(VcsError::InvalidCommit(commit.to_string()));
}
let spec = format!("{commit}:{rel_path}");
let output = std::process::Command::new("git")
.arg("-C")
.arg(repo_root)
.arg("show")
.arg(&spec)
.output()?;
if output.status.success() {
String::from_utf8(output.stdout).map_err(|_| VcsError::Utf8)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("does not exist") || stderr.contains("exists on disk") {
Err(VcsError::PathNotFound(
rel_path.to_string(),
commit.to_string(),
))
} else {
Err(VcsError::CommitNotFound(commit.to_string()))
}
}
}
pub fn cache_root() -> PathBuf {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
home.join(".cljrs").join("cache").join("git")
}
pub fn cache_path_for_url(url: &str) -> PathBuf {
let slug: String = url
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.collect();
cache_root().join(slug)
}
pub fn fetch_remote(url: &str, sha: &str) -> VcsResult<PathBuf> {
if !is_valid_commit_hash(sha) {
return Err(VcsError::InvalidCommit(sha.to_string()));
}
let slug: String = url
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.collect();
let repo_dir = cache_root().join(&slug);
if repo_dir.exists() {
let status = std::process::Command::new("git")
.arg("-C")
.arg(&repo_dir)
.arg("fetch")
.arg("--quiet")
.arg("origin")
.status()?;
if !status.success() {
return Err(VcsError::Io(std::io::Error::other("git fetch failed")));
}
} else {
std::fs::create_dir_all(&repo_dir).map_err(VcsError::Io)?;
let status = std::process::Command::new("git")
.arg("clone")
.arg("--bare")
.arg("--quiet")
.arg(url)
.arg(&repo_dir)
.status()?;
if !status.success() {
return Err(VcsError::Io(std::io::Error::other("git clone failed")));
}
}
let check = std::process::Command::new("git")
.arg("-C")
.arg(&repo_dir)
.arg("cat-file")
.arg("-e")
.arg(sha)
.status()?;
if !check.success() {
return Err(VcsError::CommitNotFound(sha.to_string()));
}
Ok(repo_dir)
}
pub fn verify_commit_signature(repo_root: &Path, commit: &str) -> VcsResult<()> {
if !is_valid_commit_hash(commit) {
return Err(VcsError::InvalidCommit(commit.to_string()));
}
let output = std::process::Command::new("git")
.arg("-C")
.arg(repo_root)
.arg("verify-commit")
.arg(commit)
.output()?;
if output.status.success() {
Ok(())
} else {
let reason = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(VcsError::SignatureVerificationFailed {
commit: commit.to_string(),
reason,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_hashes() {
assert!(is_valid_commit_hash("abc1234"));
assert!(is_valid_commit_hash(
"abc1234ef5678901234567890123456789012345"
));
assert!(!is_valid_commit_hash("abc123")); assert!(!is_valid_commit_hash("xyz1234")); assert!(!is_valid_commit_hash(""));
}
}