Skip to main content

fastskill_core/storage/
git.rs

1//! Git operations for cloning skill repositories using system git binary
2
3use 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/// Git operation error types
14#[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/// Git version information (cached after first check)
55#[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
82/// Cached git version (checked once per process)
83static GIT_VERSION: OnceLock<Result<GitVersion, ServiceError>> = OnceLock::new();
84
85/// Check git version and cache result
86pub(crate) async fn check_git_version() -> Result<(), ServiceError> {
87    // Check if already cached
88    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    // Execute git --version
99    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    // Parse version string
120    let stdout = String::from_utf8_lossy(&output.stdout);
121    let version = parse_git_version(&stdout)?;
122
123    // Validate version >= 2.0
124    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    // Cache the result
133    GIT_VERSION.set(Ok(version)).ok();
134
135    Ok(())
136}
137
138/// Parse git version from output string (e.g., "git version 2.34.1")
139pub(crate) fn parse_git_version(version_str: &str) -> Result<GitVersion, ServiceError> {
140    // Expected format: "git version X.Y.Z" or "git version X.Y.Z (extra info)"
141    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    // Remove any trailing parentheses or extra info
151    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/// Command output structure
180#[allow(dead_code)] // stdout may be used for future progress parsing
181pub(crate) struct CommandOutput {
182    stdout: String,
183    stderr: String,
184    exit_code: i32,
185}
186
187/// Execute git command with timeout
188pub(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    // Execute with timeout
200    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
229/// Check if error is a network error (retryable)
230pub(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
241/// Execute git command with retry logic for network errors
242pub(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); // Start with 1 second
250
251    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                // Check if it's a network error and we should retry
259                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; // Exponential backoff: 1s, 2s, 4s
267                    attempt += 1;
268                    continue;
269                }
270
271                // Not a network error or max attempts reached
272                return Err(ServiceError::Custom(format!(
273                    "Git command failed: {}",
274                    output.stderr
275                )));
276            }
277            Err(e) => {
278                // Check if it's a timeout or network-related error
279                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
300/// Clone a git repository to a temporary directory.
301///
302/// This function uses the system git binary to clone a repository. It performs shallow clones
303/// (depth=1) for optimal performance and supports branch/tag checkout.
304///
305/// # Arguments
306///
307/// * `url` - Git repository URL (HTTPS, SSH, or GitHub tree URL)
308/// * `branch` - Optional branch name to checkout after clone
309/// * `tag` - Optional tag name to checkout after clone (mutually exclusive with `branch`)
310/// * `auth` - **Deprecated**: Ignored, system git handles authentication automatically
311///
312/// # Returns
313///
314/// Returns a `TempDir` containing the cloned repository. The directory is automatically
315/// cleaned up when the `TempDir` is dropped.
316///
317/// # Errors
318///
319/// Returns `ServiceError` if:
320/// - Git is not installed or not in PATH
321/// - Git version is too old (< 2.0)
322/// - Clone operation fails (network error, invalid URL, authentication failure)
323/// - Checkout operation fails
324/// - Operation times out (5 minute timeout for clone)
325///
326/// # Examples
327///
328/// ```no_run
329/// use fastskill::storage::git::clone_repository;
330///
331/// # async fn example() -> Result<(), fastskill::core::service::ServiceError> {
332/// let temp_dir = clone_repository(
333///     "https://github.com/example/repo.git",
334///     Some("main"),
335///     None,
336///     None,
337/// ).await?;
338/// // Use temp_dir.path() to access cloned repository
339/// # Ok(())
340/// # }
341/// ```
342pub async fn clone_repository(
343    url: &str,
344    branch: Option<&str>,
345    tag: Option<&str>,
346    auth: Option<&SourceAuth>,
347) -> Result<TempDir, ServiceError> {
348    // Log deprecation warning if auth is provided
349    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 first
357    check_git_version().await?;
358
359    // Create temporary directory
360    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    // Build clone command arguments
367    let mut clone_args = vec!["clone", "--depth=1", "--quiet"];
368
369    // Add branch or tag if specified
370    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    // Add single-branch and no-tags for optimization
377    clone_args.push("--single-branch");
378    clone_args.push("--no-tags");
379
380    // Add URL and destination
381    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    // Execute clone with retry (5 minute timeout, max 3 attempts)
387    let clone_timeout = Duration::from_secs(300); // 5 minutes
388    let output = execute_git_command_with_retry(&clone_args, clone_timeout, None, 3).await?;
389
390    if output.exit_code != 0 {
391        // Clean up on failure
392        drop(temp_dir);
393        return Err(GitError::CloneFailed {
394            url: url.to_string(),
395            stderr: output.stderr,
396        }
397        .into());
398    }
399
400    // Checkout branch or tag if specified (already handled by --branch flag, but verify)
401    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
413/// Checkout a specific branch or tag in a git repository.
414///
415/// # Arguments
416///
417/// * `repo_path` - Path to the git repository
418/// * `ref_name` - Branch or tag name to checkout
419/// * `_is_branch` - Whether the reference is a branch (kept for API compatibility)
420///
421/// # Errors
422///
423/// Returns `ServiceError` if:
424/// - Checkout operation fails (reference not found, conflicts, etc.)
425/// - Operation times out (1 minute timeout)
426///
427/// # Examples
428///
429/// ```no_run
430/// use fastskill::storage::git::checkout_branch_or_tag;
431/// use std::path::Path;
432///
433/// # async fn example() -> Result<(), fastskill::core::service::ServiceError> {
434/// checkout_branch_or_tag(Path::new("/path/to/repo"), "main", true).await?;
435/// # Ok(())
436/// # }
437/// ```
438pub async fn checkout_branch_or_tag(
439    repo_path: &Path,
440    ref_name: &str,
441    _is_branch: bool,
442) -> Result<(), ServiceError> {
443    // Build checkout command
444    let args = vec!["checkout", ref_name];
445
446    // Execute checkout (1 minute timeout)
447    let checkout_timeout = Duration::from_secs(60); // 1 minute
448    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    // Note: is_branch parameter is kept for API compatibility but not used
459    // (git checkout works the same for branches and tags)
460
461    Ok(())
462}
463
464/// Validate that a cloned repository contains a valid skill structure.
465///
466/// A valid skill structure must contain a `SKILL.md` file either at the repository root
467/// or in a subdirectory.
468///
469/// # Arguments
470///
471/// * `cloned_path` - Path to the cloned repository directory
472///
473/// # Returns
474///
475/// Returns the path to the directory containing `SKILL.md` (may be a subdirectory).
476///
477/// # Errors
478///
479/// Returns `ServiceError::Validation` if `SKILL.md` is not found in the repository.
480///
481/// # Examples
482///
483/// ```no_run
484/// use fastskill::storage::git::validate_cloned_skill;
485/// use std::path::Path;
486///
487/// # fn example() -> Result<(), fastskill::core::service::ServiceError> {
488/// let skill_path = validate_cloned_skill(Path::new("/path/to/cloned/repo"))?;
489/// // skill_path points to directory containing SKILL.md
490/// # Ok(())
491/// # }
492/// ```
493pub fn validate_cloned_skill(cloned_path: &Path) -> Result<PathBuf, ServiceError> {
494    // Check if SKILL.md exists at the root
495    let skill_file = cloned_path.join("SKILL.md");
496    if skill_file.exists() {
497        return Ok(cloned_path.to_path_buf());
498    }
499
500    // Check subdirectories for SKILL.md
501    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}