1use crate::core::service::ServiceError;
4use crate::core::sources::SourceAuth;
5use std::path::{Path, PathBuf};
6use std::sync::OnceLock;
7use std::time::Duration;
8use tempfile::TempDir;
9use tokio::process::Command;
10use tokio::time::timeout;
11use tracing::{debug, info, warn};
12
13#[derive(Debug, thiserror::Error)]
15pub enum GitError {
16 #[error("Git binary not found. Please install git: https://git-scm.com/downloads")]
17 GitNotInstalled,
18
19 #[error("Git version {version} is too old. FastSkill requires git {required} or higher. Please upgrade: https://git-scm.com/downloads")]
20 GitVersionTooOld { version: String, required: String },
21
22 #[error("Failed to clone repository {url}: {stderr}")]
23 CloneFailed { url: String, stderr: String },
24
25 #[error("Failed to checkout {ref_name}: {stderr}")]
26 CheckoutFailed { ref_name: String, stderr: String },
27
28 #[error("Git operation '{operation}' timed out after {timeout_secs} seconds")]
29 Timeout {
30 operation: String,
31 timeout_secs: u64,
32 },
33
34 #[error("Network error for {url} (attempt {attempt}/{max_attempts})")]
35 NetworkError {
36 url: String,
37 attempt: u32,
38 max_attempts: u32,
39 },
40
41 #[error("Invalid git URL '{url}': {reason}")]
42 InvalidUrl { url: String, reason: String },
43
44 #[error("Authentication failed for {url}: {stderr}")]
45 AuthenticationFailed { url: String, stderr: String },
46}
47
48impl From<GitError> for ServiceError {
49 fn from(err: GitError) -> Self {
50 ServiceError::Custom(err.to_string())
51 }
52}
53
54#[derive(Debug, Clone)]
56pub(crate) struct GitVersion {
57 major: u32,
58 minor: u32,
59 patch: u32,
60}
61
62impl GitVersion {
63 fn new(major: u32, minor: u32, patch: u32) -> Self {
64 Self {
65 major,
66 minor,
67 patch,
68 }
69 }
70
71 fn is_supported(&self) -> bool {
72 self.major >= 2
73 }
74}
75
76impl std::fmt::Display for GitVersion {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
79 }
80}
81
82static GIT_VERSION: OnceLock<Result<GitVersion, ServiceError>> = OnceLock::new();
84
85pub(crate) async fn check_git_version() -> Result<(), ServiceError> {
87 if let Some(result) = GIT_VERSION.get() {
89 return result.as_ref().map(|_| ()).map_err(|_| {
90 GitError::GitVersionTooOld {
91 version: "unknown".to_string(),
92 required: "2.0".to_string(),
93 }
94 .into()
95 });
96 }
97
98 let output = Command::new("git")
100 .arg("--version")
101 .output()
102 .await
103 .map_err(|e| {
104 if e.kind() == std::io::ErrorKind::NotFound {
105 GitError::GitNotInstalled.into()
106 } else {
107 ServiceError::Custom(format!("Failed to execute git --version: {}", e))
108 }
109 })?;
110
111 if !output.status.success() {
112 let stderr = String::from_utf8_lossy(&output.stderr);
113 return Err(ServiceError::Custom(format!(
114 "git --version failed: {}",
115 stderr
116 )));
117 }
118
119 let stdout = String::from_utf8_lossy(&output.stdout);
121 let version = parse_git_version(&stdout)?;
122
123 if !version.is_supported() {
125 return Err(GitError::GitVersionTooOld {
126 version: format!("{}", version),
127 required: "2.0".to_string(),
128 }
129 .into());
130 }
131
132 GIT_VERSION.set(Ok(version)).ok();
134
135 Ok(())
136}
137
138pub(crate) fn parse_git_version(version_str: &str) -> Result<GitVersion, ServiceError> {
140 let parts: Vec<&str> = version_str.split_whitespace().collect();
142 if parts.len() < 3 || parts[0] != "git" || parts[1] != "version" {
143 return Err(ServiceError::Custom(format!(
144 "Unexpected git version format: {}",
145 version_str
146 )));
147 }
148
149 let version_part = parts[2];
150 let version_part = version_part
152 .split('(')
153 .next()
154 .unwrap_or(version_part)
155 .trim();
156 let version_numbers: Vec<&str> = version_part.split('.').collect();
157
158 if version_numbers.len() < 2 {
159 return Err(ServiceError::Custom(format!(
160 "Invalid git version format: {}",
161 version_part
162 )));
163 }
164
165 let major = version_numbers[0]
166 .parse::<u32>()
167 .map_err(|e| ServiceError::Custom(format!("Failed to parse git major version: {}", e)))?;
168 let minor = version_numbers[1]
169 .parse::<u32>()
170 .map_err(|e| ServiceError::Custom(format!("Failed to parse git minor version: {}", e)))?;
171 let patch = version_numbers
172 .get(2)
173 .and_then(|s| s.parse::<u32>().ok())
174 .unwrap_or(0);
175
176 Ok(GitVersion::new(major, minor, patch))
177}
178
179#[allow(dead_code)] pub(crate) struct CommandOutput {
182 stdout: String,
183 stderr: String,
184 exit_code: i32,
185}
186
187pub(crate) async fn execute_git_command(
189 args: &[&str],
190 timeout_duration: Duration,
191 cwd: Option<&Path>,
192) -> Result<CommandOutput, ServiceError> {
193 let mut cmd = Command::new("git");
194 cmd.args(args);
195 if let Some(cwd) = cwd {
196 cmd.current_dir(cwd);
197 }
198
199 let args_str = args.join(" ");
201 let output = timeout(timeout_duration, cmd.output())
202 .await
203 .map_err(|_| -> ServiceError {
204 GitError::Timeout {
205 operation: args_str.clone(),
206 timeout_secs: timeout_duration.as_secs(),
207 }
208 .into()
209 })?
210 .map_err(|e| {
211 if e.kind() == std::io::ErrorKind::NotFound {
212 GitError::GitNotInstalled.into()
213 } else {
214 ServiceError::Custom(format!("Failed to execute git command: {}", e))
215 }
216 })?;
217
218 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
219 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
220 let exit_code = output.status.code().unwrap_or(-1);
221
222 Ok(CommandOutput {
223 stdout,
224 stderr,
225 exit_code,
226 })
227}
228
229pub(crate) fn is_network_error(stderr: &str) -> bool {
231 let lower_stderr = stderr.to_lowercase();
232 lower_stderr.contains("network")
233 || lower_stderr.contains("connection")
234 || lower_stderr.contains("timeout")
235 || lower_stderr.contains("unable to access")
236 || lower_stderr.contains("failed to connect")
237 || lower_stderr.contains("connection refused")
238 || lower_stderr.contains("name resolution")
239}
240
241pub(crate) async fn execute_git_command_with_retry(
243 args: &[&str],
244 timeout_duration: Duration,
245 cwd: Option<&Path>,
246 max_attempts: u32,
247) -> Result<CommandOutput, ServiceError> {
248 let mut attempt = 1;
249 let mut delay = Duration::from_secs(1); loop {
252 match execute_git_command(args, timeout_duration, cwd).await {
253 Ok(output) => {
254 if output.exit_code == 0 {
255 return Ok(output);
256 }
257
258 if attempt < max_attempts && is_network_error(&output.stderr) {
260 warn!(
261 "Git operation failed with network error (attempt {}/{}): {}",
262 attempt, max_attempts, output.stderr
263 );
264 info!("Retrying in {:?}...", delay);
265 tokio::time::sleep(delay).await;
266 delay *= 2; attempt += 1;
268 continue;
269 }
270
271 return Err(ServiceError::Custom(format!(
273 "Git command failed: {}",
274 output.stderr
275 )));
276 }
277 Err(e) => {
278 let error_msg = e.to_string();
280 if attempt < max_attempts
281 && (error_msg.contains("timeout") || error_msg.contains("network"))
282 {
283 warn!(
284 "Git operation failed (attempt {}/{}): {}",
285 attempt, max_attempts, error_msg
286 );
287 info!("Retrying in {:?}...", delay);
288 tokio::time::sleep(delay).await;
289 delay *= 2;
290 attempt += 1;
291 continue;
292 }
293
294 return Err(e);
295 }
296 }
297 }
298}
299
300pub async fn clone_repository(
343 url: &str,
344 branch: Option<&str>,
345 tag: Option<&str>,
346 auth: Option<&SourceAuth>,
347) -> Result<TempDir, ServiceError> {
348 if auth.is_some() {
350 warn!(
351 "SourceAuth parameter is deprecated. System git handles authentication automatically. \
352 Please configure git credentials (SSH keys or credential helper) instead."
353 );
354 }
355
356 check_git_version().await?;
358
359 let temp_dir = TempDir::new().map_err(|e| {
361 ServiceError::Custom(format!("Failed to create temporary directory: {}", e))
362 })?;
363
364 info!("Cloning repository: {}", url);
365
366 let mut clone_args = vec!["clone", "--depth=1", "--quiet"];
368
369 if let Some(branch) = branch {
371 clone_args.extend(&["--branch", branch]);
372 } else if let Some(tag) = tag {
373 clone_args.extend(&["--branch", tag]);
374 }
375
376 clone_args.push("--single-branch");
378 clone_args.push("--no-tags");
379
380 clone_args.push(url);
382 clone_args.push(temp_dir.path().to_str().ok_or_else(|| {
383 ServiceError::Custom("Failed to convert temp directory path to string".to_string())
384 })?);
385
386 let clone_timeout = Duration::from_secs(300); let output = execute_git_command_with_retry(&clone_args, clone_timeout, None, 3).await?;
389
390 if output.exit_code != 0 {
391 drop(temp_dir);
393 return Err(GitError::CloneFailed {
394 url: url.to_string(),
395 stderr: output.stderr,
396 }
397 .into());
398 }
399
400 if let Some(ref_name) = branch.or(tag) {
402 checkout_branch_or_tag(temp_dir.path(), ref_name, branch.is_some()).await?;
403 debug!(
404 "Checked out {}: {}",
405 if branch.is_some() { "branch" } else { "tag" },
406 ref_name
407 );
408 }
409
410 Ok(temp_dir)
411}
412
413pub async fn checkout_branch_or_tag(
439 repo_path: &Path,
440 ref_name: &str,
441 _is_branch: bool,
442) -> Result<(), ServiceError> {
443 let args = vec!["checkout", ref_name];
445
446 let checkout_timeout = Duration::from_secs(60); let output = execute_git_command(&args, checkout_timeout, Some(repo_path)).await?;
449
450 if output.exit_code != 0 {
451 return Err(GitError::CheckoutFailed {
452 ref_name: ref_name.to_string(),
453 stderr: output.stderr,
454 }
455 .into());
456 }
457
458 Ok(())
462}
463
464pub fn validate_cloned_skill(cloned_path: &Path) -> Result<PathBuf, ServiceError> {
494 let skill_file = cloned_path.join("SKILL.md");
496 if skill_file.exists() {
497 return Ok(cloned_path.to_path_buf());
498 }
499
500 let entries = std::fs::read_dir(cloned_path)
502 .map_err(|e| ServiceError::Custom(format!("Failed to read cloned directory: {}", e)))?;
503
504 for entry in entries {
505 let entry = entry
506 .map_err(|e| ServiceError::Custom(format!("Failed to read directory entry: {}", e)))?;
507 let path = entry.path();
508 if path.is_dir() {
509 let skill_file = path.join("SKILL.md");
510 if skill_file.exists() {
511 return Ok(path);
512 }
513 }
514 }
515
516 Err(ServiceError::Validation(
517 "Cloned repository does not contain a valid skill structure (SKILL.md not found)"
518 .to_string(),
519 ))
520}