Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
REVIEW(WIP): refactor changes
Fintan Halpenny committed 10 months ago
commit 859b3010bf53eddc92d5c43fa836a85a2d7a43ba
parent 567ad41f3c260d5d810e929da5e4af1a0f17cedd
6 files changed +476 -155
modified crates/radicle-remote-helper/src/push.rs
@@ -120,6 +120,10 @@ pub enum Error {
    PushAction(#[from] error::PushAction),
    #[error(transparent)]
    Canonical(#[from] error::CanonicalUnrecoverable),
+
    #[error(transparent)]
+
    CanonicalInit(#[from] radicle::git::canonical::error::CanonicalError),
+
    #[error("could not determine object type for {oid}")]
+
    UnknownObjectType { oid: git::Oid },
}

/// Push command.
@@ -349,9 +353,13 @@ pub fn run(
                        // Note that we *do* allow rolling back to a previous commit on the
                        // canonical branch.
                        if let Some(canonical) = rules.canonical(dst.clone(), stored)? {
-
                            let kind = working.find_object(**src, None)?.kind();
-
                            let canonical =
-
                                canonical::Canonical::new(me, *src, kind.unwrap(), canonical);
+
                            let kind = working
+
                                .find_object(**src, None)?
+
                                .kind()
+
                                .and_then(git::canonical::CanonicalObject::new)
+
                                .ok_or(Error::UnknownObjectType { oid: *src })?;
+

+
                            let canonical = canonical::Canonical::new(me, *src, kind, canonical);
                            match canonical.quorum(&working) {
                                Ok(quorum) => set_canonical_refs.push(quorum),
                                Err(e) => canonical::io::handle_error(e, &dst, hints)?,
modified crates/radicle-remote-helper/src/push/canonical.rs
@@ -7,7 +7,7 @@ use super::error;
pub(crate) struct Vote {
    did: Did,
    oid: git::Oid,
-
    kind: git::raw::ObjectType,
+
    kind: git::canonical::CanonicalObject,
}

/// Validates a vote to update a canonical reference during push.
@@ -20,7 +20,7 @@ impl<'a, 'b> Canonical<'a, 'b> {
    pub fn new(
        me: Did,
        head: git::Oid,
-
        kind: git::raw::ObjectType,
+
        kind: git::canonical::CanonicalObject,
        canonical: git::canonical::Canonical<'a, 'b>,
    ) -> Self {
        Self {
@@ -59,7 +59,7 @@ impl<'a, 'b> Canonical<'a, 'b> {
            .converges(working, (&self.vote.did, &self.vote.oid))?;
        if converges || self.canonical.has_no_tips() || self.canonical.is_only(&self.vote.did) {
            self.canonical
-
                .modify_vote(self.vote.did, (self.vote.oid, self.vote.kind));
+
                .modify_vote(self.vote.did, self.vote.oid, self.vote.kind);
        }

        match self.canonical.quorum(working) {
modified crates/radicle/src/git/canonical.rs
@@ -1,14 +1,19 @@
pub mod error;
use error::*;
+

pub mod rules;
+

+
use nonempty::NonEmpty;
pub use rules::{MatchedRule, RawRule, Rules, ValidRule};

use std::collections::BTreeMap;
+
use std::fmt;

use raw::ObjectType;
use raw::Repository;

use crate::prelude::Did;
+
use crate::storage::git;

use super::raw;
use super::{Oid, Qualified};
@@ -28,7 +33,52 @@ use super::{Oid, Qualified};
pub struct Canonical<'a, 'b> {
    refname: Qualified<'a>,
    rule: &'b ValidRule,
-
    tips: BTreeMap<Did, (Oid, git2::ObjectType)>,
+
    tips: BTreeMap<Did, (Oid, CanonicalObject)>,
+
}
+

+
/// Support Git object types for canonical computation
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+
pub enum CanonicalObject {
+
    /// The Git object corresponds to a commit.
+
    Commit,
+
    /// The Git object corresponds to a tag.
+
    Tag,
+
}
+

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

+
impl CanonicalObject {
+
    /// Construct the [`CanonicalObject`] from a [`git2::ObjectType`].
+
    pub fn new(kind: git::raw::ObjectType) -> Option<Self> {
+
        match kind {
+
            ObjectType::Commit => Some(Self::Commit),
+
            ObjectType::Tag => Some(Self::Tag),
+
            _ => None,
+
        }
+
    }
+

+
    /// Returns `true` if the object is a commit.
+
    fn is_commit(&self) -> bool {
+
        match self {
+
            CanonicalObject::Commit => true,
+
            CanonicalObject::Tag => false,
+
        }
+
    }
+

+
    /// Returns `true` if the object is a tag.
+
    fn is_tag(&self) -> bool {
+
        match self {
+
            CanonicalObject::Commit => false,
+
            CanonicalObject::Tag => true,
+
        }
+
    }
}

impl<'a, 'b> Canonical<'a, 'b> {
@@ -38,31 +88,29 @@ impl<'a, 'b> Canonical<'a, 'b> {
        repo: &Repository,
        refname: Qualified<'a>,
        rule: &'b ValidRule,
-
    ) -> Result<Self, raw::Error> {
+
    ) -> Result<Self, CanonicalError> {
        let mut tips = BTreeMap::new();
        for delegate in rule.allowed().iter() {
            let name = &refname.with_namespace(delegate.as_key().into());

-
            let reference = match repo.find_reference(&name) {
+
            let reference = match repo.find_reference(name) {
                Ok(reference) => reference,
                Err(e) if super::ext::is_not_found_err(&e) => {
                    log::warn!(
                        target: "radicle",
-
                        "Missing `refs/namespaces/{}/{refname}` while calculating the canonical reference",
-
                        delegate.as_key()
+
                        "Missing `{name}` while calculating the canonical reference",
                    );
                    continue;
                }
-
                Err(e) => return Err(e),
+
                Err(e) => return Err(CanonicalError::find_reference(name, e)),
            };

            let Some(oid) = reference.target() else {
+
                log::warn!(target: "radicle", "Missing target for reference `{name}`");
                continue;
            };

-
            let Some(kind) = repo.find_object(oid, None)?.kind() else {
-
                continue;
-
            };
+
            let kind = Self::find_object_for(delegate, oid.into(), repo)?;

            tips.insert(*delegate, (oid.into(), kind));
        }
@@ -73,6 +121,30 @@ impl<'a, 'b> Canonical<'a, 'b> {
        })
    }

+
    pub fn find_object_for(
+
        did: &Did,
+
        oid: Oid,
+
        repo: &raw::Repository,
+
    ) -> Result<CanonicalObject, CanonicalError> {
+
        match repo.find_object(*oid, None) {
+
            Ok(object) => object.kind().and_then(CanonicalObject::new).ok_or_else(|| {
+
                CanonicalError::invalid_object_type(
+
                    repo.path().to_path_buf(),
+
                    *did,
+
                    oid,
+
                    object.kind(),
+
                )
+
            }),
+
            Err(err) if super::ext::is_not_found_err(&err) => Err(CanonicalError::missing_object(
+
                repo.path().to_path_buf(),
+
                *did,
+
                oid,
+
                err,
+
            )),
+
            Err(err) => Err(CanonicalError::find_object(oid, err)),
+
        }
+
    }
+

    /// Returns `true` if there were no tips found for any of the DIDs for
    /// the given reference.
    ///
@@ -88,8 +160,8 @@ impl<'a, 'b> Canonical<'a, 'b> {
    /// In some cases, we allow the vote to be modified. For example, when the
    /// `did` is pushing a new commit, we may want to see if the new commit will
    /// reach a quorum.
-
    pub fn modify_vote(&mut self, did: Did, new: (Oid, git2::ObjectType)) {
-
        self.tips.insert(did, new);
+
    pub fn modify_vote(&mut self, did: Did, oid: Oid, kind: CanonicalObject) {
+
        self.tips.insert(did, (oid, kind));
    }

    /// Check that the provided `did` is part of the set of allowed
@@ -114,77 +186,113 @@ impl<'a, 'b> Canonical<'a, 'b> {
        repo: &Repository,
        (candidate, commit): (&Did, &Oid),
    ) -> Result<bool, ConvergesError> {
-
        let mut common_kind = ObjectType::Any;
+
        /// Ensures [`Oid`]s are of the same object type
+
        enum Objects {
+
            Commits(NonEmpty<Oid>),
+
            Tags(NonEmpty<Oid>),
+
        }
+

+
        impl Objects {
+
            fn new(oid: Oid, kind: CanonicalObject) -> Self {
+
                match kind {
+
                    CanonicalObject::Commit => Self::Commits(NonEmpty::new(oid)),
+
                    CanonicalObject::Tag => Self::Tags(NonEmpty::new(oid)),
+
                }
+
            }
+

+
            fn insert(mut self, oid: Oid, kind: CanonicalObject) -> Result<Self, CanonicalObject> {
+
                match self {
+
                    Objects::Commits(ref mut commits) => match kind {
+
                        CanonicalObject::Commit => {
+
                            commits.push(oid);
+
                            Ok(self)
+
                        }
+
                        CanonicalObject::Tag => Err(CanonicalObject::Tag),
+
                    },
+
                    Objects::Tags(ref mut tags) => match kind {
+
                        CanonicalObject::Commit => {
+
                            tags.push(oid);
+
                            Ok(self)
+
                        }
+
                        CanonicalObject::Tag => Err(CanonicalObject::Commit),
+
                    },
+
                }
+
            }
+
        }
+

        let heads = {
            let heads = self
                .tips
                .iter()
                .filter_map(|(did, tip)| (did != candidate).then_some((did, tip)));

-
            let mut result = Vec::with_capacity(heads.size_hint().0);
-

-
            for (did, (oid, kind)) in heads {
-
                if common_kind == ObjectType::Any {
-
                    common_kind = *kind;
-
                } else if common_kind != *kind {
-
                    return Err(ConvergesError::invalid_object_kind(
-
                        repo.path().to_path_buf(),
-
                        *did,
-
                        *oid,
-
                        Some(*kind),
-
                    ));
+
            let mut objects = None;
+

+
            for (did, (oid, _)) in heads {
+
                let kind = find_object_for(did, *oid, repo)?;
+
                let oid = *oid;
+
                match objects {
+
                    None => objects = Some(Objects::new(oid, kind)),
+
                    Some(objs) => {
+
                        objects = Some(objs.insert(oid, kind).map_err(|expected| {
+
                            ConvergesError::mismatched_object(
+
                                repo.path().to_path_buf(),
+
                                oid,
+
                                kind,
+
                                expected,
+
                            )
+
                        })?)
+
                    }
                }
-
                result.push(Self::ensure_commit_or_tag(*did, *oid, repo)?);
            }

-
            result
+
            objects
        };

-
        if common_kind == ObjectType::Commit {
-
            for (head, _) in heads {
-
                let (ahead, behind) = repo
-
                    .graph_ahead_behind(**commit, *head)
-
                    .map_err(|err| ConvergesError::graph_descendant(*commit, head, err))?;
-
                if ahead * behind == 0 {
-
                    return Ok(true);
+
        match heads {
+
            None => Ok(true),
+
            Some(Objects::Tags(_)) => Ok(true),
+
            Some(Objects::Commits(heads)) => {
+
                for head in heads {
+
                    let (ahead, behind) = repo
+
                        .graph_ahead_behind(**commit, *head)
+
                        .map_err(|err| ConvergesError::graph_descendant(*commit, head, err))?;
+
                    if ahead * behind == 0 {
+
                        return Ok(true);
+
                    }
                }
+
                Ok(false)
            }
-
        } else {
-
            return Ok(true);
        }
-
        Ok(false)
    }

    fn quorum_tag(&self) -> Result<Oid, QuorumError> {
-
        let mut candidates = BTreeMap::<Oid, u8>::new();
-

-
        for (head, kind) in self.tips.values() {
-
            if *kind != raw::ObjectType::Tag {
-
                continue;
-
            }
-
            {
-
                let votes = candidates.entry(*head).or_default();
-
                *votes = votes.saturating_add(1);
-
            }
-
        }
+
        let voting = TagVoting::from_targets(
+
            self.tips
+
                .values()
+
                .filter_map(|(commit, kind)| kind.is_tag().then_some(*commit)),
+
        );
+
        let mut votes = voting.votes();

        // Keep tags which pass the threshold.
-
        candidates.retain(|_, votes| *votes as usize >= self.threshold());
+
        votes.votes_past_threshold(self.threshold());

-
        if candidates.len() > 1 {
+
        if votes.number_of_candidates() > 1 {
            return Err(QuorumError::DivergingTags {
                refname: self.refname.to_string(),
                threshold: self.threshold(),
-
                candidates: candidates.keys().cloned().collect(),
+
                candidates: votes.candidates().cloned().collect(),
            });
        }

-
        let (longest, _) = candidates.pop_first().ok_or(QuorumError::NoCandidates {
-
            refname: self.refname.to_string(),
-
            threshold: self.threshold(),
-
        })?;
+
        let tag = votes
+
            .pop_first_candidate()
+
            .ok_or(QuorumError::NoCandidates {
+
                refname: self.refname.to_string(),
+
                threshold: self.threshold(),
+
            })?;

-
        Ok((*longest).into())
+
        Ok((*tag).into())
    }

    /// Computes the quorum or "canonical" tip based on the tips, of `Canonical`,
@@ -195,52 +303,36 @@ impl<'a, 'b> Canonical<'a, 'b> {
    /// Also returns an error if `heads` is empty or `threshold` cannot be
    /// satisified with the number of heads given.
    fn quorum_commit(&self, repo: &raw::Repository) -> Result<Oid, QuorumError> {
-
        let mut candidates = BTreeMap::<Oid, u8>::new();
-

-
        // Build a list of candidate commits and count how many "votes" each of them has.
-
        // Commits get a point for each direct vote, as well as for being part of the ancestry
-
        // of a commit given to this function. Only commits given to the function are considered.
-
        for (i, (head, kind)) in self.tips.values().enumerate() {
-
            if *kind != raw::ObjectType::Commit {
-
                continue;
-
            }
-
            {
-
                let votes = candidates.entry(*head).or_default();
-
                *votes = votes.saturating_add(1);
-
            }
-
            // Compare this head to all other heads ahead of it in the list.
-
            for (other, kind) in self.tips.values().skip(i + 1) {
-
                if *kind != raw::ObjectType::Commit {
-
                    continue;
-
                }
-
                // N.b. if heads are equal then skip it, otherwise it will end up as
-
                // a double vote.
-
                if head == other {
-
                    continue;
-
                }
-

-
                let base = Oid::from(repo.merge_base(**head, **other)?);
-

-
                if base == *other || base == *head {
-
                    {
-
                        let votes = candidates.entry(base).or_default();
-
                        *votes = votes.saturating_add(1);
-
                    }
-
                }
+
        let mut voting = CommitVoting::from_targets(
+
            self.tips
+
                .values()
+
                .filter_map(|(commit, kind)| kind.is_commit().then_some(*commit)),
+
        );
+
        while let Some(targets) = voting.next_candidate() {
+
            for (candidate, other) in targets {
+
                let base = Oid::from(repo.merge_base(*candidate, *other)?);
+
                voting.found_merge_base(MergeBase {
+
                    candidate,
+
                    other,
+
                    base,
+
                });
            }
        }
+
        let mut votes = voting.votes();

        // Keep commits which pass the threshold.
-
        candidates.retain(|_, votes| *votes as usize >= self.threshold());
+
        votes.votes_past_threshold(self.threshold());

-
        let (mut longest, _) = candidates.pop_first().ok_or(QuorumError::NoCandidates {
-
            refname: self.refname.to_string(),
-
            threshold: self.threshold(),
-
        })?;
+
        let mut longest = votes
+
            .pop_first_candidate()
+
            .ok_or(QuorumError::NoCandidates {
+
                refname: self.refname.to_string(),
+
                threshold: self.threshold(),
+
            })?;

        // Now that all scores are calculated, figure out what is the longest branch
        // that passes the threshold. In case of divergence, return an error.
-
        for head in candidates.keys() {
+
        for head in votes.candidates() {
            let base = repo.merge_base(**head, *longest)?;

            if base == *longest {
@@ -311,40 +403,171 @@ impl<'a, 'b> Canonical<'a, 'b> {
    fn threshold(&self) -> usize {
        (*self.rule.threshold()).into()
    }
+
}

-
    fn ensure_commit_or_tag(
-
        from: Did,
-
        commit_or_tag: Oid,
-
        working: &Repository,
-
    ) -> Result<(Oid, ObjectType), ConvergesError> {
-
        match working.find_object(*commit_or_tag, None) {
-
            Ok(object) => match object.kind() {
-
                Some(kind @ ObjectType::Commit) | Some(kind @ ObjectType::Tag) => {
-
                    Ok((object.id().into(), kind))
-
                }
-
                kind => Err(ConvergesError::invalid_object_kind(
-
                    working.path().to_path_buf(),
-
                    from,
-
                    commit_or_tag,
-
                    kind,
-
                )),
+
/// Keep track of [`Votes`] for quorums involving tag objects.
+
struct TagVoting {
+
    votes: Votes,
+
}
+

+
impl TagVoting {
+
    fn from_targets(targets: impl Iterator<Item = Oid>) -> Self {
+
        let votes = targets.fold(Votes::default(), |mut votes, oid| {
+
            votes.vote(oid);
+
            votes
+
        });
+
        Self { votes }
+
    }
+

+
    fn votes(self) -> Votes {
+
        self.votes
+
    }
+
}
+

+
/// Keep track of [`Votes`] for quorums involving commit objects.
+
///
+
/// Build a list of candidate commits and count how many "votes" each of them
+
/// has. Commits get a point for each direct vote, as well as for being part of
+
/// the ancestry of a commit given to this function.
+
#[derive(Debug)]
+
struct CommitVoting {
+
    candidates: Vec<(Oid, Vec<Oid>)>,
+
    votes: Votes,
+
}
+

+
impl CommitVoting {
+
    /// Build the initial set voting.
+
    fn from_targets(targets: impl Iterator<Item = Oid> + Clone) -> Self {
+
        let ts = targets.clone();
+
        let (candidates, votes) = targets.enumerate().fold(
+
            (Vec::new(), Votes::default()),
+
            |(mut candidates, mut votes), (i, oid)| {
+
                candidates.push((oid, ts.clone().skip(i + 1).collect()));
+
                votes.vote(oid);
+
                (candidates, votes)
            },
-
            Err(err) if err.code() == raw::ErrorCode::NotFound => {
-
                Err(ConvergesError::missing_object(
-
                    working.path().to_path_buf(),
-
                    from,
-
                    commit_or_tag,
-
                    err,
-
                ))
-
            }
-
            Err(err) => Err(ConvergesError::invalid_object(
-
                working.path().to_path_buf(),
-
                from,
-
                commit_or_tag,
-
                err,
-
            )),
+
        );
+
        Self { candidates, votes }
+
    }
+

+
    /// Get the next candidate to be considered for ancestry votes.
+
    ///
+
    /// The first of each pair will be the candidate commit, which should be
+
    /// compared to the other commit to see what their common merge base is. The
+
    /// merge base is then recorded using [`MergeBase`] and is recorded using
+
    /// [`CommitVoting::found_merge_base`].
+
    fn next_candidate(&mut self) -> Option<impl Iterator<Item = (Oid, Oid)>> {
+
        self.candidates
+
            .pop()
+
            .map(|(oid, others)| others.into_iter().map(move |other| (oid, other)))
+
    }
+

+
    /// Record a merge base, and add to the vote if necessary.
+
    fn found_merge_base(
+
        &mut self,
+
        MergeBase {
+
            candidate,
+
            other,
+
            base,
+
        }: MergeBase,
+
    ) {
+
        // Avoid double counting the same commits
+
        let is_same = candidate == other;
+
        if !is_same && (base == candidate || base == other) {
+
            self.votes.vote(base);
        }
    }
+

+
    /// Finish the voting process and get the [`Votes`] from the
+
    /// [`CommitVoting`].
+
    fn votes(self) -> Votes {
+
        self.votes
+
    }
+
}
+

+
/// Record a merge base between `candidate` and `other`.
+
struct MergeBase {
+
    /// The candidate commit for the merge base.
+
    candidate: Oid,
+
    /// The commit that is being compared against for the merge base.
+
    other: Oid,
+
    /// The computed merge base commit.
+
    base: Oid,
+
}
+

+
/// Count the number of votes per [`Oid`].
+
///
+
/// Note that the count cannot exceed 255, since that is the maximum number the
+
/// `threshold` value can be.
+
#[derive(Debug, Default, PartialEq, Eq)]
+
struct Votes {
+
    inner: BTreeMap<Oid, u8>,
+
}
+

+
impl Votes {
+
    /// Increase the vote count for `oid`.
+
    ///
+
    /// If `oid` does not exist in the set of [`Votes`] yet, then no vote will
+
    /// be added.
+
    #[inline]
+
    fn vote(&mut self, oid: Oid) {
+
        self.safe_inc(oid, 1);
+
    }
+

+
    /// Filter the candidates by the ones that have a number of votes that pass
+
    /// the `threshold`.
+
    #[inline]
+
    fn votes_past_threshold(&mut self, threshold: usize) {
+
        self.inner.retain(|_, votes| *votes as usize >= threshold);
+
    }
+

+
    /// Get the number of candidates this set of votes has.
+
    #[inline]
+
    fn number_of_candidates(&self) -> usize {
+
        self.inner.len()
+
    }
+

+
    /// Get the set candidates.
+
    #[inline]
+
    fn candidates(&self) -> impl Iterator<Item = &Oid> {
+
        self.inner.keys()
+
    }
+

+
    /// Pop off the first candidate from the set of votes.
+
    #[inline]
+
    fn pop_first_candidate(&mut self) -> Option<Oid> {
+
        self.inner.pop_first().map(|(oid, _)| oid)
+
    }
+

+
    #[inline]
+
    fn safe_inc(&mut self, oid: Oid, n: u8) {
+
        let votes = self.inner.entry(oid).or_default();
+
        *votes = votes.saturating_add(n);
+
    }
+
}
+

+
fn find_object_for(
+
    did: &Did,
+
    oid: Oid,
+
    repo: &raw::Repository,
+
) -> Result<CanonicalObject, FindObjectError> {
+
    match repo.find_object(*oid, None) {
+
        Ok(object) => object.kind().and_then(CanonicalObject::new).ok_or_else(|| {
+
            FindObjectError::invalid_object_type(
+
                repo.path().to_path_buf(),
+
                *did,
+
                oid,
+
                object.kind(),
+
            )
+
        }),
+
        Err(err) if super::ext::is_not_found_err(&err) => Err(FindObjectError::missing_object(
+
            repo.path().to_path_buf(),
+
            *did,
+
            oid,
+
            err,
+
        )),
+
        Err(err) => Err(FindObjectError::find_object(oid, err)),
+
    }
}

#[cfg(test)]
@@ -363,13 +586,18 @@ mod tests {
        threshold: usize,
        repo: &git::raw::Repository,
    ) -> Result<Oid, QuorumError> {
-
        let tips: BTreeMap<Did, (Oid, git2::ObjectType)> = heads
+
        let tips: BTreeMap<Did, (Oid, CanonicalObject)> = heads
            .iter()
            .enumerate()
            .map(|(i, head)| {
                let signer = Device::mock_from_seed([(i + 1) as u8; 32]);
                let did = Did::from(signer.public_key());
-
                let kind = repo.find_object(*head, None).unwrap().kind().unwrap();
+
                let kind = repo
+
                    .find_object(*head, None)
+
                    .unwrap()
+
                    .kind()
+
                    .and_then(CanonicalObject::new)
+
                    .unwrap();
                (did, ((*head).into(), kind))
            })
            .collect();
modified crates/radicle/src/git/canonical/error.rs
@@ -4,6 +4,8 @@ use thiserror::Error;

use crate::{git::raw, git::Oid, prelude::Did};

+
use super::CanonicalObject;
+

/// Error that can occur when calculation the [`Canonical::quorum`].
#[derive(Debug, Error)]
pub enum QuorumError {
@@ -68,55 +70,135 @@ pub struct InvalidObjectType {
}

#[derive(Debug, Error)]
-
pub enum ConvergesError {
+
#[error("the object {oid} in the repository {repo:?} is of unexpected type {found} and was expected to be {expected}")]
+
pub struct MismatchedObject {
+
    repo: PathBuf,
+
    oid: Oid,
+
    found: CanonicalObject,
+
    expected: CanonicalObject,
+
}
+

+
#[derive(Debug, Error)]
+
pub enum CanonicalError {
    #[error(transparent)]
-
    GraphDescendant(#[from] GraphDescendant),
+
    InvalidObjectType(#[from] InvalidObjectType),
    #[error(transparent)]
    MissingObject(#[from] MissingObject),
-
    #[error(transparent)]
-
    InvalidObject(#[from] InvalidObject),
-
    #[error(transparent)]
-
    InvalidObjectType(#[from] InvalidObjectType),
+
    #[error("failed to find object {oid} due to: {source}")]
+
    FindObject { oid: Oid, source: git2::Error },
+
    #[error("failed to find reference {name} due to: {source}")]
+
    FindReference { name: String, source: git2::Error },
}

-
impl ConvergesError {
-
    pub(super) fn graph_descendant(head: Oid, canonical: Oid, source: raw::Error) -> Self {
-
        Self::GraphDescendant(GraphDescendant {
-
            head,
-
            canonical,
-
            source,
-
        })
+
impl CanonicalError {
+
    pub(super) fn invalid_object_type(
+
        repo: PathBuf,
+
        did: Did,
+
        oid: Oid,
+
        kind: Option<git2::ObjectType>,
+
    ) -> Self {
+
        InvalidObjectType {
+
            repo,
+
            did,
+
            oid,
+
            kind,
+
        }
+
        .into()
    }

-
    pub(super) fn missing_object(repo: PathBuf, did: Did, commit: Oid, err: raw::Error) -> Self {
-
        Self::MissingObject(MissingObject {
+
    pub(super) fn missing_object(repo: PathBuf, did: Did, oid: Oid, err: git2::Error) -> Self {
+
        MissingObject {
            repo,
            did,
-
            commit,
+
            commit: oid,
            source: err,
-
        })
+
        }
+
        .into()
+
    }
+

+
    pub(super) fn find_object(oid: Oid, err: git2::Error) -> Self {
+
        Self::FindObject { oid, source: err }
    }

-
    pub(super) fn invalid_object(repo: PathBuf, did: Did, commit: Oid, err: raw::Error) -> Self {
-
        Self::InvalidObject(InvalidObject {
+
    pub(crate) fn find_reference(name: &str, e: git2::Error) -> CanonicalError {
+
        Self::FindReference {
+
            name: name.to_string(),
+
            source: e,
+
        }
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
pub enum FindObjectError {
+
    #[error(transparent)]
+
    InvalidObjectType(#[from] InvalidObjectType),
+
    #[error(transparent)]
+
    MissingObject(#[from] MissingObject),
+
    #[error("failed to find object {oid} due to: {source}")]
+
    FindObject { oid: Oid, source: git2::Error },
+
}
+

+
impl FindObjectError {
+
    pub(super) fn find_object(oid: Oid, err: git2::Error) -> Self {
+
        Self::FindObject { oid, source: err }
+
    }
+

+
    pub(super) fn missing_object(repo: PathBuf, did: Did, oid: Oid, err: git2::Error) -> Self {
+
        MissingObject {
            repo,
            did,
-
            commit,
+
            commit: oid,
            source: err,
-
        })
+
        }
+
        .into()
    }

-
    pub(super) fn invalid_object_kind(
+
    pub(super) fn invalid_object_type(
        repo: PathBuf,
        did: Did,
        oid: Oid,
        kind: Option<git2::ObjectType>,
    ) -> Self {
-
        Self::InvalidObjectType(InvalidObjectType {
+
        InvalidObjectType {
            repo,
            did,
            oid,
            kind,
+
        }
+
        .into()
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
pub enum ConvergesError {
+
    #[error(transparent)]
+
    GraphDescendant(#[from] GraphDescendant),
+
    #[error(transparent)]
+
    MismatchedObject(#[from] MismatchedObject),
+
    #[error(transparent)]
+
    FindObjectError(#[from] FindObjectError),
+
}
+

+
impl ConvergesError {
+
    pub(super) fn graph_descendant(head: Oid, canonical: Oid, source: raw::Error) -> Self {
+
        Self::GraphDescendant(GraphDescendant {
+
            head,
+
            canonical,
+
            source,
+
        })
+
    }
+

+
    pub(super) fn mismatched_object(
+
        repo: PathBuf,
+
        oid: Oid,
+
        found: CanonicalObject,
+
        expected: CanonicalObject,
+
    ) -> Self {
+
        Self::MismatchedObject(MismatchedObject {
+
            repo,
+
            oid,
+
            found,
+
            expected,
        })
    }
}
modified crates/radicle/src/git/canonical/rules.rs
@@ -20,6 +20,7 @@ use serde_json as json;
use thiserror::Error;

use crate::git;
+
use crate::git::canonical;
use crate::git::canonical::Canonical;
use crate::git::fmt::{refname, RefString};
use crate::git::refspec::QualifiedPattern;
@@ -634,7 +635,7 @@ impl Rules {
        &'a self,
        refname: Qualified<'b>,
        repo: &Repository,
-
    ) -> Result<Option<Canonical<'b, 'a>>, git::raw::Error> {
+
    ) -> Result<Option<Canonical<'b, 'a>>, canonical::error::CanonicalError> {
        if let Some((_, rule)) = self.matches(&refname).next() {
            Ok(Some(Canonical::new(&repo.backend, refname, rule)?))
        } else {
modified crates/radicle/src/storage.rs
@@ -126,6 +126,8 @@ pub enum RepositoryError {
    DefaultBranchRule(#[from] doc::DefaultBranchRuleError),
    #[error("failed to get canonical reference rules: {0}")]
    CanonicalRefs(#[from] doc::CanonicalRefsError),
+
    #[error(transparent)]
+
    Canonical(#[from] canonical::error::CanonicalError),
}

impl RepositoryError {