use crate::core::service::ServiceError;
use crate::core::sources::SourceAuth;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::Duration;
use tempfile::TempDir;
use tokio::process::Command;
use tokio::time::timeout;
use tracing::{debug, info, warn};
#[derive(Debug, thiserror::Error)]
pub enum GitError {
#[error("Git binary not found. Please install git: https://git-scm.com/downloads")]
GitNotInstalled,
#[error("Git version {version} is too old. FastSkill requires git {required} or higher. Please upgrade: https://git-scm.com/downloads")]
GitVersionTooOld { version: String, required: String },
#[error("Failed to clone repository {url}: {stderr}")]
CloneFailed { url: String, stderr: String },
#[error("Failed to checkout {ref_name}: {stderr}")]
CheckoutFailed { ref_name: String, stderr: String },
#[error("Git operation '{operation}' timed out after {timeout_secs} seconds")]
Timeout {
operation: String,
timeout_secs: u64,
},
#[error("Network error for {url} (attempt {attempt}/{max_attempts})")]
NetworkError {
url: String,
attempt: u32,
max_attempts: u32,
},
#[error("Invalid git URL '{url}': {reason}")]
InvalidUrl { url: String, reason: String },
#[error("Authentication failed for {url}: {stderr}")]
AuthenticationFailed { url: String, stderr: String },
}
impl From<GitError> for ServiceError {
fn from(err: GitError) -> Self {
ServiceError::Custom(err.to_string())
}
}
#[derive(Debug, Clone)]
pub(crate) struct GitVersion {
major: u32,
minor: u32,
patch: u32,
}
impl GitVersion {
fn new(major: u32, minor: u32, patch: u32) -> Self {
Self {
major,
minor,
patch,
}
}
fn is_supported(&self) -> bool {
self.major >= 2
}
}
impl std::fmt::Display for GitVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
static GIT_VERSION: OnceLock<Result<GitVersion, ServiceError>> = OnceLock::new();
pub(crate) async fn check_git_version() -> Result<(), ServiceError> {
if let Some(result) = GIT_VERSION.get() {
return result.as_ref().map(|_| ()).map_err(|_| {
GitError::GitVersionTooOld {
version: "unknown".to_string(),
required: "2.0".to_string(),
}
.into()
});
}
let output = Command::new("git")
.arg("--version")
.output()
.await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
GitError::GitNotInstalled.into()
} else {
ServiceError::Custom(format!("Failed to execute git --version: {}", e))
}
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ServiceError::Custom(format!(
"git --version failed: {}",
stderr
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let version = parse_git_version(&stdout)?;
if !version.is_supported() {
return Err(GitError::GitVersionTooOld {
version: format!("{}", version),
required: "2.0".to_string(),
}
.into());
}
GIT_VERSION.set(Ok(version)).ok();
Ok(())
}
pub(crate) fn parse_git_version(version_str: &str) -> Result<GitVersion, ServiceError> {
let parts: Vec<&str> = version_str.split_whitespace().collect();
if parts.len() < 3 || parts[0] != "git" || parts[1] != "version" {
return Err(ServiceError::Custom(format!(
"Unexpected git version format: {}",
version_str
)));
}
let version_part = parts[2];
let version_part = version_part
.split('(')
.next()
.unwrap_or(version_part)
.trim();
let version_numbers: Vec<&str> = version_part.split('.').collect();
if version_numbers.len() < 2 {
return Err(ServiceError::Custom(format!(
"Invalid git version format: {}",
version_part
)));
}
let major = version_numbers[0]
.parse::<u32>()
.map_err(|e| ServiceError::Custom(format!("Failed to parse git major version: {}", e)))?;
let minor = version_numbers[1]
.parse::<u32>()
.map_err(|e| ServiceError::Custom(format!("Failed to parse git minor version: {}", e)))?;
let patch = version_numbers
.get(2)
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(0);
Ok(GitVersion::new(major, minor, patch))
}
#[allow(dead_code)] pub(crate) struct CommandOutput {
stdout: String,
stderr: String,
exit_code: i32,
}
pub(crate) async fn execute_git_command(
args: &[&str],
timeout_duration: Duration,
cwd: Option<&Path>,
) -> Result<CommandOutput, ServiceError> {
let mut cmd = Command::new("git");
cmd.args(args);
if let Some(cwd) = cwd {
cmd.current_dir(cwd);
}
let args_str = args.join(" ");
let output = timeout(timeout_duration, cmd.output())
.await
.map_err(|_| -> ServiceError {
GitError::Timeout {
operation: args_str.clone(),
timeout_secs: timeout_duration.as_secs(),
}
.into()
})?
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
GitError::GitNotInstalled.into()
} else {
ServiceError::Custom(format!("Failed to execute git command: {}", e))
}
})?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let exit_code = output.status.code().unwrap_or(-1);
Ok(CommandOutput {
stdout,
stderr,
exit_code,
})
}
pub(crate) fn is_network_error(stderr: &str) -> bool {
let lower_stderr = stderr.to_lowercase();
lower_stderr.contains("network")
|| lower_stderr.contains("connection")
|| lower_stderr.contains("timeout")
|| lower_stderr.contains("unable to access")
|| lower_stderr.contains("failed to connect")
|| lower_stderr.contains("connection refused")
|| lower_stderr.contains("name resolution")
}
pub(crate) async fn execute_git_command_with_retry(
args: &[&str],
timeout_duration: Duration,
cwd: Option<&Path>,
max_attempts: u32,
) -> Result<CommandOutput, ServiceError> {
let mut attempt = 1;
let mut delay = Duration::from_secs(1);
loop {
match execute_git_command(args, timeout_duration, cwd).await {
Ok(output) => {
if output.exit_code == 0 {
return Ok(output);
}
if attempt < max_attempts && is_network_error(&output.stderr) {
warn!(
"Git operation failed with network error (attempt {}/{}): {}",
attempt, max_attempts, output.stderr
);
info!("Retrying in {:?}...", delay);
tokio::time::sleep(delay).await;
delay *= 2; attempt += 1;
continue;
}
return Err(ServiceError::Custom(format!(
"Git command failed: {}",
output.stderr
)));
}
Err(e) => {
let error_msg = e.to_string();
if attempt < max_attempts
&& (error_msg.contains("timeout") || error_msg.contains("network"))
{
warn!(
"Git operation failed (attempt {}/{}): {}",
attempt, max_attempts, error_msg
);
info!("Retrying in {:?}...", delay);
tokio::time::sleep(delay).await;
delay *= 2;
attempt += 1;
continue;
}
return Err(e);
}
}
}
}
pub async fn clone_repository(
url: &str,
branch: Option<&str>,
tag: Option<&str>,
auth: Option<&SourceAuth>,
) -> Result<TempDir, ServiceError> {
if auth.is_some() {
warn!(
"SourceAuth parameter is deprecated. System git handles authentication automatically. \
Please configure git credentials (SSH keys or credential helper) instead."
);
}
check_git_version().await?;
let temp_dir = TempDir::new().map_err(|e| {
ServiceError::Custom(format!("Failed to create temporary directory: {}", e))
})?;
info!("Cloning repository: {}", url);
let mut clone_args = vec!["clone", "--depth=1", "--quiet"];
if let Some(branch) = branch {
clone_args.extend(&["--branch", branch]);
} else if let Some(tag) = tag {
clone_args.extend(&["--branch", tag]);
}
clone_args.push("--single-branch");
clone_args.push("--no-tags");
clone_args.push(url);
clone_args.push(temp_dir.path().to_str().ok_or_else(|| {
ServiceError::Custom("Failed to convert temp directory path to string".to_string())
})?);
let clone_timeout = Duration::from_secs(300); let output = execute_git_command_with_retry(&clone_args, clone_timeout, None, 3).await?;
if output.exit_code != 0 {
drop(temp_dir);
return Err(GitError::CloneFailed {
url: url.to_string(),
stderr: output.stderr,
}
.into());
}
if let Some(ref_name) = branch.or(tag) {
checkout_branch_or_tag(temp_dir.path(), ref_name, branch.is_some()).await?;
debug!(
"Checked out {}: {}",
if branch.is_some() { "branch" } else { "tag" },
ref_name
);
}
Ok(temp_dir)
}
pub async fn checkout_branch_or_tag(
repo_path: &Path,
ref_name: &str,
_is_branch: bool,
) -> Result<(), ServiceError> {
let args = vec!["checkout", ref_name];
let checkout_timeout = Duration::from_secs(60); let output = execute_git_command(&args, checkout_timeout, Some(repo_path)).await?;
if output.exit_code != 0 {
return Err(GitError::CheckoutFailed {
ref_name: ref_name.to_string(),
stderr: output.stderr,
}
.into());
}
Ok(())
}
pub fn validate_cloned_skill(cloned_path: &Path) -> Result<PathBuf, ServiceError> {
let skill_file = cloned_path.join("SKILL.md");
if skill_file.exists() {
return Ok(cloned_path.to_path_buf());
}
let entries = std::fs::read_dir(cloned_path)
.map_err(|e| ServiceError::Custom(format!("Failed to read cloned directory: {}", e)))?;
for entry in entries {
let entry = entry
.map_err(|e| ServiceError::Custom(format!("Failed to read directory entry: {}", e)))?;
let path = entry.path();
if path.is_dir() {
let skill_file = path.join("SKILL.md");
if skill_file.exists() {
return Ok(path);
}
}
}
Err(ServiceError::Validation(
"Cloned repository does not contain a valid skill structure (SKILL.md not found)"
.to_string(),
))
}