stormchaser_engine/
git_cache.rs1use 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
10pub struct GitCache {
12 base_dir: PathBuf,
13}
14
15impl GitCache {
16 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 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 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 let fetch_opts = git2::FetchOptions::new();
51 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) .clone(repo_url, &target_dir)
60 .with_context(|| format!("Failed to clone metadata for {}", repo_url))?;
61
62 self.checkout_revision_metadata(&repo, rev)?;
64
65 Ok(target_dir)
66 }
67
68 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(); 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 #[allow(dead_code)]
95 pub fn get_repo(&self, repo_url: &str, rev: &str) -> Result<PathBuf> {
96 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 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}