use crate::dependency::GitDependency;
use crate::error::PackageError;
use git2::{FetchOptions, RemoteCallbacks, Repository};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub struct PackageCache {
root: PathBuf,
}
#[derive(Debug, Serialize, Deserialize)]
struct PackageMeta {
name: String,
git: String,
rev: String,
cached_at: u64,
}
impl PackageCache {
pub fn new() -> Result<Self, PackageError> {
let root = Self::cache_dir()?;
std::fs::create_dir_all(&root)?;
Ok(Self { root })
}
pub fn cache_dir() -> Result<PathBuf, PackageError> {
if let Some(cache) = dirs::cache_dir() {
Ok(cache.join("grove").join("packages"))
} else if let Some(home) = dirs::home_dir() {
Ok(home.join(".grove").join("packages"))
} else {
Err(PackageError::IoError {
message: "could not determine home directory".to_string(),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "no home directory"),
})
}
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn package_path(&self, name: &str, rev: &str) -> PathBuf {
let short_rev = if rev.len() > 12 { &rev[..12] } else { rev };
self.root.join(name).join(short_rev)
}
pub fn is_cached(&self, name: &str, rev: &str) -> bool {
let path = self.package_path(name, rev);
let meta_path = path.join(".grove-meta.toml");
meta_path.exists()
}
pub fn get(&self, name: &str, rev: &str) -> Option<PathBuf> {
let path = self.package_path(name, rev);
if self.is_cached(name, rev) {
Some(path)
} else {
None
}
}
pub fn fetch(
&self,
name: &str,
spec: &GitDependency,
) -> Result<(PathBuf, String), PackageError> {
let sha = self.resolve_ref(&spec.git, spec.ref_string())?;
if let Some(path) = self.get(name, &sha) {
return Ok((path, sha));
}
let path = self.package_path(name, &sha);
std::fs::create_dir_all(&path)?;
self.clone_at_rev(&spec.git, &sha, &path)?;
let meta = PackageMeta {
name: name.to_string(),
git: spec.git.clone(),
rev: sha.clone(),
cached_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
};
let meta_path = path.join(".grove-meta.toml");
let meta_toml = toml::to_string_pretty(&meta).map_err(|e| PackageError::IoError {
message: format!("failed to serialize meta: {e}"),
source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
})?;
std::fs::write(&meta_path, meta_toml)?;
Ok((path, sha))
}
pub fn resolve_ref(&self, url: &str, ref_str: &str) -> Result<String, PackageError> {
let temp_dir = self.root.join(".git-cache");
std::fs::create_dir_all(&temp_dir)?;
let url_hash = format!("{:x}", md5_hash(url));
let repo_path = temp_dir.join(&url_hash);
let repo = if repo_path.exists() {
Repository::open_bare(&repo_path).map_err(|e| PackageError::GitFetchFailed {
url: url.to_string(),
reason: format!("failed to open cached repo: {e}"),
})?
} else {
let mut callbacks = RemoteCallbacks::new();
callbacks.transfer_progress(|_| true);
let mut fetch_opts = FetchOptions::new();
fetch_opts.remote_callbacks(callbacks);
let mut builder = git2::build::RepoBuilder::new();
builder.bare(true);
builder.fetch_options(fetch_opts);
builder
.clone(url, &repo_path)
.map_err(|e| PackageError::GitFetchFailed {
url: url.to_string(),
reason: e.message().to_string(),
})?
};
let mut remote = repo
.find_remote("origin")
.or_else(|_| repo.remote_anonymous(url))
.map_err(|e| PackageError::GitFetchFailed {
url: url.to_string(),
reason: format!("failed to find remote: {e}"),
})?;
let mut callbacks = RemoteCallbacks::new();
callbacks.transfer_progress(|_| true);
let mut fetch_opts = FetchOptions::new();
fetch_opts.remote_callbacks(callbacks);
remote
.fetch(&[ref_str], Some(&mut fetch_opts), None)
.map_err(|e| PackageError::GitFetchFailed {
url: url.to_string(),
reason: format!("fetch failed: {e}"),
})?;
if ref_str.len() == 40 && ref_str.chars().all(|c| c.is_ascii_hexdigit()) {
return Ok(ref_str.to_string());
}
if let Ok(reference) = repo.find_reference(&format!("refs/tags/{ref_str}")) {
if let Some(target) = reference.target() {
return Ok(target.to_string());
}
if let Ok(obj) = reference.peel(git2::ObjectType::Commit) {
return Ok(obj.id().to_string());
}
}
if let Ok(reference) = repo.find_reference(&format!("refs/remotes/origin/{ref_str}")) {
if let Some(target) = reference.target() {
return Ok(target.to_string());
}
}
if let Ok(reference) = repo.find_reference("FETCH_HEAD") {
if let Some(target) = reference.target() {
return Ok(target.to_string());
}
}
if let Ok(obj) = repo.revparse_single(ref_str) {
return Ok(obj.id().to_string());
}
Err(PackageError::GitFetchFailed {
url: url.to_string(),
reason: format!("could not resolve ref '{ref_str}'"),
})
}
fn clone_at_rev(&self, url: &str, rev: &str, dest: &Path) -> Result<(), PackageError> {
let mut callbacks = RemoteCallbacks::new();
callbacks.transfer_progress(|_| true);
let mut fetch_opts = FetchOptions::new();
fetch_opts.remote_callbacks(callbacks);
let mut builder = git2::build::RepoBuilder::new();
builder.fetch_options(fetch_opts);
let repo = builder
.clone(url, dest)
.map_err(|e| PackageError::GitFetchFailed {
url: url.to_string(),
reason: e.message().to_string(),
})?;
let oid = git2::Oid::from_str(rev).map_err(|e| PackageError::GitFetchFailed {
url: url.to_string(),
reason: format!("invalid SHA: {e}"),
})?;
let commit = repo
.find_commit(oid)
.map_err(|e| PackageError::GitFetchFailed {
url: url.to_string(),
reason: format!("commit not found: {e}"),
})?;
repo.checkout_tree(commit.as_object(), None)
.map_err(|e| PackageError::GitFetchFailed {
url: url.to_string(),
reason: format!("checkout failed: {e}"),
})?;
repo.set_head_detached(oid)
.map_err(|e| PackageError::GitFetchFailed {
url: url.to_string(),
reason: format!("set head failed: {e}"),
})?;
let git_dir = dest.join(".git");
if git_dir.exists() {
std::fs::remove_dir_all(&git_dir)?;
}
Ok(())
}
pub fn list(&self) -> Result<Vec<(String, String, PathBuf)>, PackageError> {
let mut packages = Vec::new();
if !self.root.exists() {
return Ok(packages);
}
for entry in std::fs::read_dir(&self.root)? {
let entry = entry?;
let pkg_name = entry.file_name().to_string_lossy().to_string();
if pkg_name.starts_with('.') {
continue;
}
if entry.path().is_dir() {
for version_entry in std::fs::read_dir(entry.path())? {
let version_entry = version_entry?;
let rev = version_entry.file_name().to_string_lossy().to_string();
let path = version_entry.path();
if path.join(".grove-meta.toml").exists() {
packages.push((pkg_name.clone(), rev, path));
}
}
}
}
Ok(packages)
}
pub fn remove(&self, name: &str) -> Result<(), PackageError> {
let pkg_dir = self.root.join(name);
if pkg_dir.exists() {
std::fs::remove_dir_all(&pkg_dir)?;
}
Ok(())
}
pub fn remove_version(&self, name: &str, rev: &str) -> Result<(), PackageError> {
let path = self.package_path(name, rev);
if path.exists() {
std::fs::remove_dir_all(&path)?;
}
Ok(())
}
pub fn clean(&self) -> Result<(), PackageError> {
if self.root.exists() {
std::fs::remove_dir_all(&self.root)?;
std::fs::create_dir_all(&self.root)?;
}
Ok(())
}
pub fn size(&self) -> Result<u64, PackageError> {
fn dir_size(path: &Path) -> std::io::Result<u64> {
let mut size = 0;
if path.is_dir() {
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
size += dir_size(&path)?;
} else {
size += entry.metadata()?.len();
}
}
}
Ok(size)
}
dir_size(&self.root).map_err(|e| PackageError::IoError {
message: "failed to calculate cache size".to_string(),
source: e,
})
}
}
impl Default for PackageCache {
fn default() -> Self {
Self::new().expect("failed to create package cache")
}
}
fn md5_hash(s: &str) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
s.hash(&mut hasher);
hasher.finish()
}
#[derive(Debug, Default)]
pub struct ResolvedPackage {
pub name: String,
pub version: String,
pub path: PathBuf,
pub rev: Option<String>,
pub git: Option<String>,
pub source_path: Option<String>,
pub dependencies: Vec<String>,
}
pub type ResolvedPackagesMap = HashMap<String, ResolvedPackage>;