Skip to main content

stormchaser_engine/
git_cache.rs

1use anyhow::{Context, Result};
2use git2::{
3    build::{CheckoutBuilder, RepoBuilder},
4    Repository,
5};
6use sha2::{Digest, Sha256};
7use std::path::{Path, PathBuf};
8use tracing::{debug, info};
9
10/// Gitcache.
11pub struct GitCache {
12    base_dir: PathBuf,
13}
14
15impl GitCache {
16    /// New.
17    pub fn new<P: AsRef<Path>>(base_dir: P) -> Self {
18        Self {
19            base_dir: base_dir.as_ref().to_path_buf(),
20        }
21    }
22
23    /// Returns the target directory for a repo/rev pair.
24    fn get_target_dir(&self, repo_url: &str, rev: &str) -> PathBuf {
25        let repo_hash = self.hash_url(repo_url);
26        let rev_hash = self.hash_rev(rev);
27        self.base_dir
28            .join("checkouts")
29            .join(&repo_hash)
30            .join(&rev_hash)
31    }
32
33    /// Initializes a repository cache entry if it doesn't exist.
34    /// Uses metadata-only clone (no initial checkout).
35    pub fn init_repo(&self, repo_url: &str, rev: &str) -> Result<PathBuf> {
36        let target_dir = self.get_target_dir(repo_url, rev);
37
38        if target_dir.exists() {
39            return Ok(target_dir);
40        }
41
42        info!(
43            "Initializing metadata-only cache for {} at rev {} in {:?}",
44            repo_url, rev, target_dir
45        );
46
47        std::fs::create_dir_all(&target_dir)?;
48
49        // Clone WITHOUT checkout
50        let fetch_opts = git2::FetchOptions::new();
51        // We could add depth(1) here but it can be problematic with specific refs/shas
52
53        let mut empty_checkout = CheckoutBuilder::new();
54        empty_checkout.dry_run();
55
56        let repo = RepoBuilder::new()
57            .fetch_options(fetch_opts)
58            .with_checkout(empty_checkout) // Empty checkout
59            .clone(repo_url, &target_dir)
60            .with_context(|| format!("Failed to clone metadata for {}", repo_url))?;
61
62        // Resolve and set HEAD to the revision
63        self.checkout_revision_metadata(&repo, rev)?;
64
65        Ok(target_dir)
66    }
67
68    /// Ensures specific files/folders are present in the working directory.
69    /// This can be called multiple times to add more files to the same cache entry.
70    pub fn ensure_files(&self, repo_url: &str, rev: &str, paths: &[String]) -> Result<PathBuf> {
71        let target_dir = self.init_repo(repo_url, rev)?;
72        let repo = Repository::open(&target_dir)?;
73
74        if paths.is_empty() {
75            return Ok(target_dir);
76        }
77
78        debug!("Ensuring paths {:?} are present in {:?}", paths, target_dir);
79
80        let mut cb = CheckoutBuilder::new();
81        cb.force(); // Overwrite if exists
82
83        for path in paths {
84            cb.path(path);
85        }
86
87        repo.checkout_head(Some(&mut cb))
88            .with_context(|| format!("Failed to checkout paths {:?} for rev {}", paths, rev))?;
89
90        Ok(target_dir)
91    }
92
93    /// Legacy method updated to use the new implementation
94    #[allow(dead_code)]
95    pub fn get_repo(&self, repo_url: &str, rev: &str) -> Result<PathBuf> {
96        // Full checkout for the legacy get_repo
97        // We achieve this by NOT specifying paths, but by default git2 checkout_head
98        // without pathspecs checkouts everything.
99        let target_dir = self.init_repo(repo_url, rev)?;
100        let repo = Repository::open(&target_dir)?;
101
102        let mut cb = CheckoutBuilder::new();
103        cb.force();
104        repo.checkout_head(Some(&mut cb))?;
105
106        Ok(target_dir)
107    }
108
109    fn hash_url(&self, url: &str) -> String {
110        let mut hasher = Sha256::new();
111        hasher.update(url.as_bytes());
112        hex::encode(hasher.finalize())[..16].to_string()
113    }
114
115    fn hash_rev(&self, rev: &str) -> String {
116        let mut hasher = Sha256::new();
117        hasher.update(rev.as_bytes());
118        hex::encode(hasher.finalize())[..16].to_string()
119    }
120
121    fn checkout_revision_metadata(&self, repo: &Repository, rev: &str) -> Result<()> {
122        let (object, reference) = repo
123            .revparse_ext(rev)
124            .with_context(|| format!("Failed to find revision {}", rev))?;
125
126        // Note: we DON'T call checkout_tree here if we want metadata only.
127        // We just set HEAD.
128
129        match reference {
130            Some(ref r) if r.is_branch() => repo.set_head(r.name().unwrap()),
131            _ => repo.set_head_detached(object.id()),
132        }
133        .with_context(|| format!("Failed to set HEAD to revision {}", rev))?;
134
135        Ok(())
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use tempfile::tempdir;
143
144    #[test]
145    fn test_git_cache_path_generation() {
146        let tmp = tempdir().unwrap();
147        let cache = GitCache::new(tmp.path());
148
149        let url = "https://github.com/example/repo.git";
150        let rev = "main";
151
152        let _path = cache.get_target_dir(url, rev);
153
154        assert_eq!(cache.hash_url(url).len(), 16);
155        assert_eq!(cache.hash_rev(rev).len(), 16);
156    }
157}