use std::collections::{HashMap, HashSet};
use std::ops::ControlFlow;
use git2::{Oid, Repository, Tree};
use auths_id::ports::registry::RegistryError;
use auths_id::storage::registry::shard::path_parts;
fn from_git2(e: git2::Error) -> RegistryError {
match e.code() {
git2::ErrorCode::NotFound => RegistryError::NotFound {
entity_type: "git object".into(),
id: e.message().to_string(),
},
git2::ErrorCode::Locked => {
RegistryError::ConcurrentModification(format!("Git lock conflict: {}", e.message()))
}
_ => RegistryError::storage(e),
}
}
pub struct TreeNavigator<'a> {
repo: &'a Repository,
root: Tree<'a>,
}
impl<'a> TreeNavigator<'a> {
pub fn new(repo: &'a Repository, root: Tree<'a>) -> Self {
Self { repo, root }
}
fn get_entry_info(&self, path: &[&str]) -> Option<(Oid, git2::ObjectType)> {
if path.is_empty() {
return None;
}
let mut current_tree_oid = self.root.id();
for component in &path[..path.len() - 1] {
let tree = self.repo.find_tree(current_tree_oid).ok()?;
let entry = tree.get_name(component)?;
if entry.kind() != Some(git2::ObjectType::Tree) {
return None;
}
current_tree_oid = entry.id();
}
let tree = self.repo.find_tree(current_tree_oid).ok()?;
let last = path.last()?;
let entry = tree.get_name(last)?;
Some((entry.id(), entry.kind().unwrap_or(git2::ObjectType::Blob)))
}
pub fn read_blob(&self, path: &[&str]) -> Result<Vec<u8>, RegistryError> {
let (oid, kind) = self
.get_entry_info(path)
.ok_or_else(|| RegistryError::NotFound {
entity_type: "blob".into(),
id: path.join("/"),
})?;
if kind != git2::ObjectType::Blob {
return Err(RegistryError::NotFound {
entity_type: "blob".into(),
id: path.join("/"),
});
}
let blob = self.repo.find_blob(oid).map_err(from_git2)?;
Ok(blob.content().to_vec())
}
pub fn read_blob_path(&self, path: &str) -> Result<Vec<u8>, RegistryError> {
let parts = path_parts(path);
self.read_blob(&parts)
}
pub fn exists(&self, path: &[&str]) -> bool {
self.get_entry_info(path).is_some()
}
pub fn exists_path(&self, path: &str) -> bool {
let parts = path_parts(path);
self.exists(&parts)
}
pub fn visit_dir<F>(&self, path: &[&str], mut visitor: F) -> Result<(), RegistryError>
where
F: FnMut(&str) -> ControlFlow<()>,
{
let tree = if path.is_empty() {
self.root.clone()
} else {
let (oid, kind) = self
.get_entry_info(path)
.ok_or_else(|| RegistryError::NotFound {
entity_type: "directory".into(),
id: path.join("/"),
})?;
if kind != git2::ObjectType::Tree {
return Err(RegistryError::NotFound {
entity_type: "directory".into(),
id: path.join("/"),
});
}
self.repo.find_tree(oid).map_err(from_git2)?
};
for entry in tree.iter() {
if let Some(name) = entry.name()
&& visitor(name).is_break()
{
break;
}
}
Ok(())
}
}
pub struct TreeMutator {
pending_writes: HashMap<String, Vec<u8>>,
pending_deletes: HashSet<String>,
}
impl TreeMutator {
pub fn new() -> Self {
Self {
pending_writes: HashMap::new(),
pending_deletes: HashSet::new(),
}
}
pub fn write_blob(&mut self, path: &str, content: Vec<u8>) {
self.pending_deletes.remove(path);
self.pending_writes.insert(path.to_string(), content);
}
pub fn delete(&mut self, path: &str) {
self.pending_writes.remove(path);
self.pending_deletes.insert(path.to_string());
}
pub fn build_tree(&self, repo: &Repository, base: Option<&Tree>) -> Result<Oid, RegistryError> {
self.build_tree_recursive(repo, base, "")
}
fn build_tree_recursive(
&self,
repo: &Repository,
base: Option<&Tree>,
prefix: &str,
) -> Result<Oid, RegistryError> {
let mut children: HashMap<String, ChildEntry> = HashMap::new();
if let Some(tree) = base {
for entry in tree.iter() {
if let Some(name) = entry.name() {
children.insert(
name.to_string(),
ChildEntry {
oid: entry.id(),
kind: entry.kind().unwrap_or(git2::ObjectType::Blob),
},
);
}
}
}
let prefix_with_slash = if prefix.is_empty() {
String::new()
} else {
format!("{}/", prefix)
};
let mut affected_children: HashSet<String> = HashSet::new();
for (path, content) in &self.pending_writes {
if let Some(remainder) = path.strip_prefix(&prefix_with_slash) {
let parts: Vec<&str> = remainder.splitn(2, '/').collect();
let child_name = parts[0];
if parts.len() == 1 {
let blob_oid = repo.blob(content).map_err(from_git2)?;
children.insert(
child_name.to_string(),
ChildEntry {
oid: blob_oid,
kind: git2::ObjectType::Blob,
},
);
} else {
affected_children.insert(child_name.to_string());
}
} else if prefix.is_empty() && !path.contains('/') {
let blob_oid = repo.blob(content).map_err(from_git2)?;
children.insert(
path.clone(),
ChildEntry {
oid: blob_oid,
kind: git2::ObjectType::Blob,
},
);
} else if prefix.is_empty() {
let parts: Vec<&str> = path.splitn(2, '/').collect();
affected_children.insert(parts[0].to_string());
}
}
for path in &self.pending_deletes {
if let Some(remainder) = path.strip_prefix(&prefix_with_slash) {
let parts: Vec<&str> = remainder.splitn(2, '/').collect();
let child_name = parts[0];
if parts.len() == 1 {
children.remove(child_name);
} else {
affected_children.insert(child_name.to_string());
}
} else if prefix.is_empty() && !path.contains('/') {
children.remove(path);
} else if prefix.is_empty() {
let parts: Vec<&str> = path.splitn(2, '/').collect();
affected_children.insert(parts[0].to_string());
}
}
for child_name in affected_children {
let child_path = if prefix.is_empty() {
child_name.clone()
} else {
format!("{}/{}", prefix, child_name)
};
let child_base = base.and_then(|t| {
t.get_name(&child_name)
.filter(|e| e.kind() == Some(git2::ObjectType::Tree))
.and_then(|e| repo.find_tree(e.id()).ok())
});
let child_oid = self.build_tree_recursive(repo, child_base.as_ref(), &child_path)?;
let child_tree = repo.find_tree(child_oid).map_err(from_git2)?;
if !child_tree.is_empty() {
children.insert(
child_name,
ChildEntry {
oid: child_oid,
kind: git2::ObjectType::Tree,
},
);
} else {
children.remove(&child_name);
}
}
let mut builder = repo.treebuilder(None).map_err(from_git2)?;
for (name, entry) in &children {
let filemode = match entry.kind {
git2::ObjectType::Blob => 0o100644,
git2::ObjectType::Tree => 0o040000,
_ => 0o100644,
};
builder
.insert(name, entry.oid, filemode)
.map_err(from_git2)?;
}
builder.write().map_err(from_git2)
}
}
impl Default for TreeMutator {
fn default() -> Self {
Self::new()
}
}
struct ChildEntry {
oid: Oid,
kind: git2::ObjectType,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup_test_repo() -> (TempDir, Repository) {
let dir = TempDir::new().unwrap();
let repo = Repository::init(dir.path()).unwrap();
(dir, repo)
}
fn create_test_tree(repo: &Repository) -> Tree<'_> {
let hello_oid = repo.blob(b"hello").unwrap();
let world_oid = repo.blob(b"world").unwrap();
let mut foo_builder = repo.treebuilder(None).unwrap();
foo_builder.insert("bar.txt", hello_oid, 0o100644).unwrap();
let foo_oid = foo_builder.write().unwrap();
let mut root_builder = repo.treebuilder(None).unwrap();
root_builder.insert("foo", foo_oid, 0o040000).unwrap();
root_builder.insert("baz.txt", world_oid, 0o100644).unwrap();
let root_oid = root_builder.write().unwrap();
repo.find_tree(root_oid).unwrap()
}
#[test]
fn navigator_read_blob() {
let (_dir, repo) = setup_test_repo();
let tree = create_test_tree(&repo);
let nav = TreeNavigator::new(&repo, tree);
let content = nav.read_blob(&["baz.txt"]).unwrap();
assert_eq!(content, b"world");
let nested = nav.read_blob(&["foo", "bar.txt"]).unwrap();
assert_eq!(nested, b"hello");
}
#[test]
fn navigator_read_blob_path() {
let (_dir, repo) = setup_test_repo();
let tree = create_test_tree(&repo);
let nav = TreeNavigator::new(&repo, tree);
let content = nav.read_blob_path("foo/bar.txt").unwrap();
assert_eq!(content, b"hello");
}
#[test]
fn navigator_read_nonexistent() {
let (_dir, repo) = setup_test_repo();
let tree = create_test_tree(&repo);
let nav = TreeNavigator::new(&repo, tree);
let result = nav.read_blob(&["nonexistent.txt"]);
assert!(result.is_err());
}
#[test]
fn navigator_exists() {
let (_dir, repo) = setup_test_repo();
let tree = create_test_tree(&repo);
let nav = TreeNavigator::new(&repo, tree);
assert!(nav.exists(&["baz.txt"]));
assert!(nav.exists(&["foo", "bar.txt"]));
assert!(nav.exists(&["foo"]));
assert!(!nav.exists(&["nonexistent"]));
}
#[test]
fn navigator_visit_dir() {
let (_dir, repo) = setup_test_repo();
let tree = create_test_tree(&repo);
let nav = TreeNavigator::new(&repo, tree);
let mut entries = Vec::new();
nav.visit_dir(&[], |name| {
entries.push(name.to_string());
ControlFlow::Continue(())
})
.unwrap();
entries.sort();
assert_eq!(entries, vec!["baz.txt", "foo"]);
}
#[test]
fn navigator_visit_dir_nested() {
let (_dir, repo) = setup_test_repo();
let tree = create_test_tree(&repo);
let nav = TreeNavigator::new(&repo, tree);
let mut entries = Vec::new();
nav.visit_dir(&["foo"], |name| {
entries.push(name.to_string());
ControlFlow::Continue(())
})
.unwrap();
assert_eq!(entries, vec!["bar.txt"]);
}
#[test]
fn mutator_write_to_empty_tree() {
let (_dir, repo) = setup_test_repo();
let mut mutator = TreeMutator::new();
mutator.write_blob("test.txt", b"content".to_vec());
let oid = mutator.build_tree(&repo, None).unwrap();
let tree = repo.find_tree(oid).unwrap();
let nav = TreeNavigator::new(&repo, tree);
let content = nav.read_blob(&["test.txt"]).unwrap();
assert_eq!(content, b"content");
}
#[test]
fn mutator_write_nested() {
let (_dir, repo) = setup_test_repo();
let mut mutator = TreeMutator::new();
mutator.write_blob("a/b/c.txt", b"nested".to_vec());
let oid = mutator.build_tree(&repo, None).unwrap();
let tree = repo.find_tree(oid).unwrap();
let nav = TreeNavigator::new(&repo, tree);
let content = nav.read_blob(&["a", "b", "c.txt"]).unwrap();
assert_eq!(content, b"nested");
}
#[test]
fn mutator_preserves_existing() {
let (_dir, repo) = setup_test_repo();
let base = create_test_tree(&repo);
let mut mutator = TreeMutator::new();
mutator.write_blob("new.txt", b"new content".to_vec());
let oid = mutator.build_tree(&repo, Some(&base)).unwrap();
let tree = repo.find_tree(oid).unwrap();
let nav = TreeNavigator::new(&repo, tree);
let new_content = nav.read_blob(&["new.txt"]).unwrap();
assert_eq!(new_content, b"new content");
let old_content = nav.read_blob(&["baz.txt"]).unwrap();
assert_eq!(old_content, b"world");
let nested = nav.read_blob(&["foo", "bar.txt"]).unwrap();
assert_eq!(nested, b"hello");
}
#[test]
fn mutator_overwrites_existing() {
let (_dir, repo) = setup_test_repo();
let base = create_test_tree(&repo);
let mut mutator = TreeMutator::new();
mutator.write_blob("baz.txt", b"updated".to_vec());
let oid = mutator.build_tree(&repo, Some(&base)).unwrap();
let tree = repo.find_tree(oid).unwrap();
let nav = TreeNavigator::new(&repo, tree);
let content = nav.read_blob(&["baz.txt"]).unwrap();
assert_eq!(content, b"updated");
}
#[test]
fn mutator_delete() {
let (_dir, repo) = setup_test_repo();
let base = create_test_tree(&repo);
let mut mutator = TreeMutator::new();
mutator.delete("baz.txt");
let oid = mutator.build_tree(&repo, Some(&base)).unwrap();
let tree = repo.find_tree(oid).unwrap();
let nav = TreeNavigator::new(&repo, tree);
assert!(!nav.exists(&["baz.txt"]));
assert!(nav.exists(&["foo", "bar.txt"]));
}
#[test]
fn mutator_reuses_unchanged_subtrees() {
let (_dir, repo) = setup_test_repo();
let base = create_test_tree(&repo);
let foo_entry = base.get_name("foo").unwrap();
let original_foo_oid = foo_entry.id();
let mut mutator = TreeMutator::new();
mutator.write_blob("baz.txt", b"updated".to_vec());
let oid = mutator.build_tree(&repo, Some(&base)).unwrap();
let tree = repo.find_tree(oid).unwrap();
let new_foo_entry = tree.get_name("foo").unwrap();
assert_eq!(new_foo_entry.id(), original_foo_oid);
}
#[test]
fn mutator_no_mutations_returns_same_tree() {
let (_dir, repo) = setup_test_repo();
let base = create_test_tree(&repo);
let mutator = TreeMutator::new();
let oid = mutator.build_tree(&repo, Some(&base)).unwrap();
let tree = repo.find_tree(oid).unwrap();
let nav = TreeNavigator::new(&repo, tree);
assert!(nav.exists(&["foo", "bar.txt"]));
assert!(nav.exists(&["baz.txt"]));
}
}