mockforge_plugin_loader/
git.rs

1//! Git repository plugin loading
2//!
3//! This module provides functionality for cloning plugins from Git repositories
4//! with support for:
5//! - Version pinning (tags, branches, commits)
6//! - Shallow clones for performance
7//! - SSH and HTTPS authentication
8//! - Repository caching
9
10use super::*;
11use std::path::{Path, PathBuf};
12
13#[cfg(feature = "git-support")]
14use git2::{build::RepoBuilder, FetchOptions, Repository};
15
16/// Git repository reference (tag, branch, or commit)
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum GitRef {
19    /// A specific tag (e.g., "v1.0.0")
20    Tag(String),
21    /// A branch name (e.g., "main", "develop")
22    Branch(String),
23    /// A commit SHA (e.g., "abc123def456")
24    Commit(String),
25    /// Default branch (usually "main" or "master")
26    Default,
27}
28
29impl GitRef {
30    /// Parse a Git reference from a string
31    ///
32    /// Examples:
33    /// - "v1.0.0" -> Tag("v1.0.0")
34    /// - "main" -> Branch("main")
35    /// - "abc123" -> Commit("abc123") if it looks like a commit SHA
36    pub fn parse(s: &str) -> Self {
37        if s.is_empty() {
38            return GitRef::Default;
39        }
40
41        // Check if it's a commit SHA (40-char hex string)
42        if s.len() == 40 && s.chars().all(|c| c.is_ascii_hexdigit()) {
43            return GitRef::Commit(s.to_string());
44        }
45
46        // Check if it starts with 'v' followed by numbers (version tag)
47        if s.starts_with('v') && s[1..].chars().next().is_some_and(|c| c.is_ascii_digit()) {
48            return GitRef::Tag(s.to_string());
49        }
50
51        // Otherwise, treat as branch
52        GitRef::Branch(s.to_string())
53    }
54}
55
56impl std::fmt::Display for GitRef {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            GitRef::Tag(tag) => write!(f, "tag:{}", tag),
60            GitRef::Branch(branch) => write!(f, "branch:{}", branch),
61            GitRef::Commit(commit) => write!(f, "commit:{}", commit),
62            GitRef::Default => write!(f, "default"),
63        }
64    }
65}
66
67/// Git plugin source specification
68#[derive(Debug, Clone)]
69pub struct GitPluginSource {
70    /// Repository URL (HTTPS or SSH)
71    pub url: String,
72    /// Git reference (tag, branch, or commit)
73    pub git_ref: GitRef,
74    /// Subdirectory within the repo (optional)
75    pub subdirectory: Option<String>,
76}
77
78impl GitPluginSource {
79    /// Parse a Git plugin source from a string
80    ///
81    /// Formats:
82    /// - `https://github.com/user/repo` - Default branch
83    /// - `https://github.com/user/repo#v1.0.0` - Specific tag/branch/commit
84    /// - `https://github.com/user/repo#v1.0.0:subdir` - With subdirectory
85    pub fn parse(input: &str) -> LoaderResult<Self> {
86        // Split on '#' for ref specification
87        let (url_part, ref_part) = if let Some((url, ref_spec)) = input.split_once('#') {
88            (url, Some(ref_spec))
89        } else {
90            (input, None)
91        };
92
93        // Parse ref and subdirectory
94        let (git_ref, subdirectory) = if let Some(ref_spec) = ref_part {
95            if let Some((ref_str, subdir)) = ref_spec.split_once(':') {
96                (GitRef::parse(ref_str), Some(subdir.to_string()))
97            } else {
98                (GitRef::parse(ref_spec), None)
99            }
100        } else {
101            (GitRef::Default, None)
102        };
103
104        Ok(Self {
105            url: url_part.to_string(),
106            git_ref,
107            subdirectory,
108        })
109    }
110}
111
112impl std::fmt::Display for GitPluginSource {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        write!(f, "{}#{}", self.url, self.git_ref)?;
115        if let Some(ref subdir) = self.subdirectory {
116            write!(f, ":{}", subdir)?;
117        }
118        Ok(())
119    }
120}
121
122/// Configuration for Git plugin loading
123#[derive(Debug, Clone)]
124pub struct GitPluginConfig {
125    /// Cache directory for cloned repositories
126    pub cache_dir: PathBuf,
127    /// Use shallow clones (depth=1) for performance
128    pub shallow_clone: bool,
129    /// Include submodules when cloning
130    pub include_submodules: bool,
131}
132
133impl Default for GitPluginConfig {
134    fn default() -> Self {
135        Self {
136            cache_dir: dirs::cache_dir()
137                .unwrap_or_else(|| PathBuf::from(".cache"))
138                .join("mockforge")
139                .join("git-plugins"),
140            shallow_clone: true,
141            include_submodules: false,
142        }
143    }
144}
145
146/// Git plugin loader for cloning plugins from Git repositories
147#[cfg(feature = "git-support")]
148pub struct GitPluginLoader {
149    config: GitPluginConfig,
150}
151
152#[cfg(feature = "git-support")]
153impl GitPluginLoader {
154    /// Create a new Git plugin loader
155    pub fn new(config: GitPluginConfig) -> LoaderResult<Self> {
156        // Create cache directory if it doesn't exist
157        std::fs::create_dir_all(&config.cache_dir).map_err(|e| {
158            PluginLoaderError::fs(format!(
159                "Failed to create cache directory {}: {}",
160                config.cache_dir.display(),
161                e
162            ))
163        })?;
164
165        Ok(Self { config })
166    }
167
168    /// Clone a plugin from a Git repository
169    ///
170    /// Returns the path to the cloned plugin directory
171    pub async fn clone_from_git(&self, source: &GitPluginSource) -> LoaderResult<PathBuf> {
172        tracing::info!("Cloning plugin from Git: {}", source);
173
174        // Generate cache key from repository URL and ref
175        let cache_key = self.generate_cache_key(&source.url, &source.git_ref);
176        let repo_path = self.config.cache_dir.join(&cache_key);
177
178        // Check if repository is already cloned
179        if repo_path.exists() && Repository::open(&repo_path).is_ok() {
180            tracing::info!("Using cached repository at: {}", repo_path.display());
181
182            // Update the repository
183            self.update_repository(&repo_path, source).await?;
184        } else {
185            // Clone the repository
186            self.clone_repository(&source.url, &repo_path, source).await?;
187        }
188
189        // If subdirectory is specified, return that path
190        let plugin_path = if let Some(ref subdir) = source.subdirectory {
191            let subdir_path = repo_path.join(subdir);
192            if !subdir_path.exists() {
193                return Err(PluginLoaderError::load(format!(
194                    "Subdirectory '{}' not found in repository",
195                    subdir
196                )));
197            }
198            subdir_path
199        } else {
200            repo_path
201        };
202
203        tracing::info!("Plugin cloned to: {}", plugin_path.display());
204        Ok(plugin_path)
205    }
206
207    /// Clone a repository
208    async fn clone_repository(
209        &self,
210        url: &str,
211        dest: &Path,
212        source: &GitPluginSource,
213    ) -> LoaderResult<()> {
214        tracing::info!("Cloning repository from: {}", url);
215
216        // Prepare fetch options
217        let mut fetch_options = FetchOptions::new();
218
219        // Configure shallow clone if enabled
220        if self.config.shallow_clone && matches!(source.git_ref, GitRef::Tag(_) | GitRef::Branch(_))
221        {
222            fetch_options.depth(1);
223        }
224
225        // Build repository
226        let mut repo_builder = RepoBuilder::new();
227        repo_builder.fetch_options(fetch_options);
228
229        // Set branch if specified
230        if let GitRef::Branch(ref branch) = source.git_ref {
231            repo_builder.branch(branch);
232        }
233
234        // Clone the repository
235        let repo = repo_builder
236            .clone(url, dest)
237            .map_err(|e| PluginLoaderError::load(format!("Failed to clone repository: {}", e)))?;
238
239        // Checkout specific ref if needed
240        match &source.git_ref {
241            GitRef::Tag(tag) => {
242                self.checkout_tag(&repo, tag)?;
243            }
244            GitRef::Commit(commit) => {
245                self.checkout_commit(&repo, commit)?;
246            }
247            GitRef::Branch(_) | GitRef::Default => {
248                // Already on the correct branch from clone
249            }
250        }
251
252        // Initialize submodules if enabled
253        if self.config.include_submodules {
254            self.init_submodules(&repo)?;
255        }
256
257        tracing::info!("Repository cloned successfully");
258        Ok(())
259    }
260
261    /// Update an existing repository
262    async fn update_repository(
263        &self,
264        repo_path: &Path,
265        source: &GitPluginSource,
266    ) -> LoaderResult<()> {
267        tracing::info!("Updating repository at: {}", repo_path.display());
268
269        let repo = Repository::open(repo_path)
270            .map_err(|e| PluginLoaderError::load(format!("Failed to open repository: {}", e)))?;
271
272        // Fetch latest changes
273        let mut remote = repo
274            .find_remote("origin")
275            .map_err(|e| PluginLoaderError::load(format!("Failed to find remote: {}", e)))?;
276
277        let mut fetch_options = FetchOptions::new();
278        remote
279            .fetch(&[] as &[&str], Some(&mut fetch_options), None)
280            .map_err(|e| PluginLoaderError::load(format!("Failed to fetch: {}", e)))?;
281
282        // Checkout the specified ref
283        match &source.git_ref {
284            GitRef::Tag(tag) => {
285                self.checkout_tag(&repo, tag)?;
286            }
287            GitRef::Branch(branch) => {
288                self.checkout_branch(&repo, branch)?;
289            }
290            GitRef::Commit(commit) => {
291                self.checkout_commit(&repo, commit)?;
292            }
293            GitRef::Default => {
294                // Stay on current branch, just pull
295                self.pull_current_branch(&repo)?;
296            }
297        }
298
299        tracing::info!("Repository updated successfully");
300        Ok(())
301    }
302
303    /// Checkout a specific tag
304    fn checkout_tag(&self, repo: &Repository, tag: &str) -> LoaderResult<()> {
305        let refname = format!("refs/tags/{}", tag);
306        let obj = repo
307            .revparse_single(&refname)
308            .map_err(|e| PluginLoaderError::load(format!("Failed to find tag '{}': {}", tag, e)))?;
309
310        repo.checkout_tree(&obj, None)
311            .map_err(|e| PluginLoaderError::load(format!("Failed to checkout tag: {}", e)))?;
312
313        repo.set_head_detached(obj.id())
314            .map_err(|e| PluginLoaderError::load(format!("Failed to set HEAD: {}", e)))?;
315
316        Ok(())
317    }
318
319    /// Checkout a specific branch
320    fn checkout_branch(&self, repo: &Repository, branch: &str) -> LoaderResult<()> {
321        let refname = format!("refs/remotes/origin/{}", branch);
322        let obj = repo.revparse_single(&refname).map_err(|e| {
323            PluginLoaderError::load(format!("Failed to find branch '{}': {}", branch, e))
324        })?;
325
326        repo.checkout_tree(&obj, None)
327            .map_err(|e| PluginLoaderError::load(format!("Failed to checkout branch: {}", e)))?;
328
329        // Create local branch if it doesn't exist
330        let branch_refname = format!("refs/heads/{}", branch);
331        let _ = repo.reference(&branch_refname, obj.id(), true, "checkout branch");
332
333        repo.set_head(&branch_refname)
334            .map_err(|e| PluginLoaderError::load(format!("Failed to set HEAD: {}", e)))?;
335
336        Ok(())
337    }
338
339    /// Checkout a specific commit
340    fn checkout_commit(&self, repo: &Repository, commit: &str) -> LoaderResult<()> {
341        let obj = repo.revparse_single(commit).map_err(|e| {
342            PluginLoaderError::load(format!("Failed to find commit '{}': {}", commit, e))
343        })?;
344
345        repo.checkout_tree(&obj, None)
346            .map_err(|e| PluginLoaderError::load(format!("Failed to checkout commit: {}", e)))?;
347
348        repo.set_head_detached(obj.id())
349            .map_err(|e| PluginLoaderError::load(format!("Failed to set HEAD: {}", e)))?;
350
351        Ok(())
352    }
353
354    /// Pull the current branch
355    fn pull_current_branch(&self, repo: &Repository) -> LoaderResult<()> {
356        // Get current branch
357        let head = repo
358            .head()
359            .map_err(|e| PluginLoaderError::load(format!("Failed to get HEAD: {}", e)))?;
360
361        if !head.is_branch() {
362            // Detached HEAD, nothing to pull
363            return Ok(());
364        }
365
366        let branch = head
367            .shorthand()
368            .ok_or_else(|| PluginLoaderError::load("Failed to get branch name"))?;
369
370        // Fetch and merge
371        let mut remote = repo
372            .find_remote("origin")
373            .map_err(|e| PluginLoaderError::load(format!("Failed to find remote: {}", e)))?;
374
375        let mut fetch_options = FetchOptions::new();
376        remote
377            .fetch(&[branch], Some(&mut fetch_options), None)
378            .map_err(|e| PluginLoaderError::load(format!("Failed to fetch: {}", e)))?;
379
380        // Fast-forward merge
381        let fetch_head = repo
382            .find_reference("FETCH_HEAD")
383            .map_err(|e| PluginLoaderError::load(format!("Failed to find FETCH_HEAD: {}", e)))?;
384
385        let fetch_commit = repo
386            .reference_to_annotated_commit(&fetch_head)
387            .map_err(|e| PluginLoaderError::load(format!("Failed to get commit: {}", e)))?;
388
389        // Perform fast-forward
390        let (analysis, _) = repo
391            .merge_analysis(&[&fetch_commit])
392            .map_err(|e| PluginLoaderError::load(format!("Failed to analyze merge: {}", e)))?;
393
394        if analysis.is_fast_forward() {
395            let mut reference = repo
396                .find_reference(&format!("refs/heads/{}", branch))
397                .map_err(|e| PluginLoaderError::load(format!("Failed to find reference: {}", e)))?;
398
399            reference
400                .set_target(fetch_commit.id(), "Fast-forward")
401                .map_err(|e| PluginLoaderError::load(format!("Failed to fast-forward: {}", e)))?;
402
403            repo.set_head(&format!("refs/heads/{}", branch))
404                .map_err(|e| PluginLoaderError::load(format!("Failed to set HEAD: {}", e)))?;
405
406            repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
407                .map_err(|e| PluginLoaderError::load(format!("Failed to checkout HEAD: {}", e)))?;
408        }
409
410        Ok(())
411    }
412
413    /// Initialize submodules
414    fn init_submodules(&self, repo: &Repository) -> LoaderResult<()> {
415        repo.submodules()
416            .map_err(|e| PluginLoaderError::load(format!("Failed to get submodules: {}", e)))?
417            .iter_mut()
418            .try_for_each(|submodule| {
419                submodule.update(true, None).map_err(|e| {
420                    PluginLoaderError::load(format!("Failed to update submodule: {}", e))
421                })
422            })?;
423
424        Ok(())
425    }
426
427    /// Generate a cache key from repository URL and ref
428    fn generate_cache_key(&self, url: &str, git_ref: &GitRef) -> String {
429        use ring::digest::{Context, SHA256};
430
431        let combined = format!("{}#{}", url, git_ref);
432        let mut context = Context::new(&SHA256);
433        context.update(combined.as_bytes());
434        let digest = context.finish();
435        hex::encode(digest.as_ref())
436    }
437
438    /// Clear the Git repository cache
439    pub async fn clear_cache(&self) -> LoaderResult<()> {
440        if self.config.cache_dir.exists() {
441            tokio::fs::remove_dir_all(&self.config.cache_dir).await.map_err(|e| {
442                PluginLoaderError::fs(format!("Failed to clear cache directory: {}", e))
443            })?;
444            tokio::fs::create_dir_all(&self.config.cache_dir).await.map_err(|e| {
445                PluginLoaderError::fs(format!("Failed to recreate cache directory: {}", e))
446            })?;
447        }
448        Ok(())
449    }
450
451    /// Get the size of the Git repository cache
452    pub fn get_cache_size(&self) -> LoaderResult<u64> {
453        let mut total_size = 0u64;
454
455        if !self.config.cache_dir.exists() {
456            return Ok(0);
457        }
458
459        for entry in std::fs::read_dir(&self.config.cache_dir)
460            .map_err(|e| PluginLoaderError::fs(format!("Failed to read cache directory: {}", e)))?
461        {
462            let entry =
463                entry.map_err(|e| PluginLoaderError::fs(format!("Failed to read entry: {}", e)))?;
464            let metadata = entry
465                .metadata()
466                .map_err(|e| PluginLoaderError::fs(format!("Failed to read metadata: {}", e)))?;
467
468            if metadata.is_file() {
469                total_size += metadata.len();
470            } else if metadata.is_dir() {
471                total_size += self.calculate_dir_size(&entry.path())?;
472            }
473        }
474
475        Ok(total_size)
476    }
477
478    /// Calculate the size of a directory recursively
479    #[allow(clippy::only_used_in_recursion)]
480    fn calculate_dir_size(&self, dir: &Path) -> LoaderResult<u64> {
481        let mut total_size = 0u64;
482
483        for entry in std::fs::read_dir(dir)
484            .map_err(|e| PluginLoaderError::fs(format!("Failed to read directory: {}", e)))?
485        {
486            let entry =
487                entry.map_err(|e| PluginLoaderError::fs(format!("Failed to read entry: {}", e)))?;
488            let metadata = entry
489                .metadata()
490                .map_err(|e| PluginLoaderError::fs(format!("Failed to read metadata: {}", e)))?;
491
492            if metadata.is_file() {
493                total_size += metadata.len();
494            } else if metadata.is_dir() {
495                total_size += self.calculate_dir_size(&entry.path())?;
496            }
497        }
498
499        Ok(total_size)
500    }
501}
502
503#[cfg(not(feature = "git-support"))]
504pub struct GitPluginLoader;
505
506#[cfg(not(feature = "git-support"))]
507impl GitPluginLoader {
508    pub fn new(_config: GitPluginConfig) -> LoaderResult<Self> {
509        Err(PluginLoaderError::load(
510            "Git support not enabled. Recompile with 'git-support' feature",
511        ))
512    }
513
514    pub async fn clone_from_git(&self, _source: &GitPluginSource) -> LoaderResult<PathBuf> {
515        Err(PluginLoaderError::load(
516            "Git support not enabled. Recompile with 'git-support' feature",
517        ))
518    }
519
520    pub async fn clear_cache(&self) -> LoaderResult<()> {
521        Err(PluginLoaderError::load(
522            "Git support not enabled. Recompile with 'git-support' feature",
523        ))
524    }
525
526    pub fn get_cache_size(&self) -> LoaderResult<u64> {
527        Err(PluginLoaderError::load(
528            "Git support not enabled. Recompile with 'git-support' feature",
529        ))
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    #[test]
538    fn test_git_ref_parse() {
539        assert_eq!(GitRef::parse("v1.0.0"), GitRef::Tag("v1.0.0".to_string()));
540        assert_eq!(GitRef::parse("main"), GitRef::Branch("main".to_string()));
541        assert_eq!(
542            GitRef::parse("abc123def456789012345678901234567890abcd"),
543            GitRef::Commit("abc123def456789012345678901234567890abcd".to_string())
544        );
545        assert_eq!(GitRef::parse(""), GitRef::Default);
546    }
547
548    #[test]
549    fn test_git_plugin_source_parse() {
550        // URL only
551        let source = GitPluginSource::parse("https://github.com/user/repo").unwrap();
552        assert_eq!(source.url, "https://github.com/user/repo");
553        assert_eq!(source.git_ref, GitRef::Default);
554        assert_eq!(source.subdirectory, None);
555
556        // URL with tag
557        let source = GitPluginSource::parse("https://github.com/user/repo#v1.0.0").unwrap();
558        assert_eq!(source.url, "https://github.com/user/repo");
559        assert_eq!(source.git_ref, GitRef::Tag("v1.0.0".to_string()));
560        assert_eq!(source.subdirectory, None);
561
562        // URL with branch and subdirectory
563        let source =
564            GitPluginSource::parse("https://github.com/user/repo#main:plugins/auth").unwrap();
565        assert_eq!(source.url, "https://github.com/user/repo");
566        assert_eq!(source.git_ref, GitRef::Branch("main".to_string()));
567        assert_eq!(source.subdirectory, Some("plugins/auth".to_string()));
568    }
569
570    #[test]
571    fn test_git_ref_display() {
572        assert_eq!(GitRef::Tag("v1.0.0".to_string()).to_string(), "tag:v1.0.0");
573        assert_eq!(GitRef::Branch("main".to_string()).to_string(), "branch:main");
574        assert_eq!(GitRef::Commit("abc123".to_string()).to_string(), "commit:abc123");
575        assert_eq!(GitRef::Default.to_string(), "default");
576    }
577
578    #[test]
579    fn test_git_plugin_source_display() {
580        let source = GitPluginSource {
581            url: "https://github.com/user/repo".to_string(),
582            git_ref: GitRef::Tag("v1.0.0".to_string()),
583            subdirectory: Some("plugins".to_string()),
584        };
585        assert_eq!(source.to_string(), "https://github.com/user/repo#tag:v1.0.0:plugins");
586    }
587}