fabro/lib/crates/arc-git-storage/src/gitobj.rs
Bryan Helmkamp 9e3b73dbe4 Move crates/ to lib/crates/ and update all references
Updated: Cargo.toml workspace members, CI workflow paths, Dockerfile
COPY, AGENTS.md, skill mapping, and doc references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:12:35 -04:00

540 lines
16 KiB
Rust

use std::collections::BTreeMap;
use std::path::Path;
use git2::{Oid, Repository, Signature};
use crate::{Error, Result};
/// Git file modes for tree entries.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileMode {
Blob,
BlobExecutable,
Tree,
}
impl FileMode {
fn as_i32(self) -> i32 {
match self {
FileMode::Blob => 0o100644,
FileMode::BlobExecutable => 0o100755,
FileMode::Tree => 0o040000,
}
}
fn from_i32(mode: i32) -> Self {
match mode {
0o100755 => FileMode::BlobExecutable,
0o040000 => FileMode::Tree,
_ => FileMode::Blob,
}
}
}
/// A single entry in a flat tree map.
#[derive(Debug, Clone)]
pub struct TreeEntry {
pub oid: Oid,
pub filemode: FileMode,
}
/// A flat, sorted map of paths to tree entries.
///
/// Intermediate representation between reading an existing git tree and writing a new one.
/// Paths use forward slashes and are relative to the tree root (e.g. `"src/main.rs"`).
#[derive(Debug, Clone, Default)]
pub struct TreeEntries(BTreeMap<String, TreeEntry>);
impl TreeEntries {
pub fn new() -> Self {
Self(BTreeMap::new())
}
pub fn set(&mut self, path: impl Into<String>, oid: Oid, filemode: FileMode) {
self.0.insert(path.into(), TreeEntry { oid, filemode });
}
pub fn remove(&mut self, path: &str) {
self.0.remove(path);
}
pub fn get(&self, path: &str) -> Option<&TreeEntry> {
self.0.get(path)
}
pub fn merge(&mut self, other: &TreeEntries) {
for (path, entry) in &other.0 {
self.0.insert(path.clone(), entry.clone());
}
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &TreeEntry)> {
self.0.iter().map(|(k, v)| (k.as_str(), v))
}
/// Iterate entries whose paths start with the given prefix.
pub fn under_prefix<'a>(
&'a self,
prefix: &'a str,
) -> impl Iterator<Item = (&'a str, &'a TreeEntry)> {
self.0
.range(prefix.to_string()..)
.take_while(move |(k, _)| k.starts_with(prefix))
.map(|(k, v)| (k.as_str(), v))
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
/// Wraps a `git2::Repository` with operations for creating blobs, trees, commits, and refs.
pub struct Store {
repo: Repository,
}
impl Store {
pub fn new(repo: Repository) -> Self {
Self { repo }
}
pub fn repo(&self) -> &Repository {
&self.repo
}
/// Store bytes as a git blob.
pub fn write_blob(&self, content: &[u8]) -> Result<Oid> {
Ok(self.repo.blob(content)?)
}
/// Read a file from disk, store as a blob.
/// Returns `(oid, filemode)` where filemode detects the executable bit on unix.
pub fn write_blob_from_file(&self, path: &Path) -> Result<(Oid, FileMode)> {
let content = std::fs::read(path).map_err(|e| Error::ReadFile {
path: path.to_path_buf(),
source: e,
})?;
let mode = detect_filemode(path);
let oid = self.repo.blob(&content)?;
Ok((oid, mode))
}
/// Recursively flatten a git tree into `TreeEntries`.
pub fn read_tree(&self, oid: Oid) -> Result<TreeEntries> {
let tree = self.repo.find_tree(oid)?;
let mut entries = TreeEntries::new();
read_tree_recursive(&self.repo, &tree, "", &mut entries)?;
Ok(entries)
}
/// Build nested git tree objects from flat `TreeEntries`.
pub fn write_tree(&self, entries: &TreeEntries) -> Result<Oid> {
let root = build_dir_node(entries);
write_dir_node(&self.repo, &root)
}
/// Write an empty tree (zero entries).
pub fn write_empty_tree(&self) -> Result<Oid> {
let builder = self.repo.treebuilder(None)?;
Ok(builder.write()?)
}
/// Create a commit. Does NOT update any ref — caller does that via `update_ref`.
/// `author` is used for both author and committer fields.
pub fn write_commit(
&self,
tree_oid: Oid,
parents: &[Oid],
message: &str,
author: &Signature<'_>,
) -> Result<Oid> {
let tree = self.repo.find_tree(tree_oid)?;
let parent_commits: Vec<git2::Commit<'_>> = parents
.iter()
.map(|oid| self.repo.find_commit(*oid))
.collect::<std::result::Result<Vec<_>, _>>()?;
let parent_refs: Vec<&git2::Commit<'_>> = parent_commits.iter().collect();
let oid = self
.repo
.commit(None, author, author, message, &tree, &parent_refs)?;
Ok(oid)
}
/// Set a branch ref to point at `commit_oid`. Creates branch if needed.
pub fn update_ref(&self, branch: &str, commit_oid: Oid) -> Result<()> {
let refname = format!("refs/heads/{branch}");
self.repo
.reference(&refname, commit_oid, true, "update ref")?;
Ok(())
}
/// Resolve branch to commit OID. Returns `None` if branch doesn't exist.
pub fn resolve_ref(&self, branch: &str) -> Result<Option<Oid>> {
let refname = format!("refs/heads/{branch}");
match self.repo.find_reference(&refname) {
Ok(reference) => Ok(Some(reference.peel_to_commit()?.id())),
Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
/// Delete a branch reference. No-op if branch doesn't exist.
pub fn delete_ref(&self, branch: &str) -> Result<()> {
let refname = format!("refs/heads/{branch}");
match self.repo.find_reference(&refname) {
Ok(mut reference) => {
reference.delete()?;
Ok(())
}
Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
}
/// Recursively read a git tree into flat `TreeEntries`.
fn read_tree_recursive(
repo: &Repository,
tree: &git2::Tree<'_>,
prefix: &str,
entries: &mut TreeEntries,
) -> Result<()> {
for entry in tree.iter() {
let name = entry.name().unwrap_or("");
let path = if prefix.is_empty() {
name.to_string()
} else {
format!("{prefix}/{name}")
};
let mode = FileMode::from_i32(entry.filemode());
if mode == FileMode::Tree {
let subtree = repo.find_tree(entry.id())?;
read_tree_recursive(repo, &subtree, &path, entries)?;
} else {
entries.set(path, entry.id(), mode);
}
}
Ok(())
}
/// Intermediate structure for building nested git trees from flat paths.
struct DirNode {
files: BTreeMap<String, TreeEntry>,
dirs: BTreeMap<String, DirNode>,
}
impl DirNode {
fn new() -> Self {
Self {
files: BTreeMap::new(),
dirs: BTreeMap::new(),
}
}
}
/// Build a `DirNode` tree from flat `TreeEntries`.
fn build_dir_node(entries: &TreeEntries) -> DirNode {
let mut root = DirNode::new();
for (path, entry) in entries.iter() {
let parts: Vec<&str> = path.split('/').collect();
insert_into_dir_node(&mut root, &parts, entry);
}
root
}
fn insert_into_dir_node(node: &mut DirNode, parts: &[&str], entry: &TreeEntry) {
match parts {
[name] => {
node.files.insert(name.to_string(), entry.clone());
}
[dir, rest @ ..] => {
let child = node
.dirs
.entry(dir.to_string())
.or_insert_with(DirNode::new);
insert_into_dir_node(child, rest, entry);
}
[] => {}
}
}
/// Recursively write a `DirNode` as nested git trees, bottom-up.
fn write_dir_node(repo: &Repository, node: &DirNode) -> Result<Oid> {
let mut builder = repo.treebuilder(None)?;
for (name, entry) in &node.files {
builder.insert(name, entry.oid, entry.filemode.as_i32())?;
}
for (name, child) in &node.dirs {
let child_oid = write_dir_node(repo, child)?;
builder.insert(name, child_oid, FileMode::Tree.as_i32())?;
}
Ok(builder.write()?)
}
/// Detect file mode (executable or not) from filesystem metadata.
#[cfg(unix)]
fn detect_filemode(path: &Path) -> FileMode {
use std::os::unix::fs::PermissionsExt;
match std::fs::metadata(path) {
Ok(meta) => {
if meta.permissions().mode() & 0o111 != 0 {
FileMode::BlobExecutable
} else {
FileMode::Blob
}
}
Err(_) => FileMode::Blob,
}
}
#[cfg(not(unix))]
fn detect_filemode(_path: &Path) -> FileMode {
FileMode::Blob
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_repo() -> (tempfile::TempDir, Store) {
let dir = tempfile::TempDir::new().unwrap();
let repo = Repository::init(dir.path()).unwrap();
(dir, Store::new(repo))
}
// -- TreeEntries tests (pure data, no git) --
#[test]
fn tree_entries_set_and_get() {
let mut entries = TreeEntries::new();
let oid = Oid::zero();
entries.set("src/main.rs", oid, FileMode::Blob);
let entry = entries.get("src/main.rs").unwrap();
assert_eq!(entry.oid, oid);
assert_eq!(entry.filemode, FileMode::Blob);
}
#[test]
fn tree_entries_remove() {
let mut entries = TreeEntries::new();
entries.set("a.txt", Oid::zero(), FileMode::Blob);
entries.set("b.txt", Oid::zero(), FileMode::Blob);
entries.remove("a.txt");
assert!(entries.get("a.txt").is_none());
assert!(entries.get("b.txt").is_some());
assert_eq!(entries.len(), 1);
}
#[test]
fn tree_entries_merge() {
let mut a = TreeEntries::new();
a.set("file1.txt", Oid::zero(), FileMode::Blob);
let mut b = TreeEntries::new();
b.set("file2.txt", Oid::zero(), FileMode::Blob);
a.merge(&b);
assert_eq!(a.len(), 2);
assert!(a.get("file1.txt").is_some());
assert!(a.get("file2.txt").is_some());
}
#[test]
fn tree_entries_under_prefix() {
let mut entries = TreeEntries::new();
entries.set("src/a.rs", Oid::zero(), FileMode::Blob);
entries.set("src/b.rs", Oid::zero(), FileMode::Blob);
entries.set("test/c.rs", Oid::zero(), FileMode::Blob);
let src: Vec<&str> = entries.under_prefix("src/").map(|(k, _)| k).collect();
assert_eq!(src, vec!["src/a.rs", "src/b.rs"]);
}
#[test]
fn tree_entries_empty() {
let entries = TreeEntries::new();
assert!(entries.is_empty());
assert_eq!(entries.len(), 0);
}
#[test]
fn tree_entries_iter_sorted() {
let mut entries = TreeEntries::new();
entries.set("z.txt", Oid::zero(), FileMode::Blob);
entries.set("a.txt", Oid::zero(), FileMode::Blob);
entries.set("m.txt", Oid::zero(), FileMode::Blob);
let keys: Vec<&str> = entries.iter().map(|(k, _)| k).collect();
assert_eq!(keys, vec!["a.txt", "m.txt", "z.txt"]);
}
// -- write_blob / read back --
#[test]
fn write_blob_and_read_back() {
let (_dir, store) = temp_repo();
let content = b"hello world";
let oid = store.write_blob(content).unwrap();
let blob = store.repo().find_blob(oid).unwrap();
assert_eq!(blob.content(), content);
}
// -- write_blob_from_file --
#[test]
fn write_blob_from_file_regular() {
let (_dir, store) = temp_repo();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), b"file content").unwrap();
let (oid, mode) = store.write_blob_from_file(tmp.path()).unwrap();
assert_eq!(mode, FileMode::Blob);
let blob = store.repo().find_blob(oid).unwrap();
assert_eq!(blob.content(), b"file content");
}
#[cfg(unix)]
#[test]
fn write_blob_from_file_executable() {
use std::os::unix::fs::PermissionsExt;
let (_dir, store) = temp_repo();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), b"#!/bin/sh").unwrap();
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
let (_oid, mode) = store.write_blob_from_file(tmp.path()).unwrap();
assert_eq!(mode, FileMode::BlobExecutable);
}
// -- write_empty_tree --
#[test]
fn write_empty_tree() {
let (_dir, store) = temp_repo();
let oid = store.write_empty_tree().unwrap();
let tree = store.repo().find_tree(oid).unwrap();
assert_eq!(tree.len(), 0);
}
// -- write_tree + read_tree roundtrip --
#[test]
fn write_and_read_tree_flat() {
let (_dir, store) = temp_repo();
let oid1 = store.write_blob(b"content a").unwrap();
let oid2 = store.write_blob(b"content b").unwrap();
let mut entries = TreeEntries::new();
entries.set("a.txt", oid1, FileMode::Blob);
entries.set("b.txt", oid2, FileMode::Blob);
let tree_oid = store.write_tree(&entries).unwrap();
let read_back = store.read_tree(tree_oid).unwrap();
assert_eq!(read_back.len(), 2);
assert_eq!(read_back.get("a.txt").unwrap().oid, oid1);
assert_eq!(read_back.get("b.txt").unwrap().oid, oid2);
}
#[test]
fn write_and_read_tree_nested() {
let (_dir, store) = temp_repo();
let oid1 = store.write_blob(b"main").unwrap();
let oid2 = store.write_blob(b"lib").unwrap();
let oid3 = store.write_blob(b"readme").unwrap();
let mut entries = TreeEntries::new();
entries.set("src/main.rs", oid1, FileMode::Blob);
entries.set("src/lib/mod.rs", oid2, FileMode::Blob);
entries.set("README.md", oid3, FileMode::Blob);
let tree_oid = store.write_tree(&entries).unwrap();
let read_back = store.read_tree(tree_oid).unwrap();
assert_eq!(read_back.len(), 3);
assert_eq!(read_back.get("src/main.rs").unwrap().oid, oid1);
assert_eq!(read_back.get("src/lib/mod.rs").unwrap().oid, oid2);
assert_eq!(read_back.get("README.md").unwrap().oid, oid3);
}
// -- write_commit --
#[test]
fn write_commit_orphan() {
let (_dir, store) = temp_repo();
let tree_oid = store.write_empty_tree().unwrap();
let sig = Signature::now("Test", "test@example.com").unwrap();
let commit_oid = store
.write_commit(tree_oid, &[], "initial commit", &sig)
.unwrap();
let commit = store.repo().find_commit(commit_oid).unwrap();
assert_eq!(commit.parent_count(), 0);
assert_eq!(commit.message(), Some("initial commit"));
}
#[test]
fn write_commit_with_parent() {
let (_dir, store) = temp_repo();
let tree_oid = store.write_empty_tree().unwrap();
let sig = Signature::now("Test", "test@example.com").unwrap();
let parent_oid = store
.write_commit(tree_oid, &[], "first commit", &sig)
.unwrap();
let child_oid = store
.write_commit(tree_oid, &[parent_oid], "second commit", &sig)
.unwrap();
let child = store.repo().find_commit(child_oid).unwrap();
assert_eq!(child.parent_count(), 1);
assert_eq!(child.parent_id(0).unwrap(), parent_oid);
}
// -- ref operations --
#[test]
fn update_and_resolve_ref() {
let (_dir, store) = temp_repo();
let tree_oid = store.write_empty_tree().unwrap();
let sig = Signature::now("Test", "test@example.com").unwrap();
let commit_oid = store.write_commit(tree_oid, &[], "initial", &sig).unwrap();
store.update_ref("test-branch", commit_oid).unwrap();
let resolved = store.resolve_ref("test-branch").unwrap();
assert_eq!(resolved, Some(commit_oid));
}
#[test]
fn resolve_ref_nonexistent() {
let (_dir, store) = temp_repo();
let resolved = store.resolve_ref("nonexistent").unwrap();
assert_eq!(resolved, None);
}
#[test]
fn delete_ref_existing() {
let (_dir, store) = temp_repo();
let tree_oid = store.write_empty_tree().unwrap();
let sig = Signature::now("Test", "test@example.com").unwrap();
let commit_oid = store.write_commit(tree_oid, &[], "initial", &sig).unwrap();
store.update_ref("to-delete", commit_oid).unwrap();
store.delete_ref("to-delete").unwrap();
assert_eq!(store.resolve_ref("to-delete").unwrap(), None);
}
#[test]
fn delete_ref_nonexistent_is_noop() {
let (_dir, store) = temp_repo();
store.delete_ref("nonexistent").unwrap();
}
}