use std::path::Path;
#[derive(Debug, thiserror::Error)]
pub enum GitError {
#[error("failed to run git: {0}")]
Exec(#[from] std::io::Error),
#[error("{0}")]
Command(String),
#[error("rejected git URL: {0}")]
InvalidUrl(String),
}
fn validate_remote_url(url: &str) -> Result<(), GitError> {
let normalized = substrate::normalize_and_validate_url(url)
.map_err(|e| GitError::InvalidUrl(e.to_string()))?;
let parsed = reqwest::Url::parse(&normalized)
.map_err(|e| GitError::InvalidUrl(format!("invalid URL syntax ({})", e)))?;
if parsed.scheme() != "https" {
return Err(GitError::InvalidUrl(format!(
"only https:// URLs are allowed (got: {})",
url
)));
}
Ok(())
}
fn run_git(args: &[&str]) -> Result<(), GitError> {
let output = std::process::Command::new("git").args(args).output()?;
if !output.status.success() {
return Err(GitError::Command(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
Ok(())
}
fn run_git_in(dir: &Path, args: &[&str]) -> Result<(), GitError> {
let output = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.output()?;
if !output.status.success() {
return Err(GitError::Command(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
Ok(())
}
fn run_git_output(dir: &Path, args: &[&str]) -> Result<String, GitError> {
let output = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.output()?;
if !output.status.success() {
return Err(GitError::Command(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn is_available() -> bool {
std::process::Command::new("git")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn clone_repo(url: &str, dest: &Path, shallow: bool) -> Result<(), GitError> {
validate_remote_url(url)?;
let dest_str = dest.display().to_string();
if shallow {
run_git(&["clone", "--depth", "1", url, &dest_str])
} else {
run_git(&["clone", url, &dest_str])
}
}
pub fn clone_bare_repo(url: &str, dest: &Path) -> Result<(), GitError> {
validate_remote_url(url)?;
let dest_str = dest.display().to_string();
run_git(&["clone", "--bare", url, &dest_str])
}
pub fn clone_from_local(local_bare_path: &Path, dest: &Path) -> Result<(), GitError> {
let src = format!("file://{}", local_bare_path.display());
let dest_str = dest.display().to_string();
run_git(&["clone", &src, &dest_str])
}
pub fn checkout_ref(repo_path: &Path, ref_spec: &str) -> Result<(), GitError> {
run_git_in(repo_path, &["checkout", ref_spec])
}
pub fn init_repo(path: &Path) -> Result<(), GitError> {
run_git_in(path, &["init"])
}
pub fn add_all_and_commit(repo_path: &Path, message: &str) -> Result<String, GitError> {
run_git_in(repo_path, &["add", "."])?;
run_git_in(
repo_path,
&[
"-c",
"user.name=CMN Hypha",
"-c",
"user.email=hypha@cmn.dev",
"commit",
"-m",
message,
],
)?;
run_git_output(repo_path, &["rev-parse", "HEAD"])
}
pub fn get_head_commit(repo_path: &Path) -> Result<String, GitError> {
run_git_output(repo_path, &["rev-parse", "HEAD"])
}
pub fn commit_exists(repo_path: &Path, commit_sha: &str) -> Result<bool, GitError> {
let output = std::process::Command::new("git")
.args(["cat-file", "-t", commit_sha])
.current_dir(repo_path)
.output()?;
Ok(output.status.success())
}
pub fn fetch_to_bare(bare_repo_path: &Path, remote_url: &str) -> Result<(), GitError> {
validate_remote_url(remote_url)?;
run_git_in(
bare_repo_path,
&["fetch", remote_url, "+refs/heads/*:refs/heads/*", "--force"],
)
}
pub fn fetch_from_remote(repo_path: &Path, remote_name: &str) -> Result<(), GitError> {
run_git_in(repo_path, &["fetch", remote_name])
}
pub fn add_remote(repo_path: &Path, remote_name: &str, remote_url: &str) -> Result<(), GitError> {
run_git_in(repo_path, &["remote", "add", remote_name, remote_url])
}
pub fn set_remote_url(repo_path: &Path, remote_name: &str, new_url: &str) -> Result<(), GitError> {
run_git_in(repo_path, &["remote", "set-url", remote_name, new_url])
}
pub fn is_working_dir_clean(repo_path: &Path) -> Result<bool, GitError> {
let output = run_git_output(repo_path, &["status", "--porcelain"])?;
Ok(output.is_empty())
}
pub fn get_root_commit_bare(bare_repo_path: &Path) -> Result<String, GitError> {
run_git_output(bare_repo_path, &["rev-list", "--max-parents=0", "HEAD"])
}
pub fn get_root_commit(repo_path: &Path) -> Result<String, GitError> {
run_git_output(repo_path, &["rev-list", "--max-parents=0", "HEAD"])
}
pub fn get_remote_url(repo_path: &Path, remote: &str) -> Result<Option<String>, GitError> {
match run_git_output(repo_path, &["remote", "get-url", remote]) {
Ok(url) if url.is_empty() => Ok(None),
Ok(url) => Ok(Some(url)),
Err(_) => Ok(None),
}
}