Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
Abstract Git Traits
Draft fintohaps opened 16 days ago

This patch captures a set of abstract traits for Git operations. The idea is that these traits capture the basic capabilities that are required to interact with Radicle storage repositories.

For now, the traits are defined in radicle so that the internals can be refactored within the crate itself. Once they are proven to be stable and useful, then they can be placed in their own crate, radicle-git-repository.

22 files changed +1909 -4 a2de55cb e1867632
modified crates/radicle/src/git.rs
@@ -1,5 +1,6 @@
pub mod canonical;
pub mod raw;
+
pub mod repository;

use std::io;
use std::path::Path;
modified crates/radicle/src/git/raw.rs
@@ -6,7 +6,8 @@

// Re-exports that are only used within this crate.
pub(crate) use git2::{
-
    AutotagOption, Blob, FetchOptions, FetchPrune, Object, Revwalk, Sort, message_trailers_strs,
+
    AutotagOption, Blob, FetchOptions, FetchPrune, Object, Revwalk, Sort, TreeEntry,
+
    message_trailers_strs,
};

#[cfg(unix)]
@@ -19,8 +20,8 @@ pub(crate) use git2::RemoteCallbacks;
// Re-exports that are used by other crates in the workspace, including this crate.
pub use git2::{
    Branch, BranchType, Commit, Direction, Error, ErrorClass, ErrorCode, FileMode, ObjectType, Oid,
-
    Reference, Remote, Repository, RepositoryInitOptions, RepositoryOpenFlags, Signature, Time,
-
    Tree,
+
    Reference, References, Remote, Repository, RepositoryInitOptions, RepositoryOpenFlags,
+
    Signature, Time, Tree,
};

// Re-exports that are used by other crates in the workspace, but *not* this crate.
@@ -28,9 +29,11 @@ pub use git2::{
    AnnotatedCommit, Diff, DiffFindOptions, DiffOptions, DiffStats, MergeAnalysis, MergeOptions,
};

-
// Re-exports for `radicle-cli`.
pub mod build {
+
    // Re-exports for `radicle-cli`.
    pub use git2::build::CheckoutBuilder;
+

+
    pub(crate) use git2::build::TreeUpdateBuilder;
}

pub(crate) mod transport {
added crates/radicle/src/git/repository.rs
@@ -0,0 +1,26 @@
+
//! Git repository abstraction layer.
+
//!
+
//! Provides a library-agnostic interface for git repository operations,
+
//! separating concerns into:
+
//!
+
//! - [`ancestry`] – Git ancestry operations.
+
//! - [`types`] — Git domain types, i.e. Blob, Commit, TreeEntry, etc.
+
//! - [`object`] — The Git object store; providing read and write capabilities of Git objects.
+
//! - [`reference`] — The Git reference store; providing read and write capabilities of Git references.
+
//! - [`revwalk`] – Git commit graph walk operations, i.e. "revwalk".
+
//!
+
//! [`reference`]: self::reference
+

+
pub mod ancestry;
+
pub mod object;
+
pub mod reference;
+
pub mod revwalk;
+
pub mod types;
+

+
mod adapter;
+

+
pub use ancestry::{AheadBehind, Ancestry};
+
pub use object::{ObjectReader, ObjectWriter};
+
pub use reference::{RefReader, RefTarget, RefWriter, SymbolicRefTarget, SymbolicRefWriter};
+
pub use revwalk::{Revwalk, RevwalkPlan, SortOrder};
+
pub use types::{Blob, Commit, ObjectKind, TreeEntry};
added crates/radicle/src/git/repository/adapter.rs
@@ -0,0 +1,5 @@
+
//! Adapters for the traits defined in [`git::repository`].
+
//!
+
//! [`git::repository`]: crate::git::repository
+

+
mod git2;
added crates/radicle/src/git/repository/adapter/git2.rs
@@ -0,0 +1,44 @@
+
//! The [`Repository`] adapters for the various repository traits.
+
//!
+
//! [`Repository`]: crate::git::raw::Repository
+

+
use crate::git;
+
use crate::git::raw;
+

+
use crate::git::repository::ObjectKind;
+

+
mod ancestry;
+
mod object;
+
mod reference;
+
mod revwalk;
+

+
/// Helper trait to enable method chaining to return `None` when the error
+
/// matches [`ErrorCode::NotFound`].
+
///
+
/// [`ErrorCode::NotFound`]: crate::git::raw::ErrorCode::NotFound
+
trait NotFound<T> {
+
    fn or_is_not_found(self) -> Result<Option<T>, raw::Error>;
+
}
+

+
impl<T> NotFound<T> for Result<T, raw::Error> {
+
    fn or_is_not_found(self) -> Result<Option<T>, git::raw::Error> {
+
        self.map(|t| Ok(Some(t))).unwrap_or_else(|e| {
+
            if matches!(e.code(), raw::ErrorCode::NotFound) {
+
                Ok(None)
+
            } else {
+
                Err(e)
+
            }
+
        })
+
    }
+
}
+

+
/// Map a [`raw::ObjectType`] to an [`ObjectKind`].
+
fn object_kind(kind: raw::ObjectType) -> ObjectKind {
+
    match kind {
+
        raw::ObjectType::Blob => ObjectKind::Blob,
+
        raw::ObjectType::Tree => ObjectKind::Tree,
+
        raw::ObjectType::Commit => ObjectKind::Commit,
+
        raw::ObjectType::Tag => ObjectKind::Tag,
+
        raw::ObjectType::Any => unreachable!("git2 does not expose other object types"),
+
    }
+
}
added crates/radicle/src/git/repository/adapter/git2/ancestry.rs
@@ -0,0 +1,62 @@
+
use radicle_oid::Oid;
+

+
use crate::git::raw;
+
use crate::git::repository::ancestry;
+
use crate::git::repository::ancestry::{AheadBehind, Ancestry};
+

+
use super::NotFound as _;
+

+
impl Ancestry for raw::Repository {
+
    fn merge_base(&self, a: Oid, b: Oid) -> Result<Option<Oid>, ancestry::MergeBaseError> {
+
        let odb = self.odb().map_err(ancestry::MergeBaseError::backend)?;
+

+
        if !odb.exists(a.into()) {
+
            return Err(ancestry::MergeBaseError::CommitNotFound { oid: a });
+
        }
+

+
        if !odb.exists(b.into()) {
+
            return Err(ancestry::MergeBaseError::CommitNotFound { oid: b });
+
        }
+

+
        self.merge_base(a.into(), b.into())
+
            .map(Oid::from)
+
            .or_is_not_found()
+
            .map_err(ancestry::MergeBaseError::backend)
+
    }
+

+
    fn is_ancestor(&self, ancestor: Oid, head: Oid) -> Result<bool, ancestry::IsAncestorError> {
+
        let odb = self.odb().map_err(ancestry::IsAncestorError::backend)?;
+

+
        if !odb.exists(ancestor.into()) {
+
            return Err(ancestry::IsAncestorError::CommitNotFound { oid: ancestor });
+
        }
+

+
        if !odb.exists(head.into()) {
+
            return Err(ancestry::IsAncestorError::CommitNotFound { oid: head });
+
        }
+

+
        self.graph_descendant_of(head.into(), ancestor.into())
+
            .map_err(ancestry::IsAncestorError::backend)
+
    }
+

+
    fn ahead_behind(
+
        &self,
+
        commit: Oid,
+
        upstream: Oid,
+
    ) -> Result<AheadBehind, ancestry::AheadBehindError> {
+
        let odb = self.odb().map_err(ancestry::AheadBehindError::backend)?;
+

+
        if !odb.exists(commit.into()) {
+
            return Err(ancestry::AheadBehindError::CommitNotFound { oid: commit });
+
        }
+

+
        if !odb.exists(upstream.into()) {
+
            return Err(ancestry::AheadBehindError::CommitNotFound { oid: upstream });
+
        }
+

+
        let (ahead, behind) = self
+
            .graph_ahead_behind(commit.into(), upstream.into())
+
            .map_err(ancestry::AheadBehindError::backend)?;
+
        Ok(AheadBehind { ahead, behind })
+
    }
+
}
added crates/radicle/src/git/repository/adapter/git2/object.rs
@@ -0,0 +1,174 @@
+
use std::path::Path;
+

+
use radicle_oid::Oid;
+

+
use crate::git;
+
use crate::git::raw;
+
use crate::git::repository::object::error::{read, write};
+
use crate::git::repository::object::{ObjectReader, ObjectWriter};
+
use crate::git::repository::types::{Blob, Commit, ObjectKind, TreeEntry};
+

+
use super::NotFound as _;
+
use super::object_kind;
+

+
impl ObjectReader for raw::Repository {
+
    fn blob(&self, oid: Oid) -> Result<Option<Blob>, read::BlobError> {
+
        self.find_blob(oid.into())
+
            .map(|blob| Blob {
+
                oid,
+
                content: blob.content().to_vec(),
+
            })
+
            .or_is_not_found()
+
            .map_err(read::BlobError::backend)
+
    }
+

+
    fn blob_at<P: AsRef<Path>>(
+
        &self,
+
        oid: Oid,
+
        path: &P,
+
    ) -> Result<Option<Blob>, read::BlobAtError> {
+
        let path = path.as_ref();
+
        let commit = find_commit(self, oid, &path)?;
+
        let tree = commit.tree().map_err(|e| read::BlobAtError::Tree {
+
            commit: oid,
+
            source: Box::new(e),
+
        })?;
+
        let entry =
+
            tree.get_path(path)
+
                .or_is_not_found()
+
                .map_err(|e| read::BlobAtError::TreeEntry {
+
                    commit: oid,
+
                    path: path.to_path_buf(),
+
                    source: Box::new(e),
+
                })?;
+
        entry
+
            .map(|entry| try_entry_to_blob(self, oid, path, entry))
+
            .transpose()
+
    }
+

+
    fn commit(&self, oid: Oid) -> Result<Option<Commit>, read::CommitError> {
+
        let odb = self.odb().map_err(read::CommitError::backend)?;
+
        let commit = odb
+
            .read(oid.into())
+
            .or_is_not_found()
+
            .map_err(read::CommitError::backend)?;
+
        commit
+
            .map(|obj| {
+
                Commit::from_bytes(obj.data())
+
                    .map_err(|e| read::CommitError::Parse { oid, source: e })
+
            })
+
            .transpose()
+
    }
+

+
    fn exists(&self, oid: Oid) -> Result<bool, read::ExistsError> {
+
        self.odb()
+
            .map(|odb| odb.exists(oid.into()))
+
            .map_err(read::ExistsError::backend)
+
    }
+
}
+

+
impl ObjectWriter for raw::Repository {
+
    fn write_blob(&self, content: &[u8]) -> Result<Oid, write::BlobError> {
+
        self.blob(content)
+
            .map(Oid::from)
+
            .map_err(write::BlobError::backend)
+
    }
+

+
    fn write_tree(&self, entries: &[TreeEntry]) -> Result<Oid, write::TreeError> {
+
        let empty_tree = empty_tree(self)?;
+
        let mut builder = raw::build::TreeUpdateBuilder::new();
+
        let odb = self.odb().map_err(write::TreeError::backend)?;
+
        for entry in entries {
+
            match entry {
+
                TreeEntry::Blob { path, content } => {
+
                    let oid = self
+
                        .blob(content)
+
                        .map_err(|e| write::TreeError::WriteBlob {
+
                            path: path.to_path_buf(),
+
                            source: Box::new(e),
+
                        })?;
+
                    builder.upsert(path, oid, raw::FileMode::Blob);
+
                }
+
                TreeEntry::BlobRef { path, oid } => {
+
                    if !odb.exists(oid.into()) {
+
                        return Err(write::TreeError::MissingBlob { oid: *oid });
+
                    }
+
                    builder.upsert(path, (*oid).into(), raw::FileMode::Blob);
+
                }
+
            }
+
        }
+

+
        builder
+
            .create_updated(self, &empty_tree)
+
            .map(Oid::from)
+
            .map_err(write::TreeError::backend)
+
    }
+

+
    fn write_commit(&self, bytes: &[u8]) -> Result<Oid, write::CommitError> {
+
        let odb = self.odb().map_err(write::CommitError::backend)?;
+
        odb.write(raw::ObjectType::Commit, bytes)
+
            .map(Oid::from)
+
            .map_err(write::CommitError::backend)
+
    }
+
}
+

+
/// Get or create the empty tree for use as a baseline.
+
fn empty_tree(repo: &raw::Repository) -> Result<git::raw::Tree<'_>, write::TreeError> {
+
    let empty_oid = repo
+
        .treebuilder(None)
+
        .map_err(write::TreeError::backend)?
+
        .write()
+
        .map_err(write::TreeError::backend)?;
+
    repo.find_tree(empty_oid).map_err(write::TreeError::backend)
+
}
+

