use anyhow::{Context, Result};
use git2::{
build::{CheckoutBuilder, RepoBuilder},
Repository,
};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use tracing::{debug, info};
pub struct GitCache {
base_dir: PathBuf,
}
impl GitCache {
pub fn new<P: AsRef<Path>>(base_dir: P) -> Self {
Self {
base_dir: base_dir.as_ref().to_path_buf(),
}
}
fn get_target_dir(&self, repo_url: &str, rev: &str) -> PathBuf {
let repo_hash = self.hash_url(repo_url);
let rev_hash = self.hash_rev(rev);
self.base_dir
.join("checkouts")
.join(&repo_hash)
.join(&rev_hash)
}
pub fn init_repo(&self, repo_url: &str, rev: &str) -> Result<PathBuf> {
let target_dir = self.get_target_dir(repo_url, rev);
if target_dir.exists() {
return Ok(target_dir);
}
info!(
"Initializing metadata-only cache for {} at rev {} in {:?}",
repo_url, rev, target_dir
);
std::fs::create_dir_all(&target_dir)?;
let fetch_opts = git2::FetchOptions::new();
let mut empty_checkout = CheckoutBuilder::new();
empty_checkout.dry_run();
let repo = RepoBuilder::new()
.fetch_options(fetch_opts)
.with_checkout(empty_checkout) .clone(repo_url, &target_dir)
.with_context(|| format!("Failed to clone metadata for {}", repo_url))?;
self.checkout_revision_metadata(&repo, rev)?;
Ok(target_dir)
}
pub fn ensure_files(&self, repo_url: &str, rev: &str, paths: &[String]) -> Result<PathBuf> {
let target_dir = self.init_repo(repo_url, rev)?;
let repo = Repository::open(&target_dir)?;
if paths.is_empty() {
return Ok(target_dir);
}
debug!("Ensuring paths {:?} are present in {:?}", paths, target_dir);
let mut cb = CheckoutBuilder::new();
cb.force();
for path in paths {
cb.path(path);
}
repo.checkout_head(Some(&mut cb))
.with_context(|| format!("Failed to checkout paths {:?} for rev {}", paths, rev))?;
Ok(target_dir)
}
#[allow(dead_code)]
pub fn get_repo(&self, repo_url: &str, rev: &str) -> Result<PathBuf> {
let target_dir = self.init_repo(repo_url, rev)?;
let repo = Repository::open(&target_dir)?;
let mut cb = CheckoutBuilder::new();
cb.force();
repo.checkout_head(Some(&mut cb))?;
Ok(target_dir)
}
fn hash_url(&self, url: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(url.as_bytes());
hex::encode(hasher.finalize())[..16].to_string()
}
fn hash_rev(&self, rev: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(rev.as_bytes());
hex::encode(hasher.finalize())[..16].to_string()
}
fn checkout_revision_metadata(&self, repo: &Repository, rev: &str) -> Result<()> {
let (object, reference) = repo
.revparse_ext(rev)
.with_context(|| format!("Failed to find revision {}", rev))?;
match reference {
Some(ref r) if r.is_branch() => repo.set_head(r.name().unwrap()),
_ => repo.set_head_detached(object.id()),
}
.with_context(|| format!("Failed to set HEAD to revision {}", rev))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_git_cache_path_generation() {
let tmp = tempdir().unwrap();
let cache = GitCache::new(tmp.path());
let url = "https://github.com/example/repo.git";
let rev = "main";
let _path = cache.get_target_dir(url, rev);
assert_eq!(cache.hash_url(url).len(), 16);
assert_eq!(cache.hash_rev(rev).len(), 16);
}
}