use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ProvisionError {
#[error("git error: {0}")]
Git(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
pub trait GitBackend: Send + Sync {
fn clone_repo(
&self,
repo_url: &str,
git_ref: &str,
target_dir: &Path,
) -> Result<(), ProvisionError>;
fn is_git_repo(&self, dir: &Path) -> bool;
fn remote_url(&self, dir: &Path) -> Result<String, ProvisionError>;
fn fetch_and_reset(&self, dir: &Path, git_ref: &str) -> Result<(), ProvisionError>;
}
#[derive(Debug, Clone, Default)]
pub struct RealGitBackend {
identity: crate::core::git_identity::GitIdentity,
}
impl RealGitBackend {
pub fn new(identity: crate::core::git_identity::GitIdentity) -> Self {
Self { identity }
}
fn command(&self) -> std::process::Command {
let mut cmd = trusty_common::git::command();
cmd.args(self.identity.commit_config_args());
for key in &self.identity.env_remove {
cmd.env_remove(key);
}
cmd.envs(self.identity.env.iter().cloned());
cmd
}
}
impl GitBackend for RealGitBackend {
fn clone_repo(
&self,
repo_url: &str,
git_ref: &str,
target_dir: &Path,
) -> Result<(), ProvisionError> {
let dir_str = target_dir.to_string_lossy().into_owned();
let mut args: Vec<&str> = vec!["clone", "--progress", "--depth", "1"];
if !git_ref.trim().is_empty() {
args.push("--branch");
args.push(git_ref);
}
args.push(repo_url);
args.push(&dir_str);
let cwd = target_dir.parent().unwrap_or(std::path::Path::new("/"));
let mut cmd = self.command();
cmd.args(&args).current_dir(cwd);
let outcome = super::clone_progress::clone_with_progress(cmd)
.map_err(|e| ProvisionError::Git(format!("git clone exec failed: {e}")))?;
if outcome.success {
Ok(())
} else {
Err(ProvisionError::Git(format!(
"git clone failed: {}",
outcome.stderr
)))
}
}
fn is_git_repo(&self, dir: &Path) -> bool {
let dot_git = dir.join(".git");
dot_git.is_dir() || dot_git.is_file()
}
fn remote_url(&self, dir: &Path) -> Result<String, ProvisionError> {
let dir_s = dir.to_string_lossy();
let out = self
.command()
.args(["-C", &dir_s, "remote", "get-url", "origin"])
.output()
.map_err(|e| ProvisionError::Git(format!("git remote get-url exec failed: {e}")))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
} else {
let stderr = String::from_utf8_lossy(&out.stderr);
Err(ProvisionError::Git(format!(
"git remote get-url failed: {stderr}"
)))
}
}
fn fetch_and_reset(&self, dir: &Path, git_ref: &str) -> Result<(), ProvisionError> {
let dir_s = dir.to_string_lossy();
let fetch = self
.command()
.args(["-C", &dir_s, "fetch", "origin", git_ref])
.output()
.map_err(|e| ProvisionError::Git(format!("git fetch exec failed: {e}")))?;
if !fetch.status.success() {
let stderr = String::from_utf8_lossy(&fetch.stderr);
return Err(ProvisionError::Git(format!("git fetch failed: {stderr}")));
}
let reset = self
.command()
.args(["-C", &dir_s, "reset", "--hard", "FETCH_HEAD"])
.output()
.map_err(|e| ProvisionError::Git(format!("git reset exec failed: {e}")))?;
if reset.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&reset.stderr);
Err(ProvisionError::Git(format!("git reset failed: {stderr}")))
}
}
}
pub struct FakeGitBackend {
pub calls: std::sync::Mutex<Vec<(String, String, PathBuf)>>,
strict: bool,
}
impl FakeGitBackend {
pub fn new() -> Self {
Self {
calls: std::sync::Mutex::new(Vec::new()),
strict: false,
}
}
pub fn new_strict() -> Self {
Self {
calls: std::sync::Mutex::new(Vec::new()),
strict: true,
}
}
}
impl Default for FakeGitBackend {
fn default() -> Self {
Self::new()
}
}
impl GitBackend for FakeGitBackend {
fn clone_repo(
&self,
repo_url: &str,
git_ref: &str,
target_dir: &Path,
) -> Result<(), ProvisionError> {
self.calls.lock().unwrap().push((
repo_url.to_owned(),
git_ref.to_owned(),
target_dir.to_owned(),
));
if self.strict && target_dir.exists() {
return Err(ProvisionError::Git(format!(
"git clone failed (exit 128): destination path '{}' already exists and is not an empty directory",
target_dir.display()
)));
}
let git_dir = target_dir.join(".git");
std::fs::create_dir_all(&git_dir)?;
let config = format!(
"[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = {repo_url}\n"
);
std::fs::write(git_dir.join("config"), config)?;
Ok(())
}
fn is_git_repo(&self, dir: &Path) -> bool {
dir.join(".git").is_dir()
}
fn remote_url(&self, dir: &Path) -> Result<String, ProvisionError> {
let config_path = dir.join(".git").join("config");
let content = std::fs::read_to_string(&config_path)
.map_err(|e| ProvisionError::Git(format!("no .git/config: {e}")))?;
let mut in_origin = false;
for line in content.lines() {
let t = line.trim();
if t == "[remote \"origin\"]" {
in_origin = true;
} else if t.starts_with('[') {
in_origin = false;
} else if in_origin && let Some(url) = t.strip_prefix("url = ") {
return Ok(url.to_owned());
}
}
Err(ProvisionError::Git(
"no origin remote in .git/config".to_owned(),
))
}
fn fetch_and_reset(&self, _dir: &Path, _git_ref: &str) -> Result<(), ProvisionError> {
Ok(())
}
}
#[cfg(test)]
mod tests;