+
fn find_commit<'a, P: AsRef<Path>>(
+
    repository: &'a git::raw::Repository,
+
    commit: Oid,
+
    path: &P,
+
) -> Result<git::raw::Commit<'a>, read::BlobAtError> {
+
    match repository.find_commit(commit.into()) {
+
        Ok(c) => Ok(c),
+
        Err(e) if matches!(e.code(), git::raw::ErrorCode::NotFound) => {
+
            Err(read::BlobAtError::CommitNotFound {
+
                commit,
+
                path: path.as_ref().to_path_buf(),
+
            })
+
        }
+
        Err(e) => Err(read::BlobAtError::backend(e)),
+
    }
+
}
+

+
fn try_object_to_blob(obj: git::raw::Object) -> Result<Blob, read::BlobAtError> {
+
    let blob = obj.into_blob().map_err(|obj| {
+
        let actual = obj
+
            .kind()
+
            .map(|k| object_kind(k).to_string())
+
            .unwrap_or_else(|| "unknown".to_string());
+
        read::BlobAtError::TypeMismatch {
+
            oid: Oid::from(obj.id()),
+
            expected: ObjectKind::Blob,
+
            actual,
+
        }
+
    })?;
+
    Ok(Blob {
+
        oid: Oid::from(blob.id()),
+
        content: blob.content().to_vec(),
+
    })
+
}
+

+
fn try_entry_to_blob(
+
    repository: &git::raw::Repository,
+
    oid: Oid,
+
    path: &Path,
+
    entry: raw::TreeEntry<'_>,
+
) -> Result<Blob, read::BlobAtError> {
+
    let obj = entry
+
        .to_object(repository)
+
        .map_err(|e| read::BlobAtError::Object {
+
            commit: oid,
+
            path: path.to_path_buf(),
+
            source: Box::new(e),
+
        })?;
+
    try_object_to_blob(obj)
+
}
added crates/radicle/src/git/repository/adapter/git2/reference.rs
@@ -0,0 +1,297 @@
+
use radicle_git_ref_format::{Qualified, RefStr, refspec};
+
use radicle_oid::Oid;
+

+
use crate::git;
+
use crate::git::raw;
+
use crate::git::repository::reference::error::{read, write};
+

+
use crate::git::repository::reference::{
+
    RefReader, RefTarget, RefWriter, SymbolicRefTarget, SymbolicRefWriter,
+
};
+

+
use super::NotFound as _;
+

+
/// Iterator adapter for [`RefReader::list_refs`].
+
pub struct References<'a> {
+
    inner: git::raw::References<'a>,
+
}
+

+
impl Iterator for References<'_> {
+
    type Item = Result<(Qualified<'static>, Oid), read::ListReferenceError>;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        loop {
+
            let r = match self.inner.next()? {
+
                Ok(r) => r,
+
                Err(e) => {
+
                    return Some(Err(read::ListReferenceError::backend(e)));
+
                }
+
            };
+

+
            let name = match r.name() {
+
                Some(n) => n,
+
                None => continue,
+
            };
+

+
            let refstr = match RefStr::try_from_str(name) {
+
                Ok(r) => r,
+
                Err(e) => {
+
                    return Some(Err(read::ListReferenceError::Parse {
+
                        name: name.to_string(),
+
                        source: e,
+
                    }));
+
                }
+
            };
+

+
            let qualified = match Qualified::from_refstr(refstr) {
+
                Some(q) => q.to_owned(),
+
                None => continue,
+
            };
+

+
            let oid = match r.resolve().map(|r| r.target()) {
+
                Ok(Some(oid)) => Oid::from(oid),
+
                Ok(None) => continue,
+
                Err(e) => {
+
                    return Some(Err(read::ListReferenceError::Peel {
+
                        name: qualified,
+
                        source: Box::new(e),
+
                    }));
+
                }
+
            };
+

+
            return Some(Ok((qualified, oid)));
+
        }
+
    }
+
}
+

+
impl RefReader for raw::Repository {
+
    type References<'a> = References<'a>;
+

+
    fn ref_target<R: AsRef<RefStr>>(&self, name: &R) -> Result<Option<Oid>, read::RefTargetError> {
+
        self.refname_to_id(name.as_ref().as_str())
+
            .map(Oid::from)
+
            .or_is_not_found()
+
            .map_err(read::RefTargetError::backend)
+
    }
+

+
    fn list_refs<'a, P>(&'a self, pattern: &P) -> Result<Self::References<'a>, read::ListRefsError>
