Skip to main content

synwire_agent/vfs/
clone.rs

1//! Git repository clone and update operations.
2
3use std::path::Path;
4use std::process::Command;
5
6/// Options controlling how a repository is cloned or updated.
7#[derive(Debug, Clone)]
8#[non_exhaustive]
9pub struct CloneOptions {
10    /// Remote URL to clone from.
11    pub url: String,
12    /// Shallow clone depth.  `None` performs a full clone.
13    pub depth: Option<u32>,
14    /// Branch, tag, or commit ref to check out after cloning.
15    pub r#ref: Option<String>,
16    /// Whether to trigger semantic indexing after a successful clone.
17    pub index: bool,
18}
19
20impl CloneOptions {
21    /// Construct minimal options with only the remote URL.
22    #[must_use]
23    pub fn new(url: impl Into<String>) -> Self {
24        Self {
25            url: url.into(),
26            depth: None,
27            r#ref: None,
28            index: false,
29        }
30    }
31}
32
33/// Errors produced by [`clone_or_update`].
34#[derive(Debug, thiserror::Error)]
35#[non_exhaustive]
36pub enum CloneError {
37    /// A git operation failed.
38    #[error("git error: {0}")]
39    Git(String),
40    /// An I/O error occurred.
41    #[error("I/O error: {0}")]
42    Io(String),
43}
44
45/// Run a git command and map failures to [`CloneError`].
46fn run_git(args: &[&str]) -> Result<(), CloneError> {
47    let output = Command::new("git")
48        .args(args)
49        .output()
50        .map_err(|e| CloneError::Io(e.to_string()))?;
51
52    if output.status.success() {
53        Ok(())
54    } else {
55        let stderr = String::from_utf8_lossy(&output.stderr);
56        Err(CloneError::Git(stderr.trim().to_owned()))
57    }
58}
59
60/// Clone `options.url` into `dest`, or fetch + checkout if `dest` already exists.
61///
62/// When `dest` already contains a `.git` directory the function runs
63/// `git fetch origin` followed by an optional `git checkout <ref>`.
64/// Otherwise it performs a fresh `git clone`, honouring the optional
65/// `depth` and `ref` fields in [`CloneOptions`].
66///
67/// # Errors
68///
69/// Returns [`CloneError::Git`] when a git command exits with a non-zero
70/// status, carrying the stderr output. Returns [`CloneError::Io`] when the
71/// git binary cannot be spawned.
72pub fn clone_or_update(options: &CloneOptions, dest: &Path) -> Result<(), CloneError> {
73    if dest.join(".git").is_dir() {
74        // Existing repo — fetch and optionally checkout the requested ref.
75        let dest_str = dest
76            .to_str()
77            .ok_or_else(|| CloneError::Io("destination path is not valid UTF-8".to_owned()))?;
78
79        run_git(&["-C", dest_str, "fetch", "origin"])?;
80
81        if let Some(git_ref) = &options.r#ref {
82            run_git(&["-C", dest_str, "checkout", git_ref])?;
83        }
84    } else {
85        // Fresh clone.
86        let dest_str = dest
87            .to_str()
88            .ok_or_else(|| CloneError::Io("destination path is not valid UTF-8".to_owned()))?;
89
90        let mut args: Vec<&str> = vec!["clone"];
91
92        // Allocate the depth string outside the conditional so the borrow
93        // lives long enough for the `args` slice.
94        let depth_str;
95        if let Some(depth) = options.depth {
96            depth_str = depth.to_string();
97            args.extend_from_slice(&["--depth", &depth_str]);
98        }
99
100        if let Some(git_ref) = &options.r#ref {
101            args.extend_from_slice(&["--branch", git_ref]);
102        }
103
104        args.push(&options.url);
105        args.push(dest_str);
106
107        run_git(&args)?;
108    }
109
110    Ok(())
111}
112
113#[cfg(test)]
114#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
115mod tests {
116    use super::*;
117    use tempfile::tempdir;
118
119    #[test]
120    fn clone_invalid_url_returns_git_error() {
121        let dir = tempdir().expect("tempdir");
122        // Use a nonexistent dest sub-path so there is no `.git` dir and the
123        // clone path is taken. The URL is intentionally bogus.
124        let dest = dir.path().join("target");
125        let opts = CloneOptions::new("https://invalid.example.test/no-such-repo.git");
126        let err = clone_or_update(&opts, &dest);
127        // Either a Git error (git is available but URL fails) or an IO error
128        // (git binary not found) — but never "not yet implemented".
129        assert!(err.is_err());
130        let msg = err.unwrap_err().to_string();
131        assert!(
132            !msg.contains("not yet implemented"),
133            "should no longer return stub error, got: {msg}"
134        );
135    }
136}