use crate::github::types::TreeResponse;
use serde::{Deserialize, Serialize};
use std::io;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RefCache {
pub tree_sha: String,
pub etag: Option<String>,
}
#[cfg(test)]
pub fn root() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("rootle").join("providers").join("github"))
}
fn migrate_from_legacy(base: &Path) {
let mine = base.join("providers").join("github");
for dir in ["trees", "blobs", "index"] {
let old = base.join(dir);
let new = mine.join(dir);
if old.exists() && !new.exists() {
let _ = std::fs::create_dir_all(&mine);
let _ = std::fs::rename(&old, &new);
}
}
}
fn root_or_migrate() -> Option<PathBuf> {
let base = dirs::cache_dir().map(|d| d.join("rootle"))?;
migrate_from_legacy(&base);
Some(base.join("providers").join("github"))
}
fn ref_path(root: &std::path::Path, owner: &str, repo: &str, branch: &str) -> PathBuf {
root.join("index/refs").join(owner).join(repo).join(branch)
}
fn tree_path(root: &std::path::Path, sha: &str) -> PathBuf {
root.join("trees").join(format!("{sha}.json"))
}
fn blob_path(root: &std::path::Path, sha: &str) -> PathBuf {
root.join("blobs")
.join(&sha[..2.min(sha.len())])
.join(&sha[2.min(sha.len())..])
}
pub fn read_blob(sha: &str) -> Option<Vec<u8>> {
let path = blob_path(&root_or_migrate()?, sha);
let bytes = std::fs::read(&path).ok()?;
touch(&path); Some(bytes)
}
pub fn write_blob(sha: &str, bytes: &[u8]) -> io::Result<()> {
let Some(root) = root_or_migrate() else {
return Ok(());
};
atomic_write(&blob_path(&root, sha), bytes)
}
pub fn cached_branch(owner: &str, repo: &str) -> Option<String> {
let dir = root_or_migrate()?.join("index/refs").join(owner).join(repo);
let entry = std::fs::read_dir(dir).ok()?.next()?.ok()?;
entry.file_name().into_string().ok()
}
pub fn read_ref(owner: &str, repo: &str, branch: &str) -> Option<RefCache> {
let path = ref_path(&root_or_migrate()?, owner, repo, branch);
let text = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&text).ok()
}
pub fn write_ref(owner: &str, repo: &str, branch: &str, entry: &RefCache) -> io::Result<()> {
let Some(root) = root_or_migrate() else {
return Ok(());
};
atomic_write(
&ref_path(&root, owner, repo, branch),
serde_json::to_string(entry)?.as_bytes(),
)
}
pub fn read_tree(sha: &str) -> Option<TreeResponse> {
let path = tree_path(&root_or_migrate()?, sha);
let text = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&text).ok()
}
pub fn write_tree(tree: &TreeResponse) -> io::Result<()> {
let Some(root) = root_or_migrate() else {
return Ok(());
};
atomic_write(
&tree_path(&root, &tree.sha),
serde_json::to_string(tree)?.as_bytes(),
)
}
fn atomic_write(path: &PathBuf, bytes: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
pub fn harden(max_bytes: u64) {
let Some(root) = root_or_migrate() else {
return;
};
sweep_orphans(&root);
evict_blobs(&root, max_bytes);
}
fn touch(path: &PathBuf) {
let _ = filetime::set_file_mtime(path, filetime::FileTime::now());
}
fn walk_files(dir: &PathBuf, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk_files(&path, out);
} else {
out.push(path);
}
}
}
fn sweep_orphans(root: &Path) {
let index = root.join("index").join("refs");
let trees_dir = root.join("trees");
let blobs_dir = root.join("blobs");
let mut referenced_trees = std::collections::HashSet::new();
let mut ref_files = Vec::new();
walk_files(&index, &mut ref_files);
for file in ref_files {
if let Ok(text) = std::fs::read_to_string(&file)
&& let Ok(entry) = serde_json::from_str::<RefCache>(&text)
{
referenced_trees.insert(entry.tree_sha);
}
}
let mut live_trees = Vec::new();
let mut tree_files = Vec::new();
walk_files(&trees_dir, &mut tree_files);
for file in tree_files {
let sha = file
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
if referenced_trees.contains(&sha) {
live_trees.push(file);
} else {
let _ = std::fs::remove_file(&file);
}
}
let mut referenced_blobs = std::collections::HashSet::new();
for file in &live_trees {
if let Ok(text) = std::fs::read_to_string(file)
&& let Ok(tree) = serde_json::from_str::<TreeResponse>(&text)
{
referenced_blobs.extend(
tree.tree
.iter()
.filter(|e| e.kind == "blob")
.map(|e| e.sha.clone()),
);
}
}
let mut blob_files = Vec::new();
walk_files(&blobs_dir, &mut blob_files);
for file in blob_files {
let sha = format!(
"{}{}",
file.parent()
.and_then(|p| p.file_name())
.unwrap_or_default()
.to_string_lossy(),
file.file_name().unwrap_or_default().to_string_lossy()
);
if !referenced_blobs.contains(&sha) {
let _ = std::fs::remove_file(&file);
}
}
}
fn evict_blobs(root: &Path, max_bytes: u64) {
let blobs_dir = root.join("blobs");
let mut files = Vec::new();
walk_files(&blobs_dir, &mut files);
let mut entries: Vec<(PathBuf, u64, i64)> = files
.into_iter()
.filter_map(|f| {
let meta = std::fs::metadata(&f).ok()?;
Some((f, meta.len(), meta.mtime()))
})
.collect();
let mut total: u64 = entries.iter().map(|(_, size, _)| size).sum();
if total <= max_bytes {
return;
}
entries.sort_by_key(|(_, _, mtime)| *mtime);
for (path, size, _) in entries {
if total <= max_bytes {
break;
}
if std::fs::remove_file(&path).is_ok() {
total -= size;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_root(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("rootle-cache-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn put_blob(root: &Path, sha: &str, size: usize, mtime: i64) {
let path = blob_path(root, sha);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, vec![0u8; size]).unwrap();
filetime::set_file_mtime(&path, filetime::FileTime::from_unix_time(mtime, 0)).unwrap();
}
fn put_tree(root: &Path, sha: &str, blob_shas: &[&str]) {
let tree = TreeResponse {
sha: sha.into(),
truncated: false,
tree: blob_shas
.iter()
.map(|s| crate::github::types::TreeEntry {
path: format!("file-{}", &s[..4]),
kind: "blob".into(),
sha: s.to_string(),
size: Some(10),
})
.collect(),
};
let path = tree_path(root, sha);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, serde_json::to_string(&tree).unwrap()).unwrap();
}
fn put_ref(root: &Path, tree_sha: &str) {
let path = ref_path(root, "o", "r", "main");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let entry = RefCache {
tree_sha: tree_sha.into(),
etag: None,
};
std::fs::write(&path, serde_json::to_string(&entry).unwrap()).unwrap();
}
#[test]
fn eviction_removes_oldest_blobs_first() {
let root = temp_root("evict");
put_blob(&root, "aa1234", 100, 1000); put_blob(&root, "bb1234", 100, 2000);
put_blob(&root, "cc1234", 100, 3000); evict_blobs(&root, 250);
assert!(!blob_path(&root, "aa1234").exists(), "oldest evicted");
assert!(blob_path(&root, "bb1234").exists());
assert!(blob_path(&root, "cc1234").exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn sweep_removes_orphan_trees_and_blobs() {
let root = temp_root("sweep");
put_ref(&root, "livetree");
put_tree(&root, "livetree", &["bbbb11"]); put_tree(&root, "deadt1", &["cccc22"]); put_blob(&root, "bbbb11", 10, 1000); put_blob(&root, "dddd33", 10, 1000);
sweep_orphans(&root);
assert!(tree_path(&root, "livetree").exists());
assert!(!tree_path(&root, "deadt1").exists(), "orphan tree swept");
assert!(blob_path(&root, "bbbb11").exists(), "live blob kept");
assert!(!blob_path(&root, "dddd33").exists(), "orphan blob swept");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn tree_round_trip_by_sha() {
if root().is_none() {
return;
}
let tree = TreeResponse {
sha: "test-sha-rootle".into(),
truncated: false,
tree: vec![],
};
write_tree(&tree).unwrap();
let loaded = read_tree("test-sha-rootle").unwrap();
assert_eq!(loaded.sha, "test-sha-rootle");
}
#[test]
fn cached_branch_roundtrip() {
if root().is_none() {
return;
}
write_ref(
"rootle-test",
"cached-branch",
"main",
&RefCache {
tree_sha: "abc".into(),
etag: None,
},
)
.unwrap();
assert_eq!(
cached_branch("rootle-test", "cached-branch").as_deref(),
Some("main")
);
assert!(cached_branch("rootle-test", "never-opened").is_none());
if let Some(root) = root() {
let _ = std::fs::remove_dir_all(root.join("index/refs/rootle-test"));
}
}
}