+
    where
+
        P: AsRef<refspec::PatternStr>,
+
    {
+
        let inner = self
+
            .references_glob(pattern.as_ref().as_str())
+
            .map_err(read::ListRefsError::backend)?;
+
        Ok(References { inner })
+
    }
+
}
+

+
impl RefWriter for raw::Repository {
+
    fn write_ref<R>(
+
        &self,
+
        name: &R,
+
        target: RefTarget,
+
        reflog: &str,
+
    ) -> Result<(), write::WriteRefError>
+
    where
+
        R: AsRef<RefStr>,
+
    {
+
        let name = name.as_ref();
+

+
        // Verify the target object exists.
+
        {
+
            let odb = self.odb().map_err(write::WriteRefError::backend)?;
+
            let target_oid = target.target();
+
            if !odb.exists(target_oid.into()) {
+
                return Err(write::WriteRefError::MissingTarget {
+
                    name: name.to_string(),
+
                    target: target_oid,
+
                });
+
            }
+
        }
+

+
        match target {
+
            RefTarget::Create { target } => {
+
                create_reference(self, reflog, name, target)?;
+
            }
+
            RefTarget::Upsert { target } => {
+
                upsert_reference(self, reflog, name, target)?;
+
            }
+
            RefTarget::Cas { target, expected } => {
+
                cas_reference(self, reflog, name, target, expected)?;
+
            }
+
        }
+

+
        Ok(())
+
    }
+

+
    fn delete_ref<R>(&self, name: &R) -> Result<(), write::DeleteRefError>
+
    where
+
        R: AsRef<RefStr>,
+
    {
+
        match self.find_reference(name.as_ref().as_str()) {
+
            Ok(mut r) => r.delete().map_err(write::DeleteRefError::backend),
+
            Err(e) if matches!(e.code(), git::raw::ErrorCode::NotFound) => Ok(()),
+
            Err(e) => Err(write::DeleteRefError::backend(e)),
+
        }
+
    }
+
}
+

+
fn create_reference(
+
    repository: &git::raw::Repository,
+
    reflog: &str,
+
    name: &RefStr,
+
    target: Oid,
+
) -> Result<(), write::WriteRefError> {
+
    repository
+
        .reference(name, target.into(), false, reflog)
+
        .map_err(|e| {
+
            if matches!(e.code(), raw::ErrorCode::Exists) {
+
                write::WriteRefError::ReferenceExists {
+
                    name: name.to_string(),
+
                }
+
            } else {
+
                write::WriteRefError::backend(e)
+
            }
+
        })?;
+
    Ok(())
+
}
+

+
fn upsert_reference(
+
    repository: &git::raw::Repository,
+
    reflog: &str,
+
    name: &RefStr,
+
    target: Oid,
+
) -> Result<(), write::WriteRefError> {
+
    repository
+
        .reference(name, target.into(), true, reflog)
+
        .map_err(write::WriteRefError::backend)?;
+
    Ok(())
+
}
+

+
fn cas_reference(
+
    repository: &git::raw::Repository,
+
    reflog: &str,
+
    name: &RefStr,
+
    target: Oid,
+
    expected: Oid,
+
) -> Result<(), write::WriteRefError> {
+
    // CAS requires `force=true` so that libgit2 skips the existence
+
    // check in `reference_path_available` and instead compares the
+
    // current value via `cmp_old_ref`.  With `force=false`, an existing
+
    // reference would always fail with `GIT_EEXISTS` before the old
+
    // value is ever compared.
+
    repository
+
        .reference_matching(name, target.into(), true, expected.into(), reflog)
+
        .map_err(|e| {
+
            if matches!(e.code(), raw::ErrorCode::Modified) {
+
                write::WriteRefError::CasFailed {
+
                    name: name.to_string(),
+
                    expected,
+
                }
+
            } else {
+
                write::WriteRefError::backend(e)
+
            }
+
        })?;
+
    Ok(())
+
}
+

+
impl SymbolicRefWriter for raw::Repository {
+
    fn write_symbolic_ref<R>(
+
        &self,
+
        name: &R,
+
        target: SymbolicRefTarget,
+
        reflog: &str,
+
    ) -> Result<(), write::SymbolicWriteError>
+
    where
+
        R: AsRef<RefStr>,
+
    {
+
        let name = name.as_ref();
+

+
        // Ensure the target reference exists.
+
        {
+
            let target = target.target();
+
            match self.find_reference(target) {
+
                Ok(_) => {}
+
                Err(e) if matches!(e.code(), git::raw::ErrorCode::NotFound) => {
+
                    return Err(write::SymbolicWriteError::MissingTarget {
+
                        name: name.to_ref_string(),
+
                        target: target.to_owned(),
+
                    });
+
                }
+
                Err(e) => {
+
                    return Err(write::SymbolicWriteError::backend(e));
+
                }
+
            }
+
        }
+

+
        match target {
+
            SymbolicRefTarget::Create { target } => {
+
                create_symbolic_reference(self, reflog, name, &target)?;
+
            }
+
            SymbolicRefTarget::Upsert { target } => {
+
                upsert_symbolic_reference(self, reflog, name, &target)?;
+
            }
+
            SymbolicRefTarget::Cas { target, expected } => {
+
                cas_symbolic_reference(self, reflog, name, &target, &expected)?;
+
            }
+
        }
+

+
        Ok(())
+
    }
+
}
+

+
fn create_symbolic_reference(
+
    repository: &git::raw::Repository,
+
    reflog: &str,
+
    name: &RefStr,
+
    target: &RefStr,
+
) -> Result<(), write::SymbolicWriteError> {
+
    repository
+
        .reference_symbolic(name, target, false, reflog)
+
        .map_err(|e| {
+
            if matches!(e.code(), raw::ErrorCode::Exists) {
+
                write::SymbolicWriteError::ReferenceExists {
+
                    name: name.to_ref_string(),
+
                    target: target.to_ref_string(),
+
                }
+
            } else {
+
                write::SymbolicWriteError::backend(e)
+
            }
+
        })?;
+
    Ok(())
+
}
+

+
fn upsert_symbolic_reference(
+
    repository: &git::raw::Repository,
+
    reflog: &str,
+
    name: &RefStr,
+
    target: &RefStr,
+
) -> Result<(), write::SymbolicWriteError> {
+
    repository
+
        .reference_symbolic(name, target, true, reflog)
+
        .map_err(write::SymbolicWriteError::backend)?;
+
    Ok(())
+
}
+

+
fn cas_symbolic_reference(
+
    repository: &git::raw::Repository,
+
    reflog: &str,
+
    name: &RefStr,
+
    target: &RefStr,
+
    expected: &RefStr,
+
) -> Result<(), write::SymbolicWriteError> {
+
    // See `cas_reference` for why `force=true` is required for CAS.
+
    repository
+
        .reference_symbolic_matching(name, target, true, expected, reflog)
+
        .map_err(|e| {
+
            if matches!(e.code(), raw::ErrorCode::Modified) {
+
                write::SymbolicWriteError::CasFailed {
+
                    name: name.to_ref_string(),
+
                    expected: expected.to_ref_string(),
+
                }
+
            } else {
+
                write::SymbolicWriteError::backend(e)
+
            }
+
        })?;
+
    Ok(())
+
}
added crates/radicle/src/git/repository/adapter/git2/revwalk.rs
@@ -0,0 +1,121 @@
+
use radicle_oid::Oid;
+

+
use crate::git;
+
use crate::git::raw;
+

+
use crate::git::repository::revwalk;
+
use crate::git::repository::revwalk::{Revwalk, RevwalkPlan, SortOrder};
+
use crate::git::repository::types::Commit;
+

+
/// [`Revwalk::RevwalkOids`] iterator using [`raw::Revwalk`].
+
pub struct RevwalkOids<'a> {
+
    inner: raw::Revwalk<'a>,
+
}
+

+
impl<'a> RevwalkOids<'a> {
+
    pub fn hide(&mut self, oid: Oid) -> Result<(), revwalk::OidError> {
+
        self.inner
+
            .hide(oid.into())
+
            .map_err(revwalk::OidError::backend)
+
    }
+
}
+

+
impl Iterator for RevwalkOids<'_> {
+
    type Item = Result<Oid, revwalk::OidError>;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        self.inner
+
            .next()
+
            .map(|r| r.map(Oid::from).map_err(revwalk::OidError::backend))
+
    }
+
}
+

+
/// [`Revwalk::RevwalkCommits`] iterator using [`raw::Revwalk`].
+
pub struct RevwalkCommits<'a> {
+
    oids: RevwalkOids<'a>,
+
    backend: &'a raw::Repository,
+
}
+

+
impl<'a> RevwalkCommits<'a> {
+
    pub fn hide(&mut self, oid: Oid) -> Result<(), revwalk::OidError> {
+
        self.oids.hide(oid)
+
    }
+

+
    fn read(backend: &raw::Repository, oid: Oid) -> Result<Commit, revwalk::CommitError> {
+
        let odb = backend.odb().map_err(revwalk::CommitError::backend)?;
+
        let obj = odb
+
            .read(oid.into())
+
            .map_err(revwalk::CommitError::backend)?;
+
        Commit::from_bytes(obj.data()).map_err(|e| revwalk::CommitError::Parse { oid, source: e })
+
    }
+
}
+

+
impl Iterator for RevwalkCommits<'_> {
+
    type Item = Result<Commit, revwalk::CommitError>;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        let oid = match self.oids.next()? {
+
            Ok(oid) => oid,
+
            Err(e) => return Some(Err(revwalk::CommitError::backend(e))),
+
        };
+
        Some(Self::read(self.backend, oid))
+
    }
+
}
+

+
/// Configure a [`raw::Revwalk`] from a [`RevwalkPlan`].
+
fn configure_revwalk(
+
    walk: &mut raw::Revwalk<'_>,
+
    plan: &RevwalkPlan,
+
) -> Result<(), revwalk::InitError> {
+
    let sort = match plan.sort_order() {
+
        SortOrder::Chronological => git::raw::Sort::TIME,
+
        SortOrder::Topological => git::raw::Sort::TOPOLOGICAL,
+
        SortOrder::Reverse => git::raw::Sort::REVERSE,
+
        SortOrder::TopologicalReverse => git::raw::Sort::TOPOLOGICAL | git::raw::Sort::REVERSE,
+
    };
+
    walk.set_sorting(sort)
+
        .map_err(revwalk::InitError::backend)?;
+

+
    if let Some((from, to)) = plan.range_bounds() {
+
        walk.push_range(&format!("{from}..{to}"))
+
            .map_err(revwalk::InitError::backend)?;
+
    }
+

+
    for oid in plan.starts() {
+
        walk.push((*oid).into())
+
            .map_err(revwalk::InitError::backend)?;
+
    }
+

+
    for oid in plan.hidden() {
+
        walk.hide((*oid).into())
+
            .map_err(revwalk::InitError::backend)?;
+
    }
+

+
    Ok(())
+
}
+

+
impl Revwalk for raw::Repository {
+
    type RevwalkOids<'a> = RevwalkOids<'a>;
+
    type RevwalkCommits<'a> = RevwalkCommits<'a>;
+

+
    fn revwalk_oids<'a>(
+
        &'a self,
+
        plan: &RevwalkPlan,
+
    ) -> Result<Self::RevwalkOids<'a>, revwalk::InitError> {
+
        let mut walk = self.revwalk().map_err(revwalk::InitError::backend)?;
+
        configure_revwalk(&mut walk, plan)?;
+
        Ok(RevwalkOids { inner: walk })
+
    }
+

+
    fn revwalk_commits<'a>(
+
        &'a self,
+
        plan: &RevwalkPlan,
+
    ) -> Result<Self::RevwalkCommits<'a>, revwalk::InitError> {
+
        let mut walk = self.revwalk().map_err(revwalk::InitError::backend)?;
+
        configure_revwalk(&mut walk, plan)?;
+
        Ok(RevwalkCommits {
+
            oids: RevwalkOids { inner: walk },
+
            backend: self,
+
        })
+
    }
+
}
added crates/radicle/src/git/repository/ancestry.rs
@@ -0,0 +1,56 @@
+
//! Git commit graph ancestry trait.
+
//!
+
//! [`Ancestry`] provides merge-base, ancestor checks, and ahead/behind counts.
+

+
pub mod error;
+
pub use error::{AheadBehindError, IsAncestorError, MergeBaseError};
+

+
use radicle_oid::Oid;
+

+
/// The result of [`Ancestry::ahead_behind`].
+
pub struct AheadBehind {
+
    /// The given commit was ahead of the upstream by this many commits.
+
    pub ahead: usize,
+
    /// The given commit was behind the upstream by this many commits.
+
    pub behind: usize,
+
}
+

+
/// Git commit graph operations.
+
///
+
/// Provides merge-base computation and ancestor checks.
+
pub trait Ancestry {
+
    /// Find the merge base (common ancestor) of two commits.
+
    ///
+
    /// Returns `Ok(None)` if there is no common ancestor.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`CommitNotFound`]: One of the commits was not found.
+
    /// - [`Backend`]: An unexpected error from the underlying git library.
+
    ///
+
    /// [`CommitNotFound`]: MergeBaseError::CommitNotFound
+
    /// [`Backend`]: MergeBaseError::Backend
+
    fn merge_base(&self, a: Oid, b: Oid) -> Result<Option<Oid>, MergeBaseError>;
+

+
    /// Check whether `ancestor` is an ancestor of `head`.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`CommitNotFound`]: One of the commits was not found.
+
    /// - [`Backend`]: An unexpected error from the underlying git library.
+
    ///
+
    /// [`CommitNotFound`]: IsAncestorError::CommitNotFound
+
    /// [`Backend`]: IsAncestorError::Backend
+
    fn is_ancestor(&self, ancestor: Oid, head: Oid) -> Result<bool, IsAncestorError>;
+

+
    /// Count how many commits `commit` is ahead of and behind `upstream`.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`CommitNotFound`]: One of the commits was not found.
+
    /// - [`Backend`]: An unexpected error from the underlying git library.
+
    ///
+
    /// [`CommitNotFound`]: AheadBehindError::CommitNotFound
+
    /// [`Backend`]: AheadBehindError::Backend
+
    fn ahead_behind(&self, commit: Oid, upstream: Oid) -> Result<AheadBehind, AheadBehindError>;
+
}
added crates/radicle/src/git/repository/ancestry/error.rs
@@ -0,0 +1,75 @@
+
//! Errors returned by [`Ancestry`] methods.
+
//!
+
//! [`Ancestry`]: super::Ancestry
+

+
use radicle_oid::Oid;
+
use thiserror::Error;
+

+
/// Error returned by [`Ancestry::merge_base`].
+
///
+
/// [`Ancestry::merge_base`]: super::Ancestry::merge_base
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum MergeBaseError {
+
    /// One of the commits could not be found
+
    #[error("failed to find commit '{oid}' during merge base calculation")]
+
    CommitNotFound { oid: Oid },
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl MergeBaseError {
+
    pub fn backend<E>(err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self::Backend(Box::new(err))
+
    }
+
}
+

+
/// Error returned by [`Ancestry::is_ancestor`].
+
///
+
/// [`Ancestry::is_ancestor`]: super::Ancestry::is_ancestor
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum IsAncestorError {
+
    /// One of the commits could not be found.
+
    #[error("failed to find commit '{oid}'")]
+
    CommitNotFound { oid: Oid },
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl IsAncestorError {
+
    pub fn backend<E>(err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self::Backend(Box::new(err))
+
    }
+
}
+

+
/// Error returned by [`Ancestry::ahead_behind`].
+
///
+
/// [`Ancestry::ahead_behind`]: super::Ancestry::ahead_behind
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum AheadBehindError {
+
    /// One of the commits was not found.
+
    #[error("commit '{oid}' was not found")]
+
    CommitNotFound { oid: Oid },
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl AheadBehindError {
+
    pub fn backend<E>(err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self::Backend(Box::new(err))
+
    }
+
}
added crates/radicle/src/git/repository/object.rs
@@ -0,0 +1,177 @@
+
//! Git object database abstraction.
+
//!
+
//! The module provides two traits:
+
//! - [`ObjectReader`] for reading objects, and
+
//! - [`ObjectWriter`] for writing objects
+

+
pub mod error;
+

+
use std::path::Path;
+

+
use radicle_oid::Oid;
+

+
use super::types::{Blob, Commit, TreeEntry};
+

+
/// A handle for reading Git objects from the Git object database.
+
pub trait ObjectReader {
+
    /// Find a blob by its [`Oid`].
+
    ///
+
    /// Returns `None` if the blob does not exist.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`Backend`]: An unexpected error from the underlying git library.
+
    ///
+
    /// [`Backend`]: error::read::BlobError::Backend
+
    fn blob(&self, oid: Oid) -> Result<Option<Blob>, error::read::BlobError>;
+

+
    /// Find a blob by its [`Oid`], returning an error if it does not exist.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`NotFound`]: The blob identified by `oid` does not exist.
+
    /// - [`Backend`]: An unexpected error from the underlying git library.
+
    ///
+
    /// [`NotFound`]: error::read::BlobError::NotFound
+
    /// [`Backend`]: error::read::BlobError::Backend
+
    fn try_blob(&self, oid: Oid) -> Result<Blob, error::read::BlobError> {
+
        self.blob(oid)?
+
            .ok_or(error::read::BlobError::NotFound { oid })
+
    }
+

+
    /// Find a blob at a `path` within a commit's tree.
+
    ///
+
    /// Returns `None` if the path does not exist in the commit's tree.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`CommitNotFound`]: The commit identified by `commit` does not exist.
+
    /// - [`Tree`]: Failed to get the commit's tree.
+
    /// - [`TreeEntry`]: Failed to look up the entry at `path` in the tree.
+
    /// - [`Object`]: The entry was found but failed to resolve to an object.
+
    /// - [`TypeMismatch`]: The resolved object is not a blob.
+
    /// - [`Backend`]: An unexpected error from the underlying git library.
+
    ///
+
    /// [`CommitNotFound`]: error::read::BlobAtError::CommitNotFound
+
    /// [`Tree`]: error::read::BlobAtError::Tree
+
    /// [`TreeEntry`]: error::read::BlobAtError::TreeEntry
+
    /// [`Object`]: error::read::BlobAtError::Object
+
    /// [`TypeMismatch`]: error::read::BlobAtError::TypeMismatch
+
    /// [`Backend`]: error::read::BlobAtError::Backend
+
    fn blob_at<P>(&self, commit: Oid, path: &P) -> Result<Option<Blob>, error::read::BlobAtError>
+
    where
+
        P: AsRef<Path>;
+

+
    /// Find a blob at a `path` within a commit's tree, returning an error if
+
    /// the path does not exist.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`CommitNotFound`]: The commit identified by `commit` does not exist.
+
    /// - [`MissingBlob`]: The path does not exist in the commit's tree.
+
    /// - [`Tree`]: Failed to get the commit's tree.
+
    /// - [`TreeEntry`]: Failed to look up the entry at `path` in the tree.
+
    /// - [`Object`]: The entry was found but failed to resolve to an object.
+
    /// - [`TypeMismatch`]: The resolved object is not a blob.
+
    /// - [`Backend`]: An unexpected error from the underlying git library.
+
    ///
+
    /// [`CommitNotFound`]: error::read::BlobAtError::CommitNotFound
+
    /// [`MissingBlob`]: error::read::BlobAtError::MissingBlob
+
    /// [`Tree`]: error::read::BlobAtError::Tree
+
    /// [`TreeEntry`]: error::read::BlobAtError::TreeEntry
+
    /// [`Object`]: error::read::BlobAtError::Object
+
    /// [`TypeMismatch`]: error::read::BlobAtError::TypeMismatch
+
    /// [`Backend`]: error::read::BlobAtError::Backend
+
    fn try_blob_at<P>(&self, commit: Oid, path: &P) -> Result<Blob, error::read::BlobAtError>
+
    where
+
        P: AsRef<Path>,
+
    {
+
        self.blob_at(commit, path)?
+
            .ok_or_else(|| error::read::BlobAtError::MissingBlob {
+
                commit,
+
                path: path.as_ref().to_path_buf(),
+
            })
+
    }
+

+
    /// Read a commit by its [`Oid`].
+
    ///
+
    /// Returns `None` if the commit does not exist.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`Parse`]: The object was found but could not be parsed as a commit.
+
    /// - [`Backend`]: An unexpected error from the underlying git library.
+
    ///
+
    /// [`Parse`]: error::read::CommitError::Parse
+
    /// [`Backend`]: error::read::CommitError::Backend
+
    fn commit(&self, oid: Oid) -> Result<Option<Commit>, error::read::CommitError>;
+

+
    /// Read a commit by its [`Oid`], returning an error if it does not exist.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`NotFound`]: The commit identified by `oid` does not exist.
+
    /// - [`Parse`]: The object was found but could not be parsed as a commit.
+
    /// - [`Backend`]: An unexpected error from the underlying git library.
+
    ///
+
    /// [`NotFound`]: error::read::CommitError::NotFound
+
    /// [`Parse`]: error::read::CommitError::Parse
+
    /// [`Backend`]: error::read::CommitError::Backend
+
    fn try_commit(&self, oid: Oid) -> Result<Commit, error::read::CommitError> {
+
        self.commit(oid)?
+
            .ok_or(error::read::CommitError::NotFound { oid })
+
    }
+

+
    /// Check whether an object exists in the object database.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`Backend`]: An unexpected error from the underlying git library.
+
    ///
+
    /// [`Backend`]: error::read::ExistsError::Backend
+
    fn exists(&self, oid: Oid) -> Result<bool, error::read::ExistsError>;
+
}
+

+
/// Write Git objects to the Git object database.
+
///
+
/// Every method returns the content-addressed [`Oid`] of the written object.
+
pub trait ObjectWriter {
+
    /// Write a blob given its raw bytes content.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`Backend`]: An unexpected error from the underlying git library.
+
    ///
+
    /// [`Backend`]: error::write::BlobError::Backend
+
    fn write_blob(&self, content: &[u8]) -> Result<Oid, error::write::BlobError>;
+

+
    /// Write a tree from a set of entries.
+
    ///
+
    /// [`TreeEntry::Blob`] entries have their content written as blobs first.
+
    /// [`TreeEntry::BlobRef`] entries reference existing blobs by [`Oid`].
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`MissingBlob`]: A [`TreeEntry::BlobRef`] references an [`Oid`] that
+
    ///   does not exist in the object database.
+
    /// - [`WriteBlob`]: Failed to write a blob for a [`TreeEntry::Blob`] entry.
+
    /// - [`Backend`]: An unexpected error from the underlying git library.
+
    ///
+
    /// [`MissingBlob`]: error::write::TreeError::MissingBlob
+
    /// [`WriteBlob`]: error::write::TreeError::WriteBlob
+
    /// [`Backend`]: error::write::TreeError::Backend
+
    fn write_tree(&self, entries: &[TreeEntry]) -> Result<Oid, error::write::TreeError>;
+

+
    /// Write a commit from raw bytes.
+
    ///
+
    /// The caller is responsible for producing valid Git commit bytes
+
    /// (e.g. via [`radicle_git_metadata`]).  This is necessary for signed
+
    /// commits where the exact byte representation must be controlled.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`Backend`]: An unexpected error from the underlying git library.
+
    ///
+
    /// [`Backend`]: error::write::CommitError::Backend
+
    fn write_commit(&self, bytes: &[u8]) -> Result<Oid, error::write::CommitError>;
+
}
added crates/radicle/src/git/repository/object/error.rs
@@ -0,0 +1,4 @@
+
//! Errors for Git object operations, namespaced by read and write.
+

+
pub mod read;
+
pub mod write;
added crates/radicle/src/git/repository/object/error/read.rs
@@ -0,0 +1,123 @@
+
//! Errors returned by [`ObjectReader`] methods.
+
//!
+
//! [`ObjectReader`]: super::super::ObjectReader
+

+
use std::path::PathBuf;
+

+
use radicle_oid::Oid;
+
use thiserror::Error;
+

+
use crate::git::repository::types::ObjectKind;
+

+
/// Error returned by [`ObjectReader::blob`].
+
///
+
/// [`ObjectReader::blob`]: super::super::ObjectReader::blob
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum BlobError {
+
    /// The blob was not found.
+
    #[error("failed to find blob '{oid}'")]
+
    NotFound { oid: Oid },
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl BlobError {
+
    pub fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
+
        Self::Backend(Box::new(err))
+
    }
+
}
+

+
/// Error returned by [`ObjectReader::blob_at`].
+
///
+
/// [`ObjectReader::blob_at`]: super::super::ObjectReader::blob_at
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum BlobAtError {
+
    /// Failed to find the commit.
+
    #[error("failed to find commit '{commit}' to retrieve blob at {path:?}")]
+
    CommitNotFound { commit: Oid, path: PathBuf },
+
    /// Failed to get the associated tree of the commit.
+
    #[error("failed to get associated tree of the commit '{commit}'")]
+
    Tree {
+
        commit: Oid,
+
        source: Box<dyn std::error::Error + Send + Sync + 'static>,
+
    },
+
    /// Failed to get the entry at `path` in the commit's tree.
+
    #[error("failed to get tree entry {path:?} in the commit '{commit}'")]
+
    TreeEntry {
+
        commit: Oid,
+
        path: PathBuf,
+
        source: Box<dyn std::error::Error + Send + Sync + 'static>,
+
    },
+
    /// Failed to resolve the object at the given path.
+
    #[error("failed to resolve the object at {path:?} in the commit '{commit}'")]
+
    Object {
+
        commit: Oid,
+
        path: PathBuf,
+
        source: Box<dyn std::error::Error + Send + Sync + 'static>,
+
    },
+
    /// The object exists but is not a blob.
+
    #[error("object {oid} has type `{actual}`, expected `{expected}`")]
+
    TypeMismatch {
+
        oid: Oid,
+
        expected: ObjectKind,
+
        actual: String,
+
    },
+
    /// The path does not exist in the commit's tree.
+
    #[error("the blob identified by {path:?} does not exist in the commit '{commit}'")]
+
    MissingBlob { commit: Oid, path: PathBuf },
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl BlobAtError {
+
    pub fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
+
        Self::Backend(Box::new(err))
+
    }
+
}
+

+
/// Error returned by [`ObjectReader::commit`].
+
///
+
/// [`ObjectReader::commit`]: super::super::ObjectReader::commit
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum CommitError {
+
    /// The commit was not found.
+
    #[error("failed to find commit '{oid}'")]
+
    NotFound { oid: Oid },
+
    /// Failed to parse the raw commit bytes.
+
    #[error("failed to parse commit '{oid}': {source}")]
+
    Parse {
+
        oid: Oid,
+
        source: radicle_git_metadata::commit::ParseError,
+
    },
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl CommitError {
+
    pub fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
+
        Self::Backend(Box::new(err))
+
    }
+
}
+

+
/// Error returned by [`ObjectReader::exists`].
+
///
+
/// [`ObjectReader::exists`]: super::super::ObjectReader::exists
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum ExistsError {
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl ExistsError {
+
    pub fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
+
        Self::Backend(Box::new(err))
+
    }
+
}
added crates/radicle/src/git/repository/object/error/write.rs
@@ -0,0 +1,70 @@
+
//! Errors returned by [`ObjectWriter`] methods.
+
//!
+
//! [`ObjectWriter`]: super::super::ObjectWriter
+

+
use std::path::PathBuf;
+

+
use radicle_oid::Oid;
+
use thiserror::Error;
+

+
/// Error returned by [`ObjectWriter::write_blob`].
+
///
+
/// [`ObjectWriter::write_blob`]: super::super::ObjectWriter::write_blob
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum BlobError {
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl BlobError {
+
    pub fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
+
        Self::Backend(Box::new(err))
+
    }
+
}
+

+
/// Error returned by [`ObjectWriter::write_tree`].
+
///
+
/// [`ObjectWriter::write_tree`]: super::super::ObjectWriter::write_tree
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum TreeError {
+
    /// A `BlobRef` entry references an OID that does not exist.
+
    #[error("blob reference '{oid}' does not exist in the object database")]
+
    MissingBlob { oid: Oid },
+
    /// Failed to write blob contents for a [`TreeEntry::Blob`] entry.
+
    ///
+
    /// [`TreeEntry::Blob`]: crate::git::repository::types::TreeEntry::Blob
+
    #[error("failed to write blob contents to {path:?}")]
+
    WriteBlob {
+
        path: PathBuf,
+
        source: Box<dyn std::error::Error + Send + Sync + 'static>,
+
    },
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl TreeError {
+
    pub fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
+
        Self::Backend(Box::new(err))
+
    }
+
}
+

+
/// Error returned by [`ObjectWriter::write_commit`].
+
///
+
/// [`ObjectWriter::write_commit`]: super::super::ObjectWriter::write_commit
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum CommitError {
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl CommitError {
+
    pub fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
+
        Self::Backend(Box::new(err))
+
    }
+
}
added crates/radicle/src/git/repository/reference.rs
@@ -0,0 +1,215 @@
+
//! Git reference database abstraction.
+
//!
+
//! The module provides the following traits:
+
//! - [`RefReader`] for reading references,
+
//! - [`RefWriter`] for writing references, and
+
//! - [`SymbolicRefWriter`] (extends [`RefWriter`]) for writing symbolic references.
+

+
pub mod error;
+

+
use radicle_oid::Oid;
+

+
use radicle_git_ref_format::refspec::PatternStr;
+
use radicle_git_ref_format::{Qualified, RefStr, RefString};
+

+
/// Read Git references.
+
///
+
/// # Target Resolution
+
///
+
/// Direct references point to a target [`Oid`]. For most references, this is a
+
/// commit object. In the case of annotated tags, this will be a tag object.
+
/// In both cases, the target returned will be a commit [`Oid`]; where, in the
+
/// case of an annotated tag, it is the commit of the tag itself.
+
///
+
/// Symbolic references point to another reference. These references are peeled
+
/// until they find the target [`Oid`] of a direct reference.
+
pub trait RefReader {
+
    type References<'a>: Iterator<Item = Result<(Qualified<'static>, Oid), error::read::ListReferenceError>>
+
        + 'a
+
    where
+
        Self: 'a;
+

+
    /// Resolve a reference to its target [`Oid`].
+
    ///
+
    /// Returns `None` if the reference does not exist.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`Backend`]: An unexpected error from the underlying git library.
+
    ///
+
    /// [`Backend`]: error::read::RefTargetError::Backend
+
    fn ref_target<R>(&self, name: &R) -> Result<Option<Oid>, error::read::RefTargetError>
+
    where
+
        R: AsRef<RefStr>;
+

+
    /// Resolve a reference to its target [`Oid`], returning an error if it does
+
    /// not exist.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`NotFound`]: The reference identified by `name` was not found.
+
    /// - [`Backend`]: An unexpected error from the underlying git library.
+
    ///
+
    /// [`NotFound`]: error::read::RefTargetError::NotFound
+
    /// [`Backend`]: error::read::RefTargetError::Backend
+
    fn try_ref_target<R>(&self, name: &R) -> Result<Oid, error::read::RefTargetError>
+
    where
+
        R: AsRef<RefStr>,
+
    {
+
        self.ref_target(name)?
+
            .ok_or_else(|| error::read::RefTargetError::NotFound(name.as_ref().to_ref_string()))
+
    }
+

+
    /// List all references matching a glob pattern.
+
    ///
+
    /// Each reference is parsed and peeled to its target commit. If either of
+
    /// these operations fails, it is returned in the iterator. The caller may
+
    /// choose to log these failures and skip the entry.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`Backend`]: An unexpected error when initialising the reference
+
    ///   iterator.
+
    ///
+
    /// The iterator itself yields [`ListReferenceError`] for per-reference
+
    /// failures:
+
    /// - [`Parse`]: A reference name could not be parsed as a [`Qualified`].
+
    /// - [`Peel`]: A reference could not be peeled to a target commit.
+
    /// - [`ListReferenceError::Backend`]: An unexpected error during iteration.
+
    ///
+
    /// [`Backend`]: error::read::ListRefsError::Backend
+
    /// [`ListReferenceError`]: error::read::ListReferenceError
+
    /// [`Parse`]: error::read::ListReferenceError::Parse
+
    /// [`Peel`]: error::read::ListReferenceError::Peel
+
    /// [`ListReferenceError::Backend`]: error::read::ListReferenceError::Backend
+
    fn list_refs<'a, P>(
+
        &'a self,
+
        pattern: &P,
+
    ) -> Result<Self::References<'a>, error::read::ListRefsError>
+
    where
+
        P: AsRef<PatternStr>;
+
}
+

+
/// The mode of operation for writing a reference.
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+
pub enum RefTarget {
+
    /// Set the reference to the given `target`, only if the reference does not
+
    /// already exist.
+
    Create { target: Oid },
+
    /// Set the reference to the given `target`, the reference may exist
+
    /// already.
+
    Upsert { target: Oid },
+
    /// Set the reference to the given `target`, only if the reference's
+
    /// current value matches `expected`.
+
    Cas { target: Oid, expected: Oid },
+
}
+

+
impl RefTarget {
+
    /// The target [`Oid`] that the reference should point to after the write.
+
    pub fn target(&self) -> Oid {
+
        match self {
+
            Self::Create { target } | Self::Upsert { target } | Self::Cas { target, .. } => *target,
+
        }
+
    }
+
}
+

+
/// Write Git references.
+
pub trait RefWriter {
+
    /// Set a reference to the given [`RefTarget`].
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`MissingTarget`]: The target [`Oid`] does not exist in the object
+
    ///   database.
+
    /// - [`ReferenceExists`]: The reference already exists (for
+
    ///   [`RefTarget::Create`]).
+
    /// - [`CasFailed`]: The reference's current value did not match the
+
    ///   expected value (for [`RefTarget::Cas`]).
+
    /// - [`Backend`]: An unexpected error from the underlying git library.
+
    ///
+
    /// [`MissingTarget`]: error::write::WriteRefError::MissingTarget
+
    /// [`ReferenceExists`]: error::write::WriteRefError::ReferenceExists
+
    /// [`CasFailed`]: error::write::WriteRefError::CasFailed
+
    /// [`Backend`]: error::write::WriteRefError::Backend
+
    fn write_ref<R>(
+
        &self,
+
        name: &R,
+
        target: RefTarget,
+
        reflog: &str,
+
    ) -> Result<(), error::write::WriteRefError>
+
    where
+
        R: AsRef<RefStr>;
+

+
    /// Delete a reference from the Git repository.
+
    ///
+
    /// This operation must be idempotent, i.e. successive deletes of the same
+
    /// reference name must succeed.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`Backend`]: An unexpected error from the underlying git library.
+
    ///
+
    /// [`Backend`]: error::write::DeleteRefError::Backend
+
    fn delete_ref<R>(&self, name: &R) -> Result<(), error::write::DeleteRefError>
+
    where
+
        R: AsRef<RefStr>;
+
}
+

+
/// The mode of operation for writing a symbolic reference.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub enum SymbolicRefTarget {
+
    /// Set the reference to the given `target`, only if the reference does not
+
    /// already exist.
+
    Create { target: RefString },
+
    /// Set the reference to the given `target`, the reference may exist
+
    /// already.
+
    Upsert { target: RefString },
+
    /// Set the reference to the given `target`, only if the current value of
+
    /// the reference matches `expected`.
+
    Cas {
+
        target: RefString,
+
        expected: RefString,
+
    },
+
}
+

+
impl SymbolicRefTarget {
+
    /// The target [`RefString`] that the symbolic reference should point to
+
    /// after the write.
+
    pub fn target(&self) -> &RefString {
+
        match self {
+
            Self::Create { target } | Self::Upsert { target } | Self::Cas { target, .. } => target,
+
        }
+
    }
+
}
+

+
/// Extension trait for symbolic reference support.
+
///
+
/// A symbolic reference is one that points to another reference name rather
+
/// than directly to an [`Oid`] (e.g. `HEAD → refs/heads/main`).
+
pub trait SymbolicRefWriter: RefWriter {
+
    /// Create or update a symbolic reference, identified by `name`, with the
+
    /// given [`SymbolicRefTarget`].
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`MissingTarget`]: The target reference does not exist in the
+
    ///   reference database.
+
    /// - [`ReferenceExists`]: The symbolic reference `name` already exists
+
    ///   (for [`SymbolicRefTarget::Create`]).
+
    /// - [`CasFailed`]: The current target did not match the expected value
+
    ///   (for [`SymbolicRefTarget::Cas`]).
+
    /// - [`Backend`]: An unexpected error from the underlying git library.
+
    ///
+
    /// [`MissingTarget`]: error::write::SymbolicWriteError::MissingTarget
+
    /// [`ReferenceExists`]: error::write::SymbolicWriteError::ReferenceExists
+
    /// [`CasFailed`]: error::write::SymbolicWriteError::CasFailed
+
    /// [`Backend`]: error::write::SymbolicWriteError::Backend
+
    fn write_symbolic_ref<R>(
+
        &self,
+
        name: &R,
+
        target: SymbolicRefTarget,
+
        reflog: &str,
+
    ) -> Result<(), error::write::SymbolicWriteError>
+
    where
+
        R: AsRef<RefStr>;
+
}
added crates/radicle/src/git/repository/reference/error.rs
@@ -0,0 +1,4 @@
+
//! Errors for Git reference operations, namespaced by read and write.
+

+
pub mod read;
+
pub mod write;
added crates/radicle/src/git/repository/reference/error/read.rs
@@ -0,0 +1,72 @@
+
//! Errors returned by [`RefReader`] methods.
+
//!
+
//! [`RefReader`]: super::super::RefReader
+

+
use radicle_git_ref_format::RefString;
+
use thiserror::Error;
+

+
/// Error returned by [`RefReader::ref_target`].
+
///
+
/// [`RefReader::ref_target`]: super::super::RefReader::ref_target
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum RefTargetError {
+
    /// The requested reference was not found.
+
    #[error("failed to find reference '{0}'")]
+
    NotFound(RefString),
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl RefTargetError {
+
    pub fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
+
        Self::Backend(Box::new(err))
+
    }
+
}
+

+
/// Error returned by [`RefReader::list_refs`].
+
///
+
/// [`RefReader::list_refs`]: super::super::RefReader::list_refs
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum ListRefsError {
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl ListRefsError {
+
    pub fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
+
        Self::Backend(Box::new(err))
+
    }
+
}
+

+
/// Error yielded by the [`RefReader::list_refs`] iterator.
+
///
+
/// [`RefReader::list_refs`]: super::super::RefReader::list_refs
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum ListReferenceError {
+
    /// The reference database provided a malformed reference name.
+
    #[error("failed to parse reference '{name}': {source}")]
+
    Parse {
+
        name: String,
+
        source: radicle_git_ref_format::Error,
+
    },
+
    /// The reference could not be peeled to a target commit.
+
    #[error("failed to peel '{name}' to target commit: {source}")]
+
    Peel {
+
        name: radicle_git_ref_format::Qualified<'static>,
+
        source: Box<dyn std::error::Error + Send + Sync + 'static>,
+
    },
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl ListReferenceError {
+
    pub fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
+
        Self::Backend(Box::new(err))
+
    }
+
}
added crates/radicle/src/git/repository/reference/error/write.rs
@@ -0,0 +1,86 @@
+
//! Errors returned by [`RefWriter`] and [`SymbolicRefWriter`] methods.
+
//!
+
//! [`RefWriter`]: super::super::RefWriter
+
//! [`SymbolicRefWriter`]: super::super::SymbolicRefWriter
+

+
use radicle_git_ref_format::RefString;
+
use radicle_oid::Oid;
+
use thiserror::Error;
+

+
/// Error returned by [`RefWriter::write_ref`].
+
///
+
/// [`RefWriter::write_ref`]: super::super::RefWriter::write_ref
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum WriteRefError {
+
    /// Compare-and-swap failed.
+
    #[error(
+
        "failed to update reference '{name}' due to compare-and-swap failure with expected value {expected}"
+
    )]
+
    CasFailed { name: String, expected: Oid },
+
    /// The target OID does not exist in the object database.
+
    #[error("target object {target} not found when writing reference `{name}`")]
+
    MissingTarget { name: String, target: Oid },
+
    /// The reference already exists (for create-only writes).
+
    #[error("reference '{name}' already exists")]
+
    ReferenceExists { name: String },
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl WriteRefError {
+
    pub fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
+
        Self::Backend(Box::new(err))
+
    }
+
}
+

+
/// Error returned by [`RefWriter::delete_ref`].
+
///
+
/// [`RefWriter::delete_ref`]: super::super::RefWriter::delete_ref
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum DeleteRefError {
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl DeleteRefError {
+
    pub fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
+
        Self::Backend(Box::new(err))
+
    }
+
}
+

+
/// Error returned by [`SymbolicRefWriter::write_symbolic_ref`].
+
///
+
/// [`SymbolicRefWriter::write_symbolic_ref`]: super::super::SymbolicRefWriter::write_symbolic_ref
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum SymbolicWriteError {
+
    /// The target reference does not exist.
+
    #[error("could not create symbolic reference '{name}' due to missing target '{target}'")]
+
    MissingTarget { name: RefString, target: RefString },
+
    /// The named reference already exists.
+
    #[error(
+
        "could not create symbolic reference from '{name}' to '{target}', the reference already exists"
+
    )]
+
    ReferenceExists { name: RefString, target: RefString },
+
    /// Compare-and-swap failed.
+
    #[error(
+
        "failed to update reference '{name}' due to compare-and-swap failure with expected value {expected}"
+
    )]
+
    CasFailed {
+
        name: RefString,
+
        expected: RefString,
+
    },
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl SymbolicWriteError {
+
    pub fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
+
        Self::Backend(Box::new(err))
+
    }
+
}
added crates/radicle/src/git/repository/revwalk.rs
@@ -0,0 +1,132 @@
+
//! Git commit graph walk trait.
+
//!
+
//! [`Revwalk`] provides commit iterators, given a [`RevwalkPlan`].
+

+
pub mod error;
+
pub use error::{CommitError, InitError, OidError};
+

+
use radicle_oid::Oid;
+

+
use super::types::Commit;
+

+
/// The sort order for a [`RevwalkPlan`].
+
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+
pub enum SortOrder {
+
    /// Chronological order (newest first, by commit time).
+
    #[default]
+
    Chronological,
+
    /// Topological order (parents before children).
+
    Topological,
+
    /// Reverse of default order.
+
    Reverse,
+
    /// Topological order with children before parents.
+
    TopologicalReverse,
+
}
+

+
/// A plan for walking the commit graph.
+
///
+
/// Accumulates configuration (start points, hidden commits, sort order)
+
/// and is finalised by passing it to an [`Revwalk`] implementation.
+
#[derive(Clone, Debug, Default)]
+
pub struct RevwalkPlan {
+
    start: Vec<Oid>,
+
    hide: Vec<Oid>,
+
    range: Option<(Oid, Oid)>,
+
    sort: SortOrder,
+
}
+

+
impl RevwalkPlan {
+
    /// Create a default walk, that walks all commits in chronological order.
+
    pub fn new() -> Self {
+
        Self::default()
+
    }
+

+
    /// Add a starting point for the walk.
+
    pub fn push(mut self, oid: Oid) -> Self {
+
        self.start.push(oid);
+
        self
+
    }
+

+
    /// Exclude commits reachable from this [`Oid`].
+
    pub fn hide(mut self, oid: Oid) -> Self {
+
        self.hide.push(oid);
+
        self
+
    }
+

+
    /// Walk only commits in the range `from..to` (commits reachable from
+
    /// `to` but not from `from`).
+
    pub fn range(mut self, from: Oid, to: Oid) -> Self {
+
        self.range = Some((from, to));
+
        self
+
    }
+

+
    /// Set the sort order for the walk.
+
    pub fn sort(mut self, order: SortOrder) -> Self {
+
        self.sort = order;
+
        self
+
    }
+

+
    /// The starting points for the walk.
+
    pub fn starts(&self) -> &[Oid] {
+
        &self.start
+
    }
+

+
    /// The commits to hide (exclude reachable commits).
+
    pub fn hidden(&self) -> &[Oid] {
+
        &self.hide
+
    }
+

+
    /// The range, if set.
+
    pub fn range_bounds(&self) -> Option<(Oid, Oid)> {
+
        self.range
+
    }
+

+
    /// The sort order.
+
    pub fn sort_order(&self) -> SortOrder {
+
        self.sort
+
    }
+
}
+

+
/// Git commit graph walks.
+
pub trait Revwalk {
+
    /// Iterator of commit [`Oid`]s.
+
    type RevwalkOids<'a>: Iterator<Item = Result<Oid, OidError>> + 'a
+
    where
+
        Self: 'a;
+

+
    /// Iterator of [`Commit`]s.
+
    type RevwalkCommits<'a>: Iterator<Item = Result<Commit, CommitError>> + 'a
+
    where
+
        Self: 'a;
+

+
    /// Execute a revwalk plan, returning an iterator of commit [`Oid`]s.
+
    ///
+
    /// The returned iterator yields [`OidError`] for per-step failures:
+
    /// - [`OidError::Backend`]: An unexpected error during iteration.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`Backend`]: An unexpected error when initialising the walk.
+
    ///
+
    /// [`Backend`]: InitError::Backend
+
    fn revwalk_oids<'a>(&'a self, plan: &RevwalkPlan) -> Result<Self::RevwalkOids<'a>, InitError>;
+

+
    /// Execute a revwalk plan, returning an iterator of full [`Commit`] data.
+
    ///
+
    /// More expensive than [`Self::revwalk_oids`] since each commit is fully
+
    /// parsed during iteration.
+
    ///
+
    /// The returned iterator yields [`CommitError`] for per-step failures:
+
    /// - [`CommitError::Parse`]: A commit's raw bytes could not be parsed.
+
    /// - [`CommitError::Backend`]: An unexpected error during iteration.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`Backend`]: An unexpected error when initialising the walk.
+
    ///
+
    /// [`Backend`]: InitError::Backend
+
    fn revwalk_commits<'a>(
+
        &'a self,
+
        plan: &RevwalkPlan,
+
    ) -> Result<Self::RevwalkCommits<'a>, InitError>;
+
}
added crates/radicle/src/git/repository/revwalk/error.rs
@@ -0,0 +1,74 @@
+
//! Errors returned by [`Revwalk`] methods and iterators.
+
//!
+
//! [`Revwalk`]: super::Revwalk
+

+
use radicle_oid::Oid;
+
use thiserror::Error;
+

+
/// Error returned by [`Revwalk::revwalk_oids`] and
+
/// [`Revwalk::revwalk_commits`] when initialising the walk.
+
///
+
/// [`Revwalk::revwalk_oids`]: super::Revwalk::revwalk_oids
+
/// [`Revwalk::revwalk_commits`]: super::Revwalk::revwalk_commits
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum InitError {
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl InitError {
+
    pub fn backend<E>(err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self::Backend(Box::new(err))
+
    }
+
}
+

+
/// Error yielded by the [`Revwalk::RevwalkOids`] iterator.
+
///
+
/// [`Revwalk::RevwalkOids`]: super::Revwalk::RevwalkOids
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum OidError {
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl OidError {
+
    pub fn backend<E>(err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self::Backend(Box::new(err))
+
    }
+
}
+

+
/// Error yielded by the [`Revwalk::RevwalkCommits`] iterator.
+
///
+
/// [`Revwalk::RevwalkCommits`]: super::Revwalk::RevwalkCommits
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum CommitError {
+
    /// Failed to parse the raw commit bytes.
+
    #[error("failed to parse commit '{oid}': {source}")]
+
    Parse {
+
        oid: Oid,
+
        source: radicle_git_metadata::commit::ParseError,
+
    },
+
    /// An error from the underlying git library.
+
    #[error(transparent)]
+
    Backend(Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl CommitError {
+
    pub fn backend<E>(err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self::Backend(Box::new(err))
+
    }
+
}
added crates/radicle/src/git/repository/types.rs
@@ -0,0 +1,84 @@
+
//! Domain types for Git repository operations.
+
//!
+
//! # Objects
+
//!
+
//! The following object types are defined in this module:
+
//! - [`Blob`]
+
//! - [`Commit`]
+
//! - [`TreeEntry`]
+

+
use core::fmt;
+
use std::path::PathBuf;
+

+
use radicle_git_metadata as metadata;
+
use radicle_oid::Oid;
+

+
/// An enumeration of the kinds of Git objects
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+
#[non_exhaustive]
+
pub enum ObjectKind {
+
    /// A Git blob object.
+
    Blob,
+
    /// A Git tree object.
+
    Tree,
+
    /// A Git commit object.
+
    Commit,
+
    /// A Git tag object.
+
    Tag,
+
}
+

+
impl fmt::Display for ObjectKind {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            ObjectKind::Blob => f.write_str("blob"),
+
            ObjectKind::Tree => f.write_str("tree"),
+
            ObjectKind::Commit => f.write_str("commit"),
+
            ObjectKind::Tag => f.write_str("tag"),
+
        }
+
    }
+
}
+

+
/// A Git blob object.
+
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+
pub struct Blob {
+
    /// The content-addressed identifier of the blob.
+
    pub oid: Oid,
+
    /// The blob's content.
+
    pub content: Vec<u8>,
+
}
+

+
/// A Git commit, with all metadata parsed.
+
///
+
/// This is a type alias for [`radicle_git_metadata::commit::CommitData`]
+
/// specialised to [`Oid`] for both tree and parent identifiers.
+
pub type Commit = metadata::commit::CommitData<Oid, Oid>;
+

+
/// An entry to be written into a Git tree.
+
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+
pub enum TreeEntry {
+
    /// A blob entry with inline content.
+
    ///
+
    /// The contents of the blob must be written to the Git repository before
+
    /// creating the tree entry.
+
    Blob {
+
        /// The path of the entry within the tree.
+
        ///
+
        /// Multi-component paths (e.g. `a/b/c.txt`) are supported;
+
        /// intermediate sub-trees are created automatically.
+
        path: PathBuf,
+
        /// The contents of the blob.
+
        content: Vec<u8>,
+
    },
+
    /// A reference to an existing blob by [`Oid`].
+
    ///
+
    /// Used when the blob already exists in the Git object database.
+
    BlobRef {
+
        /// The path of the entry within the tree.
+
        ///
+
        /// Multi-component paths (e.g. `a/b/c.txt`) are supported;
+
        /// intermediate sub-trees are created automatically.
+
        path: PathBuf,
+
        /// The [`Oid`] of the existing blob.
+
        oid: Oid,
+
    },
+
}