Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
radicle: the great Canonical rewrite
Lorenz Leutgeb committed 10 months ago
commit 1e748a0cfb1e13062c6d6ef45c94668d224701a6
parent 7a9d4512f99156681ca879e0794a6ade154af33b
17 files changed +2316 -1104
modified crates/radicle-cli/examples/git/git-push-canonical-annotated-tags.md
@@ -1,3 +1,9 @@
+
+   "delegates": [
+
+     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
+
+   ],
+
+   "threshold": 1
+
+ }
+
+```
In this example, we will show how we can make other references become canonical.
To illustrate, we will use annotated Git tags as an example. The storage of the repository
should look something like this by the end of the example:
@@ -87,12 +93,14 @@ $ rad id update --title "Add canonical reference rules" --payload xyz.radicle.cr
 }
```

-
Now, Alice will create an annotated tag and push it:
+
Now, Alice will create a tag and push it:

``` ~alice
$ git tag -a -m "Hotfix for release 1" v1.0-hotfix
$ git cat-file -t v1.0-hotfix
tag
+
$ git cat-file -t ac51a0746a5e8311829bc481202909a1e3acc0c2
+
tag
```

``` ~alice (stderr)
modified crates/radicle-cli/examples/git/git-push-diverge.md
@@ -29,30 +29,31 @@ As Alice, we fetch that code, but commit on top of our own master, which is no
longer canonical, since Bob pushed a more recent commit, and the threshold is 1:

``` ~alice
-
$ rad remote add did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --fetch --no-sync
-
✓ Remote bob@z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk added
-
✓ Remote-tracking branch bob@z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk/master created for z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
$ rad remote add did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --name bob --fetch --no-sync
+
✓ Remote bob added
+
✓ Remote-tracking branch bob/master created for z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
$ git branch -arv
-
  bob@z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk/master 319a7dc Third commit
-
  rad/master                                                  f2de534 Second commit
+
  bob/master 319a7dc Third commit
+
  rad/master f2de534 Second commit
$ git commit -m "Third commit by Alice" --allow-empty -q
```

If we try to push now, we get an error with a hint, telling us that we need to
integrate Bob's changes before pushing ours:

-
``` ~alice (stderr) (fail) RAD_HINT=1
+
``` ~alice (stderr)
$ git push rad
-
hint: you are attempting to push a commit that would cause your upstream to diverge from the canonical reference refs/heads/master
-
hint: to integrate the remote changes, run `git pull --rebase` and try again
-
error: refusing to update canonical reference to commit that is not a descendant of current canonical head
-
error: failed to push some refs to 'rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
+
warn: could not determine target commit for canonical reference 'refs/heads/master', found diverging commits 2e8758fc512cbdef298c86feddff5ba3280e94b4 and 319a7dc3b195368ded4b099f8c90bbb80addccd3, with base commit f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 and threshold 1
+
warn: it is recommended to find a commit to agree upon
+
✓ Synced with 1 seed(s)
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
   f2de534..2e8758f  master -> master
```

We do that, and notice that we're now able to push our code:

``` ~alice
-
$ git pull --rebase
+
$ git pull bob master --rebase
$ git log --oneline
f6cff86 Third commit by Alice
319a7dc Third commit
@@ -60,10 +61,10 @@ f2de534 Second commit
08c788d Initial commit
```
``` ~alice RAD_SOCKET=/dev/null (stderr)
-
$ git push rad
+
$ git push rad -f
✓ Canonical reference refs/heads/master updated to target commit f6cff86594495e9beccfeda7c20173e55c1dd9fc
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
   f2de534..f6cff86  master -> master
+
 + 2e8758f...f6cff86 master -> master (forced update)
```

One thing of note is that we can revert to an older commit as long as we are
deleted crates/radicle-cli/examples/rad-id-missing-commits.md
@@ -1,90 +0,0 @@
-
Sometimes, commits may appear missing in the working copy when pushing to the
-
default branch. In this scenario, we show this happening, and then how to
-
recover from the problem.
-

-
First, we need to be in a scenario where there is more than one delegate:
-

-
``` ~alice
-
$ rad id update --title "Add Bob" --description "Add Bob as a delegate" --delegate did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk -q
-
7be665f9fccba97abb21b2fa85a6fd3181c72858
-
```
-

-
``` ~alice
-
$ rad follow did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
-
✓ Follow policy updated for z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
-
$ rad sync
-
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
-
✓ Target met: 1 seed(s)
-
🌱 Fetched from z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
-
✓ Synced with 1 seed(s)
-
$ rad id update --title "Bump threshold" --description "Bumping threshold to 2" --threshold 2 -q
-
f515dc5af139b8eb9fa817df3f637f2acc29c12b
-
$ rad sync -a
-
✓ Synced with 1 seed(s)
-
```
-

-
``` ~bob
-
$ rad id accept f515dc5af139b8eb9fa817df3f637f2acc29c12b -q
-
$ rad sync -a
-
✓ Synced with 1 seed(s)
-
```
-

-
At this stage, Bob makes some changes at the same time, updating the default
-
branch:
-

-
``` ~bob (stderr) RAD_SOCKET=/dev/null
-
$ touch README.md
-
$ git add README.md
-
$ git commit -m "Add README"
-
$ git push rad master
-
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
-
   f2de534..361f146  master -> master
-
```
-

-
Alice, is also busy making some changes:
-

-
``` ~alice
-
$ rad sync -f
-
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
-
✓ Target met: 1 seed(s)
-
🌱 Fetched from z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
-
```
-

-
``` ~alice
-
$ touch LICENSE
-
$ git add LICENSE
-
$ git commit -m "Add LICENSE"
-
[master 62d19fd] Add LICENSE
-
 1 file changed, 0 insertions(+), 0 deletions(-)
-
 create mode 100644 LICENSE
-
```
-

-
However, when she goes to push to the default branch she sees an error about a missing commit from Bob:
-

-
``` ~alice (fails) (stderr)
-
$ git push rad master
-
error: the commit 361f146ec7339fffdea1ea586f51410250bec9cf for did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk is missing from the repository [..]
-
error: failed to push some refs to 'rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
-
```
-

-
The reason for this is that when attempting to compute the canonical commit for
-
the default branch, there are some checks to see if the delegates agree on the
-
new commit. In this case, Bob's commit was not available to perform this check,
-
so, Alice must fetch from Bob's state of the repository. She can do this by
-
adding him as a remote:
-

-
``` ~alice
-
$ rad remote add did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --name "bob"
-
✓ Remote bob added
-
✓ Remote-tracking branch bob/master created for z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
-
```
-

-

-
``` ~alice (stderr) RAD_SOCKET=/dev/null
-
$ git push rad master
-
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
   f2de534..62d19fd  master -> master
-
```
-

-
Note that if the remote tracking branch already exists, then she can simply `git
-
fetch bob/master`.
modified crates/radicle-cli/tests/commands.rs
@@ -456,47 +456,6 @@ fn rad_id_threshold_soft_fork() {
}

#[test]
-
fn rad_id_missing_commits() {
-
    let mut environment = Environment::new();
-
    let alice = environment.node("alice");
-
    let bob = environment.node("bob");
-
    let acme = RepoId::from_str("z42hL2jL4XNk6K8oHQaSWfMgCL7ji").unwrap();
-

-
    environment.repository(&alice);
-

-
    test(
-
        "examples/rad-init.md",
-
        environment.work(&alice),
-
        Some(&alice.home),
-
        [],
-
    )
-
    .unwrap();
-

-
    let mut alice = alice.spawn();
-
    let mut bob = bob.spawn();
-

-
    bob.handle.seed(acme, Scope::All).unwrap();
-
    alice.connect(&bob).converge([&bob]);
-

-
    bob.fork(acme, environment.work(&bob)).unwrap();
-

-
    formula(&environment.tempdir(), "examples/rad-id-missing-commits.md")
-
        .unwrap()
-
        .home(
-
            "alice",
-
            environment.work(&alice),
-
            [("RAD_HOME", alice.home.path().display())],
-
        )
-
        .home(
-
            "bob",
-
            environment.work(&bob).join("heartwood"),
-
            [("RAD_HOME", bob.home.path().display())],
-
        )
-
        .run()
-
        .unwrap();
-
}
-

-
#[test]
fn rad_id_update_delete_field() {
    Environment::alice(["rad-init", "rad-id-update-delete-field"]);
}
modified crates/radicle-node/src/worker/fetch.rs
@@ -122,9 +122,6 @@ impl Handle {
                            log::trace!(target: "worker", "Set HEAD to {}", head.new);
                        }
                    }
-
                    Err(RepositoryError::Quorum(
-
                        radicle::git::canonical::error::QuorumError::Git(e),
-
                    )) => return Err(e.into()),
                    Err(RepositoryError::Quorum(e)) => {
                        log::warn!(target: "worker", "Fetch could not set HEAD: {e}")
                    }
@@ -390,15 +387,19 @@ fn set_canonical_refs(repo: &Repository, applied: &Applied) -> Result<(), error:
        let name = name.strip_namespace();

        let canonical = match rules.canonical(name.clone(), repo) {
-
            Ok(Some(canonical)) => canonical,
-
            Ok(None) => continue,
-
            Err(e) => {
-
                log::warn!(target: "worker", "Failed to get canonical reference rule for {name}: {e}");
+
            Some(canonical) => canonical,
+
            None => continue,
+
        };
+

+
        let canonical = match canonical.find_objects() {
+
            Err(err) => {
+
                log::warn!(target: "worker", "Failed to find objects for canonical computation: {err}");
                continue;
            }
+
            Ok(canonical) => canonical,
        };

-
        match canonical.quorum(&repo.backend) {
+
        match canonical.quorum() {
            Err(err) => {
                log::warn!(
                    target: "worker",
@@ -406,7 +407,10 @@ fn set_canonical_refs(repo: &Repository, applied: &Applied) -> Result<(), error:
                );
                continue;
            }
-
            Ok((refname, _, oid)) => {
+
            Ok(git::canonical::Quorum {
+
                refname, object, ..
+
            }) => {
+
                let oid = object.id();
                if let Err(e) = repo.backend.reference(
                    refname.clone().as_str(),
                    *oid,
modified crates/radicle-remote-helper/src/push.rs
@@ -120,10 +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 },
+
    #[error(transparent)]
+
    FindObjects(#[from] git::canonical::error::FindObjectsError),
}

/// Push command.
@@ -284,7 +284,7 @@ pub fn run(
    let identity = stored.identity()?;
    let project = identity.project()?;
    let canonical_ref = git::refs::branch(project.default_branch());
-
    let mut set_canonical_refs: Vec<(git::Qualified, git::raw::ObjectType, git::Oid)> =
+
    let mut set_canonical_refs: Vec<(git::Qualified, git::canonical::Object)> =
        Vec::with_capacity(specs.len());
    let working = git::raw::Repository::open(working)?;

@@ -346,26 +346,27 @@ pub fn run(
                        let rules = crefs.rules();
                        let me = Did::from(nid);

+
                        let explorer =
+
                            push(src, &dst, *force, &nid, &working, stored, patches, &signer)?;
                        // If we're trying to update the canonical head, make sure
                        // we don't diverge from the current head. This only applies
                        // to repos with more than one delegate.
                        //
                        // 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()
-
                                .and_then(git::canonical::CanonicalObjectType::new)
+
                        if let Some(canonical) = rules.canonical(dst.clone(), stored) {
+
                            let object = working
+
                                .find_object(**src, None)
+
                                .map(|obj| git::canonical::Object::new(&obj))?
                                .ok_or(Error::UnknownObjectType { oid: *src })?;

-
                            let canonical = canonical::Canonical::new(me, *src, kind, canonical);
+
                            let canonical = canonical::Canonical::new(me, object, canonical)?;
                            match canonical.quorum(&working) {
                                Ok(quorum) => set_canonical_refs.push(quorum),
                                Err(e) => canonical::io::handle_error(e, &dst, hints)?,
                            }
                        }
-
                        push(src, &dst, *force, &nid, &working, stored, patches, &signer)
+
                        Ok(explorer)
                    }
                }
            }
@@ -386,7 +387,9 @@ pub fn run(
    if !ok.is_empty() {
        let _ = stored.sign_refs(&signer)?;

-
        for (refname, kind, oid) in &set_canonical_refs {
+
        for (refname, object) in &set_canonical_refs {
+
            let oid = object.id();
+
            let kind = object.object_type();
            let print_update = || {
                eprintln!(
                    "{} Canonical reference {} updated to target {kind} {}",
@@ -409,10 +412,10 @@ pub fn run(
            }

            match stored.backend.refname_to_id(refname.as_str()) {
-
                Ok(new) if new != **oid => {
+
                Ok(new) if new != *oid => {
                    stored.backend.reference(
                        refname.as_str(),
-
                        **oid,
+
                        *oid,
                        true,
                        "set-canonical-reference from git-push (radicle)",
                    )?;
@@ -421,7 +424,7 @@ pub fn run(
                Err(e) if e.code() == git::raw::ErrorCode::NotFound => {
                    stored.backend.reference(
                        refname.as_str(),
-
                        **oid,
+
                        *oid,
                        true,
                        "set-canonical-reference from git-push (radicle)",
                    )?;
modified crates/radicle-remote-helper/src/push/canonical.rs
@@ -1,36 +1,41 @@
use radicle::git;
+
use radicle::git::canonical;
+
use radicle::git::canonical::effects;
use radicle::git::raw::Repository;
use radicle::prelude::Did;

use super::error;

pub(crate) struct Vote {
-
    did: Did,
-
    oid: git::Oid,
-
    kind: git::canonical::CanonicalObjectType,
+
    object: canonical::Object,
+
}
+

+
impl Vote {
+
    pub(crate) fn id(&self) -> git::Oid {
+
        self.object.id()
+
    }
}

/// Validates a vote to update a canonical reference during push.
-
pub(crate) struct Canonical<'a, 'b> {
+
pub(crate) struct Canonical<'a, 'b, 'r, R> {
    vote: Vote,
-
    canonical: git::canonical::Canonical<'a, 'b>,
+
    canonical: canonical::CanonicalWithConvergence<'a, 'b, 'r, R>,
}

-
impl<'a, 'b> Canonical<'a, 'b> {
+
impl<'a, 'b, 'r, R> Canonical<'a, 'b, 'r, R>
+
where
+
    R: effects::Ancestry + effects::FindMergeBase + effects::FindObjects,
+
{
    pub fn new(
        me: Did,
-
        head: git::Oid,
-
        kind: git::canonical::CanonicalObjectType,
-
        canonical: git::canonical::Canonical<'a, 'b>,
-
    ) -> Self {
-
        Self {
-
            vote: Vote {
-
                did: me,
-
                oid: head,
-
                kind,
-
            },
-
            canonical,
-
        }
+
        object: canonical::Object,
+
        canonical: canonical::Canonical<'a, 'b, 'r, R, canonical::Initial>,
+
    ) -> Result<Self, canonical::error::FindObjectsError> {
+
        let canonical = canonical.find_objects()?;
+
        Ok(Self {
+
            vote: Vote { object },
+
            canonical: canonical.with_convergence(me, object),
+
        })
    }

    /// Calculates the quorum of the [`git::canonical::Canonical`] provided.
@@ -51,31 +56,26 @@ impl<'a, 'b> Canonical<'a, 'b> {
    ///
    /// [`head`]: crate::push::canonical::Canonical::head
    pub fn quorum(
-
        mut self,
+
        self,
        working: &Repository,
-
    ) -> Result<(git::Qualified<'a>, git::raw::ObjectType, git::Oid), error::Canonical> {
-
        let converges = self
-
            .canonical
-
            .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);
-
        }
-

-
        match self.canonical.quorum(working) {
-
            Ok((cref, quorum_type, quorum_head)) => {
+
    ) -> Result<(git::Qualified<'a>, canonical::Object), error::Canonical> {
+
        match self.canonical.quorum() {
+
            Ok(result) => {
+
                let canonical::QuorumWithConvergence { quorum, converges } = result;
+
                let canonical::Quorum { refname, object } = quorum;
+
                let quorum_head = object.id();
                // Canonical head is an ancestor of head.
-
                let is_ff = self.vote.oid == quorum_head
+
                let is_ff = self.vote.id() == quorum_head
                    || working
-
                        .graph_descendant_of(*self.vote.oid, *quorum_head)
+
                        .graph_descendant_of(*self.vote.id(), *quorum_head)
                        .map_err(|err| {
-
                            error::Canonical::graph_descendant(self.vote.oid, quorum_head, err)
+
                            error::Canonical::graph_descendant(self.vote.id(), quorum_head, err)
                        })?;

                if !is_ff && !converges {
-
                    Err(error::Canonical::heads_diverge(self.vote.oid, quorum_head))
+
                    Err(error::Canonical::heads_diverge(self.vote.id(), quorum_head))
                } else {
-
                    Ok((cref, quorum_type, quorum_head))
+
                    Ok((refname, object))
                }
            }
            Err(err) => Err(err.into()),
@@ -100,7 +100,6 @@ pub(crate) mod io {
    ) -> Result<(), error::CanonicalUnrecoverable> {
        match e {
            error::Canonical::GraphDescendant(e) => Err(e.into()),
-
            error::Canonical::Converges(e) => Err(e.into()),
            error::Canonical::HeadsDiverge(e) => {
                if hints {
                    hint(
@@ -114,31 +113,32 @@ pub(crate) mod io {
                }
                Err(e.into())
            }
-
            error::Canonical::Quorum(e) => match e {
-
                e @ QuorumError::DivergingCommits { .. } => {
-
                    warn(e.to_string());
-
                    warn("it is recommended to find a commit to agree upon");
-
                    Ok(())
+
            error::Canonical::Quorum(e) => {
+
                match e {
+
                    QuorumError::Convergence(err) => Err(err.into()),
+
                    QuorumError::MergeBase(err) => Err(err.into()),
+
                    e @ QuorumError::DivergingCommits { .. } => {
+
                        warn(e.to_string());
+
                        warn("it is recommended to find a commit to agree upon");
+
                        Ok(())
+
                    }
+
                    e @ QuorumError::DivergingTags { .. } => {
+
                        warn(e.to_string());
+
                        warn("it is recommended to find a tag to agree upon");
+
                        Ok(())
+
                    }
+
                    e @ QuorumError::DifferentTypes { .. } => {
+
                        warn(e.to_string());
+
                        warn("it is recommended to find an object type (either commit or tag) to agree upon");
+
                        Ok(())
+
                    }
+
                    e @ QuorumError::NoCandidates { .. } => {
+
                        warn(e.to_string());
+
                        warn("it is recommended to find an object (either commit or tag) to agree upon");
+
                        Ok(())
+
                    }
                }
-
                e @ QuorumError::DivergingTags { .. } => {
-
                    warn(e.to_string());
-
                    warn("it is recommended to find a tag to agree upon");
-
                    Ok(())
-
                }
-
                e @ QuorumError::DifferentTypes { .. } => {
-
                    warn(e.to_string());
-
                    warn("it is recommended to find an object type (either commit or tag) to agree upon");
-
                    Ok(())
-
                }
-
                e @ QuorumError::NoCandidates { .. } => {
-
                    warn(e.to_string());
-
                    warn(
-
                        "it is recommended to find an object (either commit or tag) to agree upon",
-
                    );
-
                    Ok(())
-
                }
-
                QuorumError::Git(err) => Err(error::CanonicalUnrecoverable::Git { source: err }),
-
            },
+
            }
        }
    }
}
modified crates/radicle-remote-helper/src/push/error.rs
@@ -9,6 +9,10 @@ pub enum CanonicalUnrecoverable {
    #[error(transparent)]
    Converges(#[from] canonical::error::ConvergesError),
    #[error(transparent)]
+
    MergeBase(#[from] canonical::error::MergeBaseError),
+
    #[error(transparent)]
+
    FindObjects(#[from] canonical::error::FindObjectsError),
+
    #[error(transparent)]
    HeadsDiverge(#[from] HeadsDiverge),
    #[error("failure while computing canonical reference: {source}")]
    Git { source: git::raw::Error },
@@ -19,8 +23,6 @@ pub enum Canonical {
    #[error(transparent)]
    GraphDescendant(GraphDescendant),
    #[error(transparent)]
-
    Converges(#[from] canonical::error::ConvergesError),
-
    #[error(transparent)]
    HeadsDiverge(HeadsDiverge),
    #[error(transparent)]
    Quorum(#[from] canonical::error::QuorumError),
modified crates/radicle/src/git/canonical.rs
@@ -1,564 +1,462 @@
pub mod error;
use error::*;

+
mod convergence;
+
use convergence::Convergence;
+

+
mod quorum;
+
use quorum::{CommitQuorum, CommitQuorumFailure, TagQuorum, TagQuorumFailure};
+

+
mod voting;
+

+
pub mod effects;
pub mod rules;

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

-
use std::collections::BTreeMap;
+
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
+
use std::marker::PhantomData;
+
use std::ops::ControlFlow;

-
use raw::ObjectType;
-
use raw::Repository;
+
use git_ext::ref_format::Namespaced;

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

-
use super::raw;
use super::{Oid, Qualified};

-
/// A collection of [`Did`]s and their [`Oid`]s that is the tip for a given
-
/// reference for that [`Did`].
-
///
-
/// The general construction of `Canonical` is by using the [`Canonical::new`]
-
/// constructor.
+
/// A marker for the initial state of [`Canonical`], after construction using
+
/// [`Canonical::new`].
+
pub struct Initial;
+

+
/// A marker for the state of [`Canonical`] once it has found objects for the
+
/// calculation, using [`Canonical::find_objects`].
+
pub struct ObjectsFound;
+

+
/// [`Canonical`] captures the state for finding the quorum between a set of
+
/// [`Did`]s and their references, attempting to agree on a Git commit or tag.
///
-
/// `Canonical` can then be used for performing calculations about the
-
/// canonicity of the reference, most importantly the [`Canonical::quorum`].
+
/// Construction happens through [`Canonical::new`], at which point you must use
+
/// [`Canonical::find_objects`] so that the state has the set of [`Object`]s it
+
/// must use for the quorum calculation.
///
-
/// References to the refname and the matched rule are kept, as they
-
/// are very handy for generating error messages.
-
#[derive(Debug)]
-
pub struct Canonical<'a, 'b> {
+
/// At this point, the caller can either call [`Canonical::quorum`] to find the
+
/// quorum, or modify the calculation by produce a [`CanonicalWithConvergence`]
+
/// using [`Canonical::with_convergence`], and then using
+
/// [`CanonicalWithConvergence::quorum`].
+
pub struct Canonical<'a, 'b, 'r, R, T> {
    refname: Qualified<'a>,
    rule: &'b ValidRule,
-
    tips: BTreeMap<Did, (Oid, CanonicalObjectType)>,
+
    repo: &'r R,
+
    objects: BTreeMap<Did, Object>,
+
    missing: Missing,
+
    _marker: PhantomData<T>,
}

-
/// Supported Git object types for canonical computation.
-
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-
pub enum CanonicalObjectType {
-
    /// The Git object is a commit.
-
    Commit,
-
    /// The Git object is a tag.
-
    Tag,
+
impl<'a, 'b, 'r, R, T> fmt::Debug for Canonical<'a, 'b, 'r, R, T> {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        f.debug_struct("Canonical")
+
            .field("refname", &self.refname)
+
            .field("rule", &self.rule)
+
            .field("objects", &self.objects)
+
            .field("missing", &self.missing)
+
            .finish()
+
    }
}

-
impl fmt::Display for CanonicalObjectType {
+
/// Similar to [`Canonical`], however it checks that a new vote of a single
+
/// [`Did`] converges with any other [`Did`], to then see if that provides a
+
/// different quorum result.
+
pub struct CanonicalWithConvergence<'a, 'b, 'r, R> {
+
    canonical: Canonical<'a, 'b, 'r, R, ObjectsFound>,
+
    convergence: Convergence<'r, R>,
+
}
+

+
impl<'a, 'b, 'r, R> fmt::Debug for CanonicalWithConvergence<'a, 'b, 'r, R> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-
        match self {
-
            CanonicalObjectType::Commit => f.write_str("commit"),
-
            CanonicalObjectType::Tag => f.write_str("tag"),
-
        }
+
        f.debug_struct("CanonicalWithConvergence")
+
            .field("canonical", &self.canonical)
+
            .field("convergence", &self.convergence)
+
            .finish()
    }
}

-
impl CanonicalObjectType {
-
    /// Construct the [`CanonicalObjectType`] 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,
-
        }
+
impl<'a, 'b, 'r, R> AsRef<Canonical<'a, 'b, 'r, R, ObjectsFound>>
+
    for CanonicalWithConvergence<'a, 'b, 'r, R>
+
{
+
    fn as_ref(&self) -> &Canonical<'a, 'b, 'r, R, ObjectsFound> {
+
        &self.canonical
    }
}

-
impl<'a, 'b> Canonical<'a, 'b> {
-
    /// Construct the set of canonical tips given for the given `rule` and
-
    /// the reference `refname`.
-
    pub fn new(
-
        repo: &Repository,
-
        refname: Qualified<'a>,
-
        rule: &'b ValidRule,
-
    ) -> 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) {
-
                Ok(reference) => reference,
-
                Err(e) if super::ext::is_not_found_err(&e) => {
-
                    log::warn!(
-
                        target: "radicle",
-
                        "Missing `{name}` while calculating the canonical reference",
-
                    );
-
                    continue;
-
                }
-
                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 kind = Self::find_object_for(delegate, oid.into(), repo)?;
-

-
            tips.insert(*delegate, (oid.into(), kind));
-
        }
-
        Ok(Canonical {
+
impl<'a, 'b, 'r, R> Canonical<'a, 'b, 'r, R, Initial>
+
where
+
    R: effects::Ancestry + effects::FindMergeBase + effects::FindObjects,
+
{
+
    /// Construct a new [`Canonical`] with the given [`Qualified`] reference, a
+
    /// canonical reference [`ValidRule`] for that reference, and the Git
+
    /// repository to load and check the Git data.
+
    pub fn new(refname: Qualified<'a>, rule: &'b ValidRule, repo: &'r R) -> Self {
+
        Self {
            refname,
-
            tips,
            rule,
-
        })
-
    }
-

-
    pub fn find_object_for(
-
        did: &Did,
-
        oid: Oid,
-
        repo: &raw::Repository,
-
    ) -> Result<CanonicalObjectType, CanonicalError> {
-
        match repo.find_object(*oid, None) {
-
            Ok(object) => object
-
                .kind()
-
                .and_then(CanonicalObjectType::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)),
+
            repo,
+
            missing: Missing::default(),
+
            objects: BTreeMap::new(),
+
            _marker: PhantomData,
        }
    }

-
    /// Returns `true` if there were no tips found for any of the DIDs for
-
    /// the given reference.
-
    ///
-
    /// N.b. this may be the case when a new reference is being created.
-
    pub fn has_no_tips(&self) -> bool {
-
        self.tips.is_empty()
-
    }
-

-
    pub fn refname(&self) -> &Qualified {
-
        &self.refname
+
    /// Find the objects for the [`Canonical`] computation, and prepare it to
+
    /// find the quorum.
+
    pub fn find_objects(
+
        self,
+
    ) -> Result<Canonical<'a, 'b, 'r, R, ObjectsFound>, effects::FindObjectsError> {
+
        let FoundObjects {
+
            objects,
+
            missing_refs,
+
            missing_objects,
+
        } = self
+
            .repo
+
            .find_objects(&self.refname, self.rule.allowed().iter())?;
+
        let missing = Missing {
+
            refs: missing_refs,
+
            objects: missing_objects,
+
        };
+
        Ok(Canonical {
+
            refname: self.refname,
+
            rule: self.rule,
+
            repo: self.repo,
+
            missing,
+
            objects,
+
            _marker: PhantomData,
+
        })
    }
+
}

-
    /// 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, oid: Oid, kind: CanonicalObjectType) {
-
        self.tips.insert(did, (oid, kind));
+
impl<'a, 'b, 'r, R> Canonical<'a, 'b, 'r, R, ObjectsFound>
+
where
+
    R: effects::Ancestry + effects::FindMergeBase + effects::FindObjects,
+
{
+
    /// Adds the check for convergence before finding the quorum.
+
    pub fn with_convergence(
+
        self,
+
        candidate: Did,
+
        object: Object,
+
    ) -> CanonicalWithConvergence<'a, 'b, 'r, R> {
+
        let convergence = Convergence::new(self.repo, candidate, object);
+
        CanonicalWithConvergence {
+
            canonical: self,
+
            convergence,
+
        }
    }

-
    /// Check that the provided `did` is part of the set of allowed
-
    /// DIDs of the matching rule.
-
    pub fn is_allowed(&self, did: &Did) -> bool {
-
        self.rule.allowed().contains(did)
+
    /// Find the [`Quorum`] for the canonical computation.
+
    pub fn quorum(self) -> Result<Quorum<'a>, QuorumError> {
+
        let mut finder = QuorumFinder::new(self.refname, self.rule, self.objects.values());
+
        while let ControlFlow::Continue(pairs) = finder.find_merge_bases() {
+
            let mut bases = Vec::with_capacity(pairs.size_hint().0);
+
            for (a, b) in pairs {
+
                bases.push(self.repo.merge_base(a, b)?);
+
            }
+
            finder.found_merge_bases(bases.into_iter());
+
        }
+
        let refname = finder.refname.clone();
+
        let threshold = (*finder.rule.threshold()).into();
+
        let results = finder.find_quorum();
+
        match results {
+
            (Ok(commit), Err(_)) => Ok(commit),
+
            (Err(_), Ok(tag)) => Ok(tag),
+
            (Ok(_), Ok(_)) => Err(QuorumError::DifferentTypes {
+
                refname: refname.to_string(),
+
            }),
+
            (Err(ec), Err(eq)) => Err(Self::convert_failures(
+
                ec,
+
                eq,
+
                refname.to_string(),
+
                threshold,
+
            )),
+
        }
    }

-
    /// Check that the provided `did` is the only DID in the set of allowed
-
    /// DIDs of the matching rule.
-
    pub fn is_only(&self, did: &Did) -> bool {
-
        self.rule.allowed().is_only(did)
+
    /// If there are [`Missing`] objects, these may be reported by the caller,
+
    /// and if further objects are found, then these can be marked using
+
    /// [`Canonical::found_objects`].
+
    pub fn missing(&self) -> &Missing {
+
        &self.missing
    }

-
    /// Checks that setting the given candidate tip would converge with at least
-
    /// one other known tip.
-
    ///
-
    /// It converges if the candidate Oid is either equal to, ahead of, or behind any of
-
    /// the tips.
-
    pub fn converges(
-
        &self,
-
        repo: &Repository,
-
        (candidate, commit): (&Did, &Oid),
-
    ) -> Result<bool, ConvergesError> {
-
        /// Ensures [`Oid`]s are of the same object type
-
        enum Objects {
-
            Commits(NonEmpty<Oid>),
-
            Tags(NonEmpty<Oid>),
+
    /// Mark the `objects` provided as found, removing them from the [`Missing`]
+
    /// set.
+
    pub fn found_objects(&mut self, objects: impl IntoIterator<Item = (Did, Object)>) {
+
        let objects = objects.into_iter();
+
        for (did, object) in objects {
+
            self.missing.found(&did, &self.refname);
+
            self.objects.insert(did, object);
        }
+
    }

-
        impl Objects {
-
            fn new(oid: Oid, kind: CanonicalObjectType) -> Self {
-
                match kind {
-
                    CanonicalObjectType::Commit => Self::Commits(NonEmpty::new(oid)),
-
                    CanonicalObjectType::Tag => Self::Tags(NonEmpty::new(oid)),
+
    fn convert_failures(
+
        commit: CommitQuorumFailure,
+
        tag: TagQuorumFailure,
+
        refname: String,
+
        threshold: usize,
+
    ) -> QuorumError {
+
        match (commit, tag) {
+
            (CommitQuorumFailure::NoCandidates, TagQuorumFailure::NoCandidates) => {
+
                QuorumError::NoCandidates { refname, threshold }
+
            }
+
            (CommitQuorumFailure::NoCandidates, TagQuorumFailure::DivergingTags { candidates }) => {
+
                QuorumError::DivergingTags {
+
                    refname,
+
                    threshold,
+
                    candidates,
                }
            }
+
            (
+
                CommitQuorumFailure::DivergingCommits {
+
                    base,
+
                    longest,
+
                    candidate,
+
                },
+
                _,
+
            ) => QuorumError::DivergingCommits {
+
                refname,
+
                threshold,
+
                base,
+
                longest,
+
                head: candidate,
+
            },
+
            (CommitQuorumFailure::NoMergeBase { a, b }, _) => {
+
                #[derive(thiserror::Error, Debug)]
+
                #[error("no existing merge base found for commit quorum")]
+
                struct NoMergeBase;

-
            fn insert(
-
                mut self,
-
                oid: Oid,
-
                kind: CanonicalObjectType,
-
            ) -> Result<Self, CanonicalObjectType> {
-
                match self {
-
                    Objects::Commits(ref mut commits) => match kind {
-
                        CanonicalObjectType::Commit => {
-
                            commits.push(oid);
-
                            Ok(self)
-
                        }
-
                        CanonicalObjectType::Tag => Err(CanonicalObjectType::Tag),
-
                    },
-
                    Objects::Tags(ref mut tags) => match kind {
-
                        CanonicalObjectType::Commit => {
-
                            tags.push(oid);
-
                            Ok(self)
-
                        }
-
                        CanonicalObjectType::Tag => Err(CanonicalObjectType::Commit),
-
                    },
-
                }
+
                effects::MergeBaseError::new(a, b, NoMergeBase).into()
            }
        }
+
    }
+
}

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

-
            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,
-
                            )
-
                        })?)
-
                    }
+
impl<'a, 'b, 'r, R> CanonicalWithConvergence<'a, 'b, 'r, R>
+
where
+
    R: effects::Ancestry + effects::FindMergeBase + effects::FindObjects,
+
{
+
    /// Find the [`QuorumWithConvergence`] for the canonical computation.
+
    pub fn quorum(mut self) -> Result<QuorumWithConvergence<'a>, QuorumError> {
+
        let converges = match self.convergence.check(self.canonical.objects.iter())? {
+
            Some((candidate, object)) => {
+
                if self.canonical.objects.is_empty()
+
                    || self.canonical.rule.allowed().is_only(&candidate)
+
                {
+
                    self.canonical.objects.insert(candidate, object);
                }
+
                true
            }
-

-
            objects
+
            None => false,
        };
-

-
        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)
-
            }
-
        }
+
        let quorum = self.canonical.quorum()?;
+
        Ok(QuorumWithConvergence { quorum, converges })
    }
+
}

-
    fn quorum_tag(&self) -> Result<Oid, QuorumError> {
-
        let voting =
-
            TagVoting::from_targets(self.tips.values().filter_map(|(commit, kind)| {
-
                (*kind == CanonicalObjectType::Tag).then_some(*commit)
-
            }));
-
        let mut votes = voting.votes();
+
/// The result of finding a quorum.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Quorum<'a> {
+
    /// The reference the quorum has been found for.
+
    pub refname: Qualified<'a>,
+
    /// The object the reference should be updated to.
+
    pub object: Object,
+
}

-
        // Keep tags which pass the threshold.
-
        votes.votes_past_threshold(self.threshold());
+
/// Similar to [`Quorum`], but also reports whether the candidate converged with
+
/// one of the other voters.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct QuorumWithConvergence<'a> {
+
    pub quorum: Quorum<'a>,
+
    pub converges: bool,
+
}

-
        if votes.number_of_candidates() > 1 {
-
            return Err(QuorumError::DivergingTags {
-
                refname: self.refname.to_string(),
-
                threshold: self.threshold(),
-
                candidates: votes.candidates().cloned().collect(),
-
            });
-
        }
+
/// Helper to perform the quorum check for both a [`TagQuorum`] and
+
/// [`CommitQuorum`].
+
#[derive(Debug)]
+
struct QuorumFinder<'a, 'b> {
+
    refname: Qualified<'a>,
+
    rule: &'b ValidRule,
+
    tag_quorum: TagQuorum,
+
    commit_quorum: CommitQuorum,
+
}

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

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

-
    /// Computes the quorum or "canonical" tip based on the tips, of `Canonical`,
-
    /// and the threshold. This can be described as the latest commit that is
-
    /// included in at least `threshold` histories. In case there are multiple tips
-
    /// passing the threshold, and they are divergent, an error is returned.
-
    ///
-
    /// 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 voting =
-
            CommitVoting::from_targets(self.tips.values().filter_map(|(commit, kind)| {
-
                (*kind == CanonicalObjectType::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.
-
        votes.votes_past_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 votes.candidates() {
-
            let base = repo.merge_base(**head, *longest)?;
-

-
            if base == *longest {
-
                // `head` is a successor of `longest`. Update `longest`.
-
                //
-
                //   o head
-
                //   |
-
                //   o longest (base)
-
                //   |
-
                //
-
                longest = *head;
-
            } else if base == **head || *head == longest {
-
                // `head` is an ancestor of `longest`, or equal to it. Do nothing.
-
                //
-
                //   o longest             o longest, head (base)
-
                //   |                     |
-
                //   o head (base)   OR    o
-
                //   |                     |
-
                //
-
            } else {
-
                // The merge base between `head` and `longest` (`base`)
-
                // is neither `head` nor `longest`. Therefore, the branches have
-
                // diverged.
-
                //
-
                //    longest   head
-
                //           \ /
-
                //            o (base)
-
                //            |
-
                //
-
                return Err(QuorumError::DivergingCommits {
-
                    refname: self.refname.to_string(),
-
                    threshold: self.threshold(),
-
                    base: base.into(),
-
                    longest,
-
                    head: *head,
-
                });
-
            }
+
impl<'a, 'b> QuorumFinder<'a, 'b> {
+
    fn new<'c, I>(refname: Qualified<'a>, rule: &'b ValidRule, objects: I) -> Self
+
    where
+
        I: Iterator<Item = &'c Object> + Clone,
+
    {
+
        let threshold = *rule.threshold();
+
        let tag_quorum = TagQuorum::new(objects.clone(), threshold.into());
+
        let commit_quorum = CommitQuorum::new(objects, threshold.into());
+
        Self {
+
            refname,
+
            rule,
+
            tag_quorum,
+
            commit_quorum,
        }
-

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

-
    /// Computes the quorum or "canonical" tip based on the tips, of `Canonical`,
-
    /// and the threshold. This can be described as the latest commit that is
-
    /// included in at least `threshold` histories. In case there are multiple tips
-
    /// passing the threshold, and they are divergent, an error is returned.
-
    ///
-
    /// Also returns an error if `heads` is empty or `threshold` cannot be
-
    /// satisified with the number of heads given.
-
    pub fn quorum(
-
        self,
-
        repo: &raw::Repository,
-
    ) -> Result<(Qualified<'a>, ObjectType, Oid), QuorumError> {
-
        let (oid, kind) = match (self.quorum_commit(repo), self.quorum_tag()) {
-
            (Ok(commit), Err(_)) => Ok((commit, ObjectType::Commit)),
-
            (Err(_), Ok(tag)) => Ok((tag, ObjectType::Tag)),
-
            (Ok(_), Ok(_)) => Err(QuorumError::DifferentTypes {
-
                refname: self.refname.clone().to_string(),
-
            }),
-
            (Err(commit), Err(QuorumError::NoCandidates { .. })) => Err(commit),
-
            (Err(QuorumError::NoCandidates { .. }), Err(tag)) => Err(tag),
-
            (Err(err), _) => Err(err),
-
        }?;
-

-
        Ok((self.refname, kind, oid))
+
    fn find_merge_bases(&mut self) -> ControlFlow<(), impl Iterator<Item = (Oid, Oid)>> {
+
        match self.commit_quorum.next_candidate() {
+
            Some(candidate) => ControlFlow::Continue(candidate),
+
            None => ControlFlow::Break(()),
+
        }
    }

-
    fn threshold(&self) -> usize {
-
        (*self.rule.threshold()).into()
+
    fn found_merge_bases<I>(&mut self, bases: I)
+
    where
+
        I: Iterator<Item = MergeBase>,
+
    {
+
        self.commit_quorum.found_merge_bases(bases);
    }
-
}
-

-
/// 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
+
    fn find_quorum(
+
        self,
+
    ) -> (
+
        Result<Quorum<'a>, quorum::CommitQuorumFailure>,
+
        Result<Quorum<'a>, quorum::TagQuorumFailure>,
+
    ) {
+
        let commit = self.commit_quorum.find_quorum().map(|id| Quorum {
+
            refname: self.refname.clone(),
+
            object: Object::Commit { id },
        });
-
        Self { votes }
-
    }
-

-
    fn votes(self) -> Votes {
-
        self.votes
+
        let tag = self.tag_quorum.find_quorum().map(|id| Quorum {
+
            refname: self.refname.clone(),
+
            object: Object::Tag { id },
+
        });
+
        (commit, tag)
    }
}

-
/// 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,
+
/// Record a merge base between `a` and `b`.
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+
pub struct MergeBase {
+
    /// The first commit for the merge base.
+
    pub a: Oid,
+
    /// The second commit that is being compared against for the merge base.
+
    pub b: Oid,
+
    /// The computed merge base commit.
+
    pub base: Oid,
}

-
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)
-
            },
-
        );
-
        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);
+
impl MergeBase {
+
    /// The merge base of the same commit is the commit itself.
+
    pub fn refl(oid: Oid) -> Self {
+
        Self {
+
            a: oid,
+
            b: oid,
+
            base: oid,
        }
    }
-

-
    /// 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,
+
/// The supported type of Git object and its [`Oid`], for canonical computation.
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+
pub enum Object {
+
    Commit { id: Oid },
+
    Tag { id: 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);
+
impl Object {
+
    pub fn new(obj: &crate::git::raw::Object) -> Option<Self> {
+
        obj.kind().and_then(|kind| match kind {
+
            crate::git::raw::ObjectType::Commit => Some(Self::Commit {
+
                id: obj.id().into(),
+
            }),
+
            crate::git::raw::ObjectType::Tag => Some(Self::Tag {
+
                id: obj.id().into(),
+
            }),
+
            _ => None,
+
        })
    }

-
    /// 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);
+
    /// The [`Oid`] of the [`Object`]
+
    pub fn id(&self) -> Oid {
+
        match self {
+
            Object::Commit { id } => *id,
+
            Object::Tag { id } => *id,
+
        }
    }

-
    /// Get the number of candidates this set of votes has.
-
    #[inline]
-
    fn number_of_candidates(&self) -> usize {
-
        self.inner.len()
+
    /// Checks if the object is a Git commit.
+
    pub fn is_commit(&self) -> bool {
+
        matches!(self, Self::Commit { .. })
    }

-
    /// Get the set candidates.
-
    #[inline]
-
    fn candidates(&self) -> impl Iterator<Item = &Oid> {
-
        self.inner.keys()
+
    /// Checks if the object is a Git tag.
+
    pub fn is_tag(&self) -> bool {
+
        matches!(self, Self::Commit { .. })
    }

-
    /// 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)
+
    /// Returns the [`ObjectType`] of the [`Object`].
+
    pub fn object_type(&self) -> ObjectType {
+
        match self {
+
            Object::Commit { .. } => ObjectType::Commit,
+
            Object::Tag { .. } => ObjectType::Tag,
+
        }
    }
+
}

-
    #[inline]
-
    fn safe_inc(&mut self, oid: Oid, n: u8) {
-
        let votes = self.inner.entry(oid).or_default();
-
        *votes = votes.saturating_add(n);
+
/// Supported Git object types for canonical computation
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+
pub enum ObjectType {
+
    /// The Git object corresponds to a commit.
+
    Commit,
+
    /// The Git object corresponds to a tag.
+
    Tag,
+
}
+

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

-
fn find_object_for(
-
    did: &Did,
-
    oid: Oid,
-
    repo: &raw::Repository,
-
) -> Result<CanonicalObjectType, FindObjectError> {
-
    match repo.find_object(*oid, None) {
-
        Ok(object) => object
-
            .kind()
-
            .and_then(CanonicalObjectType::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)),
+
/// The result of checking the relationship between two commits in the Git graph.
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+
pub struct GraphAheadBehind {
+
    /// The number of commits the given commit is ahead of the other.
+
    pub ahead: usize,
+
    /// The number of commits the given commit is behind the other.
+
    pub behind: usize,
+
}
+

+
/// The result of finding a set of objects in a Git repository.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct FoundObjects {
+
    /// The found objects, and under which [`Did`] they were found.
+
    pub objects: BTreeMap<Did, Object>,
+
    /// Any missing references while attempting to find the objects.
+
    pub missing_refs: BTreeSet<Namespaced<'static>>,
+
    // TODO(finto): I think this doesn't make sense now that we use only one
+
    // repository.
+
    /// Any missing objects, where the reference was found, but the object was
+
    /// missing.
+
    pub missing_objects: BTreeMap<Did, Oid>,
+
}
+

+
/// [`Missing`] marks whether there were any missing references or objects.
+
#[derive(Clone, Debug, Default, PartialEq, Eq)]
+
pub struct Missing {
+
    pub refs: BTreeSet<Namespaced<'static>>,
+
    pub objects: BTreeMap<Did, Oid>,
+
}
+

+
impl Missing {
+
    fn found<'a>(&mut self, did: &Did, refname: &Qualified<'a>) {
+
        self.objects.remove(did);
+
        self.refs
+
            .remove(&refname.with_namespace((did.as_key()).into()).to_owned());
    }
}

@@ -578,39 +476,43 @@ mod tests {
        threshold: usize,
        repo: &git::raw::Repository,
    ) -> Result<Oid, QuorumError> {
-
        let tips: BTreeMap<Did, (Oid, CanonicalObjectType)> = 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()
-
                    .and_then(CanonicalObjectType::new)
-
                    .unwrap();
-
                (did, ((*head).into(), kind))
-
            })
-
            .collect();
-

        let refname =
            git::refs::branch(git_ext::ref_format::RefStr::try_from_str("master").unwrap());

+
        let mut delegates = Vec::new();
+
        for (i, head) in heads.iter().enumerate() {
+
            let signer = Device::mock_from_seed([(i + 1) as u8; 32]);
+
            let did = Did::from(signer.public_key());
+
            delegates.push(did);
+
            let ns = git::Component::from(signer.public_key());
+
            repo.reference(refname.with_namespace(ns).as_str(), *head, true, "")
+
                .unwrap();
+
        }
+

        let rule: RawRule = crate::git::canonical::rules::Rule::new(
            crate::git::canonical::rules::Allowed::Delegates,
            threshold,
        );
-
        let delegates = crate::identity::doc::Delegates::new(tips.keys().cloned()).unwrap();
+
        let delegates = crate::identity::doc::Delegates::new(delegates).unwrap();
        let rule = rule.validate(&mut || delegates.clone()).unwrap();

-
        Canonical {
-
            refname,
-
            tips,
-
            rule: &rule,
+
        Canonical::new(refname, &rule, repo)
+
            .find_objects()
+
            .unwrap()
+
            .quorum()
+
            .map(|Quorum { object, .. }| object.id())
+
    }
+

+
    fn commit(id: &str) -> Object {
+
        Object::Commit {
+
            id: id.parse().unwrap(),
+
        }
+
    }
+

+
    fn tag(id: &str) -> Object {
+
        Object::Tag {
+
            id: id.parse().unwrap(),
        }
-
        .quorum(repo)
-
        .map(|(_, _, oid)| oid)
    }

    #[test]
@@ -648,202 +550,625 @@ mod tests {
    }

    #[test]
-
    fn test_quorum_groups() {
+
    fn test_quorum_different_types() {
        let tmp = tempfile::tempdir().unwrap();
        let (repo, c0) = fixtures::repository(tmp.path());
        let c0: git::Oid = c0.into();
-
        let c1 = fixtures::commit("C1", &[*c0], &repo);
-
        let c2 = fixtures::commit("C2", &[*c0], &repo);
+
        let t0 = fixtures::tag("v1", "", *c0, &repo);

-
        eprintln!("C0: {c0}");
-
        eprintln!("C1: {c1}");
-
        eprintln!("C2: {c2}");
+
        assert_matches!(
+
            quorum(&[*c0, *t0], 1, &repo),
+
            Err(QuorumError::DifferentTypes { .. })
+
        );
+
    }

+
    #[test]
+
    fn test_commit_quorum_groups() {
+
        let c0 = commit("f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354");
+
        let c1 = commit("bfb1a513e420eade90b0e6be64117b861b16ecb5");
+
        let c2 = commit("8fc5160702365f231c77732a8fa162379e54f57a");
+

+
        //   C1  C2
+
        //    \ /
+
        //     C0
+

+
        let mut cq = CommitQuorum::new([c1, c2, c1, c2].iter(), 2);
+
        cq.found_merge_bases([MergeBase {
+
            a: c1.id(),
+
            b: c2.id(),
+
            base: c0.id(),
+
        }]);
        assert_matches!(
-
            quorum(&[*c1, *c2, *c1, *c2], 2, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );

+
        let mut cq = CommitQuorum::new([c1, c2].iter(), 1);
+
        cq.found_merge_bases([MergeBase {
+
            a: c1.id(),
+
            b: c2.id(),
+
            base: c0.id(),
+
        }]);
        assert_matches!(
-
            quorum(&[*c1, *c2], 1, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );
    }

    #[test]
-
    fn test_quorum_tag() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let (repo, c0) = fixtures::repository(tmp.path());
-
        let c0: git::Oid = c0.into();
-
        let c1 = fixtures::commit("C1", &[*c0], &repo);
-
        let t1 = fixtures::tag("v1", "T1", *c1, &repo);
-
        let t2 = fixtures::tag("v2", "T2", *c1, &repo);
-

-
        eprintln!("C0: {c0}");
-
        eprintln!("C1: {c1}");
-
        eprintln!("T1: {t1}");
-
        eprintln!("T2: {t2}");
+
    fn test_tag_quorum() {
+
        let t1 = tag("0480391dd7312d35c79a455ec5d004657260b358");
+
        let t2 = tag("a2eec713ec5c287ecdf13a0180f68acfef7962d0");

-
        assert_eq!(quorum(&[*t1], 1, &repo).unwrap(), t1);
-
        assert_eq!(quorum(&[*t1, *t1], 2, &repo).unwrap(), t1);
-

-
        assert_matches!(
-
            quorum(&[*t1, *t2], 2, &repo),
-
            Err(QuorumError::NoCandidates { .. })
+
        assert_eq!(
+
            TagQuorum::new([t1].iter(), 1).find_quorum().unwrap(),
+
            t1.id()
        );
-

-
        assert_matches!(
-
            quorum(&[*t1, *c1], 1, &repo),
-
            Err(QuorumError::DifferentTypes { .. })
+
        assert_eq!(
+
            TagQuorum::new([t1, t1].iter(), 2).find_quorum().unwrap(),
+
            t1.id()
        );
-

        assert_matches!(
-
            quorum(&[*t1, *t2], 1, &repo),
-
            Err(QuorumError::DivergingTags { .. })
+
            TagQuorum::new([t1, t2].iter(), 1).find_quorum(),
+
            Err(TagQuorumFailure::DivergingTags { .. })
        );
    }

    #[test]
-
    fn test_quorum() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let (repo, c0) = fixtures::repository(tmp.path());
-
        let c0: git::Oid = c0.into();
-
        let c1 = fixtures::commit("C1", &[*c0], &repo);
-
        let c2 = fixtures::commit("C2", &[*c1], &repo);
-
        let c3 = fixtures::commit("C3", &[*c1], &repo);
-
        let b2 = fixtures::commit("B2", &[*c1], &repo);
-
        let a1 = fixtures::commit("A1", &[*c0], &repo);
-
        let m1 = fixtures::commit("M1", &[*c2, *b2], &repo);
-
        let m2 = fixtures::commit("M2", &[*a1, *b2], &repo);
-

-
        eprintln!("C0: {c0}");
-
        eprintln!("C1: {c1}");
-
        eprintln!("C2: {c2}");
-
        eprintln!("C3: {c3}");
-
        eprintln!("B2: {b2}");
-
        eprintln!("A1: {a1}");
-
        eprintln!("M1: {m1}");
-
        eprintln!("M2: {m2}");
-

-
        assert_eq!(quorum(&[*c0], 1, &repo).unwrap(), c0);
-
        assert_eq!(quorum(&[*c1], 1, &repo).unwrap(), c1);
-
        assert_eq!(quorum(&[*c2], 1, &repo).unwrap(), c2);
-

-
        //  C1
-
        //  |
-
        // C0
-
        assert_eq!(quorum(&[*c1], 1, &repo).unwrap(), c1);
+
    fn test_commit_quorum_single() {
+
        let c0 = commit("f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354");
+
        let c1 = commit("bfb1a513e420eade90b0e6be64117b861b16ecb5");
+
        let c2 = commit("8fc5160702365f231c77732a8fa162379e54f57a");
+
        assert_eq!(
+
            CommitQuorum::new([c0].iter(), 1).find_quorum().unwrap(),
+
            c0.id()
+
        );
+
        assert_eq!(
+
            CommitQuorum::new([c1].iter(), 1).find_quorum().unwrap(),
+
            c1.id()
+
        );
+
        assert_eq!(
+
            CommitQuorum::new([c2].iter(), 1).find_quorum().unwrap(),
+
            c2.id()
+
        );
+
    }

+
    #[test]
+
    fn test_commit_quorum_linear() {
+
        let c0 = commit("f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354");
+
        let c1 = commit("bfb1a513e420eade90b0e6be64117b861b16ecb5");
+
        let c2 = commit("8fc5160702365f231c77732a8fa162379e54f57a");
        //   C2
        //   |
        //  C1
        //  |
        // C0
-
        assert_eq!(quorum(&[*c1, *c2], 1, &repo).unwrap(), c2);
-
        assert_eq!(quorum(&[*c1, *c2], 2, &repo).unwrap(), c1);
-
        assert_eq!(quorum(&[*c0, *c1, *c2], 3, &repo).unwrap(), c0);
-
        assert_eq!(quorum(&[*c1, *c1, *c2], 2, &repo).unwrap(), c1);
-
        assert_eq!(quorum(&[*c1, *c1, *c2], 1, &repo).unwrap(), c2);
-
        assert_eq!(quorum(&[*c2, *c2, *c1], 1, &repo).unwrap(), c2);
+
        let merge_bases = [
+
            MergeBase {
+
                a: c2.id(),
+
                b: c1.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: c2.id(),
+
                b: c0.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: c1.id(),
+
                b: c0.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase::refl(c2.id()),
+
            MergeBase::refl(c1.id()),
+
            MergeBase::refl(c0.id()),
+
        ];
+

+
        let mut cq = CommitQuorum::new([c1, c2].iter(), 1);
+
        cq.found_merge_bases(merge_bases);
+
        assert_eq!(cq.find_quorum().unwrap(), c2.id());
+

+
        let mut cq = CommitQuorum::new([c1, c2].iter(), 2);
+
        cq.found_merge_bases(merge_bases);
+
        assert_eq!(cq.find_quorum().unwrap(), c1.id());
+

+
        let mut cq = CommitQuorum::new([c0, c1, c2].iter(), 3);
+
        cq.found_merge_bases(merge_bases);
+
        assert_eq!(cq.find_quorum().unwrap(), c0.id());
+

+
        let mut cq = CommitQuorum::new([c1, c1, c2].iter(), 2);
+
        cq.found_merge_bases(merge_bases);
+
        assert_eq!(cq.find_quorum().unwrap(), c1.id());
+

+
        let mut cq = CommitQuorum::new([c1, c1, c2].iter(), 1);
+
        cq.found_merge_bases(merge_bases);
+
        assert_eq!(cq.find_quorum().unwrap(), c2.id());
+

+
        let mut cq = CommitQuorum::new([c2, c2, c1].iter(), 1);
+
        cq.found_merge_bases(merge_bases);
+
        assert_eq!(cq.find_quorum().unwrap(), c2.id());
+
    }
+

+
    #[test]
+
    fn test_commit_quorum_two_way_fork() {
+
        let c0 = commit("f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354");
+
        let c1 = commit("bfb1a513e420eade90b0e6be64117b861b16ecb5");
+
        let c2 = commit("8fc5160702365f231c77732a8fa162379e54f57a");
+
        let b2 = commit("037a148170e3d41524b7c482a4798e5c2daeaa00");
+

+
        eprintln!("C0: {}", c0.id());
+
        eprintln!("C1: {}", c1.id());
+
        eprintln!("C2: {}", c2.id());
+
        eprintln!("B2: {}", b2.id());

        // B2 C2
        //   \|
        //   C1
        //   |
        //  C0
+
        let merge_bases = [
+
            MergeBase {
+
                a: b2.id(),
+
                b: c2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: c2.id(),
+
                b: c1.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: b2.id(),
+
                b: c1.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: c1.id(),
+
                b: c0.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase::refl(b2.id()),
+
            MergeBase::refl(c2.id()),
+
            MergeBase::refl(c1.id()),
+
            MergeBase::refl(c0.id()),
+
        ];
+

+
        let mut cq = CommitQuorum::new([c1, c2, b2].iter(), 1);
+
        cq.found_merge_bases(merge_bases);
        assert_matches!(
-
            quorum(&[*c1, *c2, *b2], 1, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
-
        );
-
        assert_matches!(
-
            quorum(&[*c2, *b2], 1, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
-
        );
-
        assert_matches!(
-
            quorum(&[*b2, *c2], 1, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );
+

+
        let mut cq = CommitQuorum::new([c2, b2].iter(), 1);
+
        cq.found_merge_bases(merge_bases);
        assert_matches!(
-
            quorum(&[*c2, *b2], 2, &repo),
-
            Err(QuorumError::NoCandidates { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );
+

+
        let mut cq = CommitQuorum::new([b2, c2].iter(), 1);
+
        cq.found_merge_bases(merge_bases);
        assert_matches!(
-
            quorum(&[*b2, *c2], 2, &repo),
-
            Err(QuorumError::NoCandidates { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );
-
        assert_eq!(quorum(&[*c1, *c2, *b2], 2, &repo).unwrap(), c1);
-
        assert_eq!(quorum(&[*c1, *c2, *b2], 3, &repo).unwrap(), c1);
-
        assert_eq!(quorum(&[*b2, *b2, *c2], 2, &repo).unwrap(), b2);
-
        assert_eq!(quorum(&[*b2, *c2, *c2], 2, &repo).unwrap(), c2);
+

+
        // Note for the next two cases we only give enough merge base
+
        // information so that the quorum fails. If we provided all
+
        // `merge_bases`, it would mean that c0 could be chosen as the quourum.
+
        let mut cq = CommitQuorum::new([c2, b2].iter(), 2);
+
        cq.found_merge_bases([MergeBase {
+
            a: b2.id(),
+
            b: c2.id(),
+
            base: c1.id(),
+
        }]);
+
        assert_matches!(cq.find_quorum(), Err(CommitQuorumFailure::NoCandidates));
+

+
        let mut cq = CommitQuorum::new([b2, c2].iter(), 2);
+
        cq.found_merge_bases([MergeBase {
+
            a: b2.id(),
+
            b: c2.id(),
+
            base: c1.id(),
+
        }]);
+
        assert_matches!(cq.find_quorum(), Err(CommitQuorumFailure::NoCandidates));
+

+
        let mut cq = CommitQuorum::new([c1, c2, b2].iter(), 2);
+
        cq.found_merge_bases(merge_bases);
+
        assert_eq!(cq.find_quorum().unwrap(), c1.id());
+

+
        let mut cq = CommitQuorum::new([c1, c2, b2].iter(), 3);
+
        cq.found_merge_bases(merge_bases);
+
        assert_eq!(cq.find_quorum().unwrap(), c1.id());
+

+
        let mut cq = CommitQuorum::new([b2, b2, c2].iter(), 2);
+
        cq.found_merge_bases(merge_bases);
+
        assert_eq!(cq.find_quorum().unwrap(), b2.id());
+

+
        let mut cq = CommitQuorum::new([b2, c2, c2].iter(), 2);
+
        cq.found_merge_bases(merge_bases);
+
        assert_eq!(cq.find_quorum().unwrap(), c2.id());
+

+
        let mut cq = CommitQuorum::new([b2, b2, c2, c2].iter(), 2);
+
        cq.found_merge_bases(merge_bases);
        assert_matches!(
-
            quorum(&[*b2, *b2, *c2, *c2], 2, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );
+
    }
+

+
    #[test]
+
    fn test_commit_quorum_three_way_fork() {
+
        let c1 = commit("bfb1a513e420eade90b0e6be64117b861b16ecb5");
+
        let c2 = commit("8fc5160702365f231c77732a8fa162379e54f57a");
+
        let c3 = commit("07c2a0f856e0d6b08115f98a265df88c4e507fa0");
+
        let b2 = commit("037a148170e3d41524b7c482a4798e5c2daeaa00");

        // B2 C2 C3
        //  \ | /
        //    C1
        //    |
        //    C0
-
        assert_eq!(quorum(&[*b2, *c2, *c2], 2, &repo).unwrap(), c2);
-
        assert_matches!(
-
            quorum(&[*b2, *c2, *c2], 3, &repo),
-
            Err(QuorumError::NoCandidates { .. })
-
        );
-
        assert_matches!(
-
            quorum(&[*b2, *c2, *b2, *c2], 3, &repo),
-
            Err(QuorumError::NoCandidates { .. })
-
        );
-
        assert_matches!(
-
            quorum(&[*c3, *b2, *c2, *b2, *c2, *c3], 3, &repo),
-
            Err(QuorumError::NoCandidates { .. })
-
        );
+
        let mut cq = CommitQuorum::new([b2, c2, c2].iter(), 2);
+
        cq.found_merge_bases([
+
            MergeBase {
+
                a: b2.id(),
+
                b: c2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase::refl(b2.id()),
+
        ]);
+
        assert_eq!(cq.find_quorum().unwrap(), c2.id());
+

+
        let mut cq = CommitQuorum::new([b2, c2, c2].iter(), 3);
+
        cq.found_merge_bases([
+
            MergeBase {
+
                a: b2.id(),
+
                b: c2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase::refl(c2.id()),
+
        ]);
+
        assert_eq!(cq.find_quorum(), Err(CommitQuorumFailure::NoCandidates));
+

+
        let mut cq = CommitQuorum::new([b2, c2, b2, c2].iter(), 3);
+
        cq.found_merge_bases([
+
            MergeBase {
+
                a: b2.id(),
+
                b: c2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: c2.id(),
+
                b: b2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase::refl(b2.id()),
+
            MergeBase::refl(c2.id()),
+
        ]);
+
        assert_eq!(cq.find_quorum(), Err(CommitQuorumFailure::NoCandidates));
+

+
        let mut cq = CommitQuorum::new([c3, b2, c2, b2, c2, c3].iter(), 3);
+
        cq.found_merge_bases([
+
            MergeBase {
+
                a: c3.id(),
+
                b: b2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: c3.id(),
+
                b: c2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: b2.id(),
+
                b: c2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: c2.id(),
+
                b: b2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase::refl(b2.id()),
+
            MergeBase::refl(c2.id()),
+
            MergeBase::refl(c3.id()),
+
        ]);
+
        assert_eq!(cq.find_quorum(), Err(CommitQuorumFailure::NoCandidates));
+
    }
+

+
    #[test]
+
    fn test_commit_quorum_fork_of_a_fork() {
+
        let c0 = commit("f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354");
+
        let c1 = commit("bfb1a513e420eade90b0e6be64117b861b16ecb5");
+
        let c2 = commit("8fc5160702365f231c77732a8fa162379e54f57a");
+
        let b2 = commit("037a148170e3d41524b7c482a4798e5c2daeaa00");
+
        let a1 = commit("2224468e22b30359611d880ccf0850d023f86f9b");

        //  B2 C2
        //    \|
        // A1 C1
        //   \|
        //   C0
+
        let mut cq = CommitQuorum::new([c2, b2, a1].iter(), 1);
+
        cq.found_merge_bases([
+
            MergeBase {
+
                a: c2.id(),
+
                b: b2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: c2.id(),
+
                b: a1.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: b2.id(),
+
                b: a1.id(),
+
                base: c0.id(),
+
            },
+
        ]);
        assert_matches!(
-
            quorum(&[*c2, *b2, *a1], 1, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
-
        );
-
        assert_matches!(
-
            quorum(&[*c2, *b2, *a1], 2, &repo),
-
            Err(QuorumError::NoCandidates { .. })
-
        );
-
        assert_matches!(
-
            quorum(&[*c2, *b2, *a1], 3, &repo),
-
            Err(QuorumError::NoCandidates { .. })
-
        );
-
        assert_matches!(
-
            quorum(&[*c1, *c2, *b2, *a1], 4, &repo),
-
            Err(QuorumError::NoCandidates { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );
-
        assert_eq!(quorum(&[*c0, *c1, *c2, *b2, *a1], 2, &repo).unwrap(), c1,);
-
        assert_eq!(quorum(&[*c0, *c1, *c2, *b2, *a1], 3, &repo).unwrap(), c1,);
-
        assert_eq!(quorum(&[*c0, *c2, *b2, *a1], 3, &repo).unwrap(), c0);
-
        assert_eq!(quorum(&[*c0, *c1, *c2, *b2, *a1], 4, &repo).unwrap(), c0,);
+
        let mut cq = CommitQuorum::new([c2, b2, a1].iter(), 2);
+
        cq.found_merge_bases([
+
            MergeBase {
+
                a: c2.id(),
+
                b: b2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: c2.id(),
+
                b: a1.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: b2.id(),
+
                b: a1.id(),
+
                base: c0.id(),
+
            },
+
        ]);
+
        assert_eq!(cq.find_quorum(), Err(CommitQuorumFailure::NoCandidates));
+

+
        let mut cq = CommitQuorum::new([c2, b2, a1].iter(), 3);
+
        cq.found_merge_bases([
+
            MergeBase {
+
                a: c2.id(),
+
                b: b2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: c2.id(),
+
                b: a1.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: b2.id(),
+
                b: a1.id(),
+
                base: c0.id(),
+
            },
+
        ]);
+
        assert_eq!(cq.find_quorum(), Err(CommitQuorumFailure::NoCandidates));
+

+
        let mut cq = CommitQuorum::new([c1, c2, b2, a1].iter(), 4);
+
        cq.found_merge_bases([
+
            MergeBase {
+
                a: c1.id(),
+
                b: c2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: c1.id(),
+
                b: b2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: c1.id(),
+
                b: a1.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: c2.id(),
+
                b: b2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: c2.id(),
+
                b: a1.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: b2.id(),
+
                b: a1.id(),
+
                base: c0.id(),
+
            },
+
        ]);
+
        assert_eq!(cq.find_quorum(), Err(CommitQuorumFailure::NoCandidates));
+

+
        let all_merge_bases = [
+
            MergeBase {
+
                a: c0.id(),
+
                b: c1.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: c0.id(),
+
                b: c2.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: c0.id(),
+
                b: b2.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: c0.id(),
+
                b: a1.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: c1.id(),
+
                b: c2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: c1.id(),
+
                b: b2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: c1.id(),
+
                b: a1.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: c2.id(),
+
                b: b2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: c2.id(),
+
                b: a1.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: b2.id(),
+
                b: a1.id(),
+
                base: c0.id(),
+
            },
+
        ];
+
        let mut cq = CommitQuorum::new([c0, c1, c2, b2, a1].iter(), 2);
+
        cq.found_merge_bases(all_merge_bases);
+
        assert_eq!(cq.find_quorum().unwrap(), c1.id());
+

+
        let mut cq = CommitQuorum::new([c0, c1, c2, b2, a1].iter(), 3);
+
        cq.found_merge_bases(all_merge_bases);
+
        assert_eq!(cq.find_quorum().unwrap(), c1.id());
+

+
        let mut cq = CommitQuorum::new([c0, c2, b2, a1].iter(), 3);
+
        cq.found_merge_bases(all_merge_bases);
+
        assert_eq!(cq.find_quorum().unwrap(), c0.id());
+

+
        let mut cq = CommitQuorum::new([c0, c1, c2, b2, a1].iter(), 4);
+
        cq.found_merge_bases(all_merge_bases);
+
        assert_eq!(cq.find_quorum().unwrap(), c0.id());
+

+
        let mut cq = CommitQuorum::new([a1, a1, c2, c2, c1].iter(), 2);
+
        cq.found_merge_bases([
+
            MergeBase::refl(a1.id()),
+
            MergeBase::refl(c2.id()),
+
            MergeBase {
+
                a: a1.id(),
+
                b: c2.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: a1.id(),
+
                b: c1.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: c2.id(),
+
                b: c1.id(),
+
                base: c0.id(),
+
            },
+
        ]);
        assert_matches!(
-
            quorum(&[*a1, *a1, *c2, *c2, *c1], 2, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );
+

+
        let mut cq = CommitQuorum::new([a1, a1, c2, c2, c1].iter(), 1);
+
        cq.found_merge_bases([
+
            MergeBase::refl(a1.id()),
+
            MergeBase::refl(c2.id()),
+
            MergeBase {
+
                a: a1.id(),
+
                b: c2.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: a1.id(),
+
                b: c1.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: c2.id(),
+
                b: c1.id(),
+
                base: c0.id(),
+
            },
+
        ]);
        assert_matches!(
-
            quorum(&[*a1, *a1, *c2, *c2, *c1], 1, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );
+

+
        let mut cq = CommitQuorum::new([a1, a1, c2, c2, c1].iter(), 1);
+
        cq.found_merge_bases([
+
            MergeBase::refl(a1.id()),
+
            MergeBase {
+
                a: a1.id(),
+
                b: c2.id(),
+
                base: c0.id(),
+
            },
+
        ]);
        assert_matches!(
-
            quorum(&[*a1, *a1, *c2], 1, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );
+

+
        let mut cq = CommitQuorum::new([b2, b2, c2, c2].iter(), 1);
+
        cq.found_merge_bases([
+
            MergeBase::refl(b2.id()),
+
            MergeBase::refl(c2.id()),
+
            MergeBase {
+
                a: b2.id(),
+
                b: c2.id(),
+
                base: c1.id(),
+
            },
+
        ]);
        assert_matches!(
-
            quorum(&[*b2, *b2, *c2, *c2], 1, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );
+

+
        let mut cq = CommitQuorum::new([b2, b2, c2, c2, a1].iter(), 1);
+
        cq.found_merge_bases([
+
            MergeBase::refl(b2.id()),
+
            MergeBase::refl(c2.id()),
+
            MergeBase {
+
                a: b2.id(),
+
                b: c2.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: b2.id(),
+
                b: a1.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: c2.id(),
+
                b: a1.id(),
+
                base: c0.id(),
+
            },
+
        ]);
        assert_matches!(
-
            quorum(&[*b2, *b2, *c2, *c2, *a1], 1, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );
+
    }
+

+
    #[test]
+
    fn test_commit_quorum_forked_merge_commits() {
+
        let c0 = commit("f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354");
+
        let c1 = commit("bfb1a513e420eade90b0e6be64117b861b16ecb5");
+
        let c2 = commit("8fc5160702365f231c77732a8fa162379e54f57a");
+
        let b2 = commit("037a148170e3d41524b7c482a4798e5c2daeaa00");
+
        let a1 = commit("2224468e22b30359611d880ccf0850d023f86f9b");
+
        let m1 = commit("dd7ee5bca2fc7288a6efcb4303278e26a2dbaa45");
+
        let m2 = commit("d54e505e3fb5c0c7e4b9a4b8a1cdeefb3fc9ef18");

        //    M2  M1
        //    /\  /\
@@ -852,105 +1177,347 @@ mod tests {
        //     A1 C1
        //       \|
        //       C0
-
        assert_eq!(quorum(&[*m1], 1, &repo).unwrap(), m1);
-
        assert_matches!(
-
            quorum(&[*m1, *m2], 1, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
-
        );
-
        assert_matches!(
-
            quorum(&[*m2, *m1], 1, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
-
        );
+
        let cq = CommitQuorum::new([m1].iter(), 1);
+
        assert_eq!(cq.find_quorum().unwrap(), m1.id());
+

+
        let mut cq = CommitQuorum::new([m1, m2].iter(), 1);
+
        cq.found_merge_bases([MergeBase {
+
            a: m1.id(),
+
            b: m2.id(),
+
            base: b2.id(),
+
        }]);
        assert_matches!(
-
            quorum(&[*m1, *m2], 2, &repo),
-
            Err(QuorumError::NoCandidates { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );
+

+
        let mut cq = CommitQuorum::new([m2, m1].iter(), 1);
+
        cq.found_merge_bases([MergeBase {
+
            a: m2.id(),
+
            b: m1.id(),
+
            base: b2.id(),
+
        }]);
        assert_matches!(
-
            quorum(&[*m1, *m2, *c2], 1, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );
+

+
        let mut cq = CommitQuorum::new([m1, m2].iter(), 2);
+
        cq.found_merge_bases([MergeBase {
+
            a: m1.id(),
+
            b: m2.id(),
+
            base: b2.id(),
+
        }]);
+
        assert_eq!(cq.find_quorum(), Err(CommitQuorumFailure::NoCandidates));
+

+
        let mut cq = CommitQuorum::new([m1, m2, c2].iter(), 1);
+
        cq.found_merge_bases([
+
            MergeBase {
+
                a: m1.id(),
+
                b: m2.id(),
+
                base: b2.id(),
+
            },
+
            MergeBase {
+
                a: m1.id(),
+
                b: c2.id(),
+
                base: c2.id(),
+
            },
+
            MergeBase {
+
                a: m2.id(),
+
                b: c2.id(),
+
                base: c0.id(),
+
            },
+
        ]);
        assert_matches!(
-
            quorum(&[*m1, *a1], 1, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );
+

+
        let mut cq = CommitQuorum::new([m1, a1].iter(), 1);
+
        cq.found_merge_bases([MergeBase {
+
            a: m1.id(),
+
            b: a1.id(),
+
            base: c0.id(),
+
        }]);
        assert_matches!(
-
            quorum(&[*m1, *a1], 2, &repo),
-
            Err(QuorumError::NoCandidates { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );
-
        assert_eq!(quorum(&[*m1, *m2, *b2, *c1], 4, &repo).unwrap(), c1);
-
        assert_eq!(quorum(&[*m1, *m1, *b2], 2, &repo).unwrap(), m1);
-
        assert_eq!(quorum(&[*m1, *m1, *c2], 2, &repo).unwrap(), m1);
-
        assert_eq!(quorum(&[*m2, *m2, *b2], 2, &repo).unwrap(), m2);
-
        assert_eq!(quorum(&[*m2, *m2, *a1], 2, &repo).unwrap(), m2);
-
        assert_eq!(quorum(&[*m1, *m1, *b2, *b2], 2, &repo).unwrap(), m1);
-
        assert_eq!(quorum(&[*m1, *m1, *c2, *c2], 2, &repo).unwrap(), m1);
-
        assert_eq!(quorum(&[*m1, *b2, *c1, *c0], 3, &repo).unwrap(), c1);
-
        assert_eq!(quorum(&[*m1, *b2, *c1, *c0], 4, &repo).unwrap(), c0);
+

+
        let mut cq = CommitQuorum::new([m1, a1].iter(), 2);
+
        cq.found_merge_bases([MergeBase {
+
            a: m1.id(),
+
            b: a1.id(),
+
            base: c0.id(),
+
        }]);
+
        assert_eq!(cq.find_quorum(), Err(CommitQuorumFailure::NoCandidates));
+

+
        let mut cq = CommitQuorum::new([m1, m2, b2, c1].iter(), 4);
+
        cq.found_merge_bases([
+
            MergeBase {
+
                a: m1.id(),
+
                b: m2.id(),
+
                base: b2.id(),
+
            },
+
            MergeBase {
+
                a: m1.id(),
+
                b: b2.id(),
+
                base: b2.id(),
+
            },
+
            MergeBase {
+
                a: m1.id(),
+
                b: c1.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: m2.id(),
+
                b: b2.id(),
+
                base: b2.id(),
+
            },
+
            MergeBase {
+
                a: m2.id(),
+
                b: c1.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: b2.id(),
+
                b: c1.id(),
+
                base: c1.id(),
+
            },
+
        ]);
+
        assert_eq!(cq.find_quorum().unwrap(), c1.id());
+

+
        let mut cq = CommitQuorum::new([m1, m1, b2].iter(), 2);
+
        cq.found_merge_bases([
+
            MergeBase::refl(m1.id()),
+
            MergeBase {
+
                a: m1.id(),
+
                b: b2.id(),
+
                base: b2.id(),
+
            },
+
        ]);
+
        assert_eq!(cq.find_quorum().unwrap(), m1.id());
+

+
        let mut cq = CommitQuorum::new([m1, m1, c2].iter(), 2);
+
        cq.found_merge_bases([
+
            MergeBase::refl(m1.id()),
+
            MergeBase {
+
                a: m1.id(),
+
                b: c2.id(),
+
                base: c2.id(),
+
            },
+
        ]);
+
        assert_eq!(cq.find_quorum().unwrap(), m1.id());
+

+
        let mut cq = CommitQuorum::new([m2, m2, b2].iter(), 2);
+
        cq.found_merge_bases([
+
            MergeBase::refl(m2.id()),
+
            MergeBase {
+
                a: m2.id(),
+
                b: b2.id(),
+
                base: b2.id(),
+
            },
+
        ]);
+
        assert_eq!(cq.find_quorum().unwrap(), m2.id());
+

+
        let mut cq = CommitQuorum::new([m2, m2, a1].iter(), 2);
+
        cq.found_merge_bases([
+
            MergeBase::refl(m2.id()),
+
            MergeBase {
+
                a: m2.id(),
+
                b: a1.id(),
+
                base: a1.id(),
+
            },
+
        ]);
+
        assert_eq!(cq.find_quorum().unwrap(), m2.id());
+

+
        let mut cq = CommitQuorum::new([m1, m1, b2, b2].iter(), 2);
+
        cq.found_merge_bases([
+
            MergeBase::refl(m1.id()),
+
            MergeBase::refl(b2.id()),
+
            MergeBase {
+
                a: m1.id(),
+
                b: b2.id(),
+
                base: b2.id(),
+
            },
+
        ]);
+
        assert_eq!(cq.find_quorum().unwrap(), m1.id());
+

+
        let mut cq = CommitQuorum::new([m1, m1, c2, c2].iter(), 2);
+
        cq.found_merge_bases([
+
            MergeBase::refl(m1.id()),
+
            MergeBase::refl(c2.id()),
+
            MergeBase {
+
                a: m1.id(),
+
                b: c2.id(),
+
                base: c2.id(),
+
            },
+
        ]);
+
        assert_eq!(cq.find_quorum().unwrap(), m1.id());
+

+
        let mut cq = CommitQuorum::new([m1, b2, c1, c0].iter(), 3);
+
        cq.found_merge_bases([
+
            MergeBase {
+
                a: m1.id(),
+
                b: b2.id(),
+
                base: b2.id(),
+
            },
+
            MergeBase {
+
                a: m1.id(),
+
                b: c1.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: m1.id(),
+
                b: c0.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: b2.id(),
+
                b: c1.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: b2.id(),
+
                b: c0.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: c1.id(),
+
                b: c0.id(),
+
                base: c0.id(),
+
            },
+
        ]);
+
        assert_eq!(cq.find_quorum().unwrap(), c1.id());
+

+
        let mut cq = CommitQuorum::new([m1, b2, c1, c0].iter(), 4);
+
        cq.found_merge_bases([
+
            MergeBase {
+
                a: m1.id(),
+
                b: b2.id(),
+
                base: b2.id(),
+
            },
+
            MergeBase {
+
                a: m1.id(),
+
                b: c1.id(),
+
                base: c1.id(),
+
            },
+
            MergeBase {
+
                a: m1.id(),
+
                b: c0.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: b2.id(),
+
                b: c1.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: b2.id(),
+
                b: c0.id(),
+
                base: c0.id(),
+
            },
+
            MergeBase {
+
                a: c1.id(),
+
                b: c0.id(),
+
                base: c0.id(),
+
            },
+
        ]);
+
        assert_eq!(cq.find_quorum().unwrap(), c0.id());
    }

    #[test]
-
    fn test_quorum_merges() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let (repo, c0) = fixtures::repository(tmp.path());
-
        let c0: git::Oid = c0.into();
-
        let c1 = fixtures::commit("C1", &[*c0], &repo);
-
        let c2 = fixtures::commit("C2", &[*c0], &repo);
-
        let c3 = fixtures::commit("C3", &[*c0], &repo);
-

-
        let m1 = fixtures::commit("M1", &[*c1, *c2], &repo);
-
        let m2 = fixtures::commit("M2", &[*c2, *c3], &repo);
-

-
        eprintln!("C0: {c0}");
-
        eprintln!("C1: {c1}");
-
        eprintln!("C2: {c2}");
-
        eprintln!("C3: {c3}");
-
        eprintln!("M1: {m1}");
-
        eprintln!("M2: {m2}");
+
    fn test_commit_quorum_merges() {
+
        let c2 = commit("8fc5160702365f231c77732a8fa162379e54f57a");
+
        let m1 = commit("dd7ee5bca2fc7288a6efcb4303278e26a2dbaa45");
+
        let m2 = commit("d54e505e3fb5c0c7e4b9a4b8a1cdeefb3fc9ef18");
+
        let m3 = commit("2224468e22b30359611d880ccf0850d023f86f9b");

        //    M2  M1
        //    /\  /\
        //   C1 C2 C3
        //     \| /
        //      C0
+

+
        let mut cq = CommitQuorum::new([m1, m2].iter(), 1);
+
        cq.found_merge_bases([MergeBase {
+
            a: m1.id(),
+
            b: m2.id(),
+
            base: c2.id(),
+
        }]);
        assert_matches!(
-
            quorum(&[*m1, *m2], 1, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
-
        );
-
        assert_matches!(
-
            quorum(&[*m1, *m2], 2, &repo),
-
            Err(QuorumError::NoCandidates { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );

-
        let m3 = fixtures::commit("M3", &[*c2, *c1], &repo);
+
        let mut cq = CommitQuorum::new([m1, m2].iter(), 2);
+
        cq.found_merge_bases([MergeBase {
+
            a: m1.id(),
+
            b: m2.id(),
+
            base: c2.id(),
+
        }]);
+
        assert_eq!(cq.find_quorum(), Err(CommitQuorumFailure::NoCandidates));

        //   M3/M2 M1
        //    /\  /\
        //   C1 C2 C3
        //     \| /
        //      C0
+
        let mut cq = CommitQuorum::new([m1, m3].iter(), 1);
+
        cq.found_merge_bases([MergeBase {
+
            a: m1.id(),
+
            b: m3.id(),
+
            base: c2.id(),
+
        }]);
        assert_matches!(
-
            quorum(&[*m1, *m3], 1, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
-
        );
-
        assert_matches!(
-
            quorum(&[*m1, *m3], 2, &repo),
-
            Err(QuorumError::NoCandidates { .. })
-
        );
-
        assert_matches!(
-
            quorum(&[*m3, *m1], 1, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
-
        );
-
        assert_matches!(
-
            quorum(&[*m3, *m1], 2, &repo),
-
            Err(QuorumError::NoCandidates { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );
+

+
        let mut cq = CommitQuorum::new([m1, m3].iter(), 2);
+
        cq.found_merge_bases([MergeBase {
+
            a: m1.id(),
+
            b: m3.id(),
+
            base: c2.id(),
+
        }]);
+
        assert_eq!(cq.find_quorum(), Err(CommitQuorumFailure::NoCandidates));
+

+
        let mut cq = CommitQuorum::new([m3, m1].iter(), 1);
+
        cq.found_merge_bases([MergeBase {
+
            a: m3.id(),
+
            b: m1.id(),
+
            base: c2.id(),
+
        }]);
        assert_matches!(
-
            quorum(&[*m3, *m2], 1, &repo),
-
            Err(QuorumError::DivergingCommits { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );
+

+
        let mut cq = CommitQuorum::new([m3, m1].iter(), 2);
+
        cq.found_merge_bases([MergeBase {
+
            a: m3.id(),
+
            b: m1.id(),
+
            base: c2.id(),
+
        }]);
+
        assert_eq!(cq.find_quorum(), Err(CommitQuorumFailure::NoCandidates));
+

+
        let mut cq = CommitQuorum::new([m3, m2].iter(), 1);
+
        cq.found_merge_bases([MergeBase {
+
            a: m3.id(),
+
            b: m2.id(),
+
            base: c2.id(),
+
        }]);
        assert_matches!(
-
            quorum(&[*m3, *m2], 2, &repo),
-
            Err(QuorumError::NoCandidates { .. })
+
            cq.find_quorum(),
+
            Err(CommitQuorumFailure::DivergingCommits { .. })
        );
+

+
        let mut cq = CommitQuorum::new([m3, m2].iter(), 2);
+
        cq.found_merge_bases([MergeBase {
+
            a: m3.id(),
+
            b: m2.id(),
+
            base: c2.id(),
+
        }]);
+
        assert_eq!(cq.find_quorum(), Err(CommitQuorumFailure::NoCandidates));
    }
}
added crates/radicle/src/git/canonical/convergence.rs
@@ -0,0 +1,140 @@
+
use std::{fmt, ops::ControlFlow};
+

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

+
use super::{effects, error, GraphAheadBehind, Object};
+

+
/// Checks for convergence and ensures that compared objects are of the same
+
/// type, i.e. commit or tag, to the [`Candidate`].
+
pub(super) struct Convergence<'r, R> {
+
    repo: &'r R,
+
    checker: Candidate,
+
}
+

+
impl<'r, R> fmt::Debug for Convergence<'r, R> {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        f.debug_struct("Convergence")
+
            .field("checker", &self.checker)
+
            .finish()
+
    }
+
}
+

+
impl<'r, R> Convergence<'r, R>
+
where
+
    R: effects::Ancestry,
+
{
+
    pub fn new(repo: &'r R, candidate: Did, object: Object) -> Self {
+
        Self {
+
            repo,
+
            checker: Candidate::new(candidate, object),
+
        }
+
    }
+

+
    /// For each voter in `voters`:
+
    ///   1. If the [`Object`] is a commit:
+
    ///
+
    ///    a. Ensure that the candidate and voting commit have a linear
+
    ///       relationship.
+
    ///    b. That [`Object`]'s type matches type of the [`Candidate`].
+
    ///
+
    ///   2. If the [`Object`] is a tag, then ensure the [`Candidate`] object is
+
    ///      a tag.
+
    ///   3. Always skip a vote that is the same as the [`Candidate`].
+
    pub fn check<'a, I>(self, voters: I) -> Result<Option<(Did, Object)>, error::ConvergesError>
+
    where
+
        I: Iterator<Item = (&'a Did, &'a Object)>,
+
    {
+
        let mut converges = false;
+
        for (did, object) in voters {
+
            match self.checker.compare_to_candidate(did, *object) {
+
                ControlFlow::Continue(c) => match c {
+
                    Effect::GraphCheck { commit, upstream } => {
+
                        converges = converges
+
                            || self
+
                                .checker
+
                                .is_linear(self.repo.graph_ahead_behind(commit, upstream)?);
+
                    }
+
                    Effect::TagConverges => {
+
                        converges = true;
+
                        continue;
+
                    }
+
                    Effect::SkipSelf => continue,
+
                },
+
                ControlFlow::Break(ConvergenceMismatch { expected, found }) => {
+
                    return Err(error::ConvergesError::mismatched_object(
+
                        expected.id(),
+
                        found.object_type(),
+
                        expected.object_type(),
+
                    ));
+
                }
+
            }
+
        }
+
        Ok(converges.then_some((self.checker.candidate, self.checker.object)))
+
    }
+
}
+

+
/// The candidate and their [`Object`] they are attempting to converge with.
+
#[derive(Debug)]
+
struct Candidate {
+
    candidate: Did,
+
    object: Object,
+
}
+

+
/// The "effect" that needs to be performed due to the result of
+
/// [`Candidate::compare_to_candidate`].
+
enum Effect {
+
    /// Perform a check of the Git graph using the `commit` and `upstream`.
+
    GraphCheck { commit: Oid, upstream: Oid },
+
    /// Mark that tags always converge – their is no ancestry check.
+
    TagConverges,
+
    /// Skip the [`Did`] since it is the same as the [`Candidate`].
+
    SkipSelf,
+
}
+

+
/// The two [`Object`]s have different types.
+
pub(super) struct ConvergenceMismatch {
+
    expected: Object,
+
    found: Object,
+
}
+

+
impl Candidate {
+
    fn new(candidate: Did, object: Object) -> Self {
+
        Self { candidate, object }
+
    }
+

+
    fn compare_to_candidate(
+
        &self,
+
        did: &Did,
+
        object: Object,
+
    ) -> ControlFlow<ConvergenceMismatch, Effect> {
+
        if &self.candidate == did {
+
            return ControlFlow::Continue(Effect::SkipSelf);
+
        }
+
        match (self.object, object) {
+
            (e @ Object::Commit { .. }, f @ Object::Tag { .. }) => {
+
                ControlFlow::Break(ConvergenceMismatch {
+
                    expected: e,
+
                    found: f,
+
                })
+
            }
+
            (e @ Object::Tag { .. }, f @ Object::Commit { .. }) => {
+
                ControlFlow::Break(ConvergenceMismatch {
+
                    expected: e,
+
                    found: f,
+
                })
+
            }
+
            (Object::Commit { id: commit }, Object::Commit { id: upstream }) => {
+
                ControlFlow::Continue(Effect::GraphCheck { commit, upstream })
+
            }
+

+
            (Object::Tag { .. }, Object::Tag { .. }) => ControlFlow::Continue(Effect::TagConverges),
+
        }
+
    }
+

+
    /// If either `ahead` or `behind` is `0` then one of the commits is in the
+
    /// lineage.
+
    fn is_linear(&self, GraphAheadBehind { ahead, behind }: GraphAheadBehind) -> bool {
+
        ahead * behind == 0
+
    }
+
}
added crates/radicle/src/git/canonical/effects.rs
@@ -0,0 +1,252 @@
+
use std::collections::{BTreeMap, BTreeSet};
+

+
use crate::git;
+
use crate::git::{Oid, Qualified};
+
use crate::prelude::Did;
+

+
use super::{FoundObjects, GraphAheadBehind, MergeBase, Object};
+

+
/// Find objects for the canonical computation.
+
///
+
/// Typically implemented by a Git repository.
+
pub trait FindObjects {
+
    /// Find the objects for the given [`Qualified`] reference name, for each
+
    /// [`Did`]'s namespace.
+
    ///
+
    /// The resulting [`FoundObjects`] includes all objects that were found, the
+
    /// references that were missing, and the objects that were missing (if the
+
    /// reference was found).
+
    fn find_objects<'a, 'b, I>(
+
        &self,
+
        refname: &Qualified<'a>,
+
        dids: I,
+
    ) -> Result<FoundObjects, FindObjectsError>
+
    where
+
        I: Iterator<Item = &'b Did>;
+
}
+

+
/// Error produced by the [`FindObjects::find_objects`] method.
+
#[derive(Debug, thiserror::Error)]
+
pub enum FindObjectsError {
+
    #[error(transparent)]
+
    InvalidObjectType(#[from] InvalidObjectType),
+
    #[error(transparent)]
+
    MissingObject(#[from] MissingObject),
+
    #[error("failed to find object {oid} due to: {source}")]
+
    FindObject {
+
        oid: Oid,
+
        source: Box<dyn std::error::Error + Send + Sync + 'static>,
+
    },
+
    #[error("failed to find reference {refname} due to: {source}")]
+
    FindReference {
+
        refname: git::Namespaced<'static>,
+
        source: Box<dyn std::error::Error + Send + Sync + 'static>,
+
    },
+
    #[error("failed to find objects")]
+
    Other {
+
        source: Box<dyn std::error::Error + Send + Sync + 'static>,
+
    },
+
}
+

+
impl FindObjectsError {
+
    pub fn find_object<E>(oid: Oid, err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self::FindObject {
+
            oid,
+
            source: Box::new(err),
+
        }
+
    }
+

+
    pub fn find_reference<E>(refname: git::Namespaced<'static>, err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self::FindReference {
+
            refname,
+
            source: Box::new(err),
+
        }
+
    }
+

+
    pub fn missing_object<E>(did: Did, oid: Oid, err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        MissingObject {
+
            did,
+
            commit: oid,
+
            source: Box::new(err),
+
        }
+
        .into()
+
    }
+

+
    pub fn invalid_object_type(did: Did, oid: Oid, kind: Option<String>) -> Self {
+
        InvalidObjectType { did, oid, kind }.into()
+
    }
+

+
    pub fn other<E>(err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self::Other {
+
            source: Box::new(err),
+
        }
+
    }
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
#[error("the object {oid} for {did} of unexpected type {kind:?}")]
+
pub struct InvalidObjectType {
+
    did: Did,
+
    oid: Oid,
+
    kind: Option<String>,
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
#[error("the commit {commit} for {did} is missing")]
+
pub struct MissingObject {
+
    did: Did,
+
    commit: Oid,
+
    source: Box<dyn std::error::Error + Send + Sync + 'static>,
+
}
+

+
/// Find the merge base of two commits.
+
///
+
/// Typically implemented by a Git repository.
+
pub trait FindMergeBase {
+
    /// Produce the [`MergeBase`] of commits `a` and `b`.
+
    fn merge_base(&self, a: Oid, b: Oid) -> Result<MergeBase, MergeBaseError>;
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
#[error("failed to find merge base for {a} and {b} due to: {source}")]
+
pub struct MergeBaseError {
+
    a: Oid,
+
    b: Oid,
+
    source: Box<dyn std::error::Error + Send + Sync + 'static>,
+
}
+

+
impl MergeBaseError {
+
    pub fn new<E>(a: Oid, b: Oid, source: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self {
+
            a,
+
            b,
+
            source: Box::new(source),
+
        }
+
    }
+
}
+

+
/// Calculate the ancestry of two commits.
+
///
+
/// Typically implemented by a Git repository.
+
pub trait Ancestry {
+
    /// Produce the [`GraphAheadBehind`] of `commit` and `upstream`.
+
    ///
+
    /// The result should provide how many commits are ahead and behind when
+
    /// comparing the `commit` and `upstream`.
+
    fn graph_ahead_behind(
+
        &self,
+
        commit: Oid,
+
        upstream: Oid,
+
    ) -> Result<GraphAheadBehind, GraphDescendant>;
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
#[error("failed to check if {commit} is an ancestor of {upstream} due to: {source}")]
+
pub struct GraphDescendant {
+
    commit: Oid,
+
    upstream: Oid,
+
    source: Box<dyn std::error::Error + Send + Sync + 'static>,
+
}
+

+
// ===========================================
+
// `git2` implementations of the above effects
+
// ===========================================
+

+
impl FindMergeBase for git2::Repository {
+
    fn merge_base(&self, a: Oid, b: Oid) -> Result<MergeBase, MergeBaseError> {
+
        self.merge_base(*a, *b)
+
            .map_err(|err| MergeBaseError {
+
                a,
+
                b,
+
                source: Box::new(err),
+
            })
+
            .map(|base| MergeBase {
+
                a,
+
                b,
+
                base: base.into(),
+
            })
+
    }
+
}
+

+
impl Ancestry for git2::Repository {
+
    fn graph_ahead_behind(
+
        &self,
+
        commit: Oid,
+
        upstream: Oid,
+
    ) -> Result<GraphAheadBehind, GraphDescendant> {
+
        self.graph_ahead_behind(*commit, *upstream)
+
            .map_err(|err| GraphDescendant {
+
                commit,
+
                upstream,
+
                source: Box::new(err),
+
            })
+
            .map(|(ahead, behind)| GraphAheadBehind { ahead, behind })
+
    }
+
}
+

+
impl FindObjects for git2::Repository {
+
    fn find_objects<'a, 'b, I>(
+
        &self,
+
        refname: &Qualified,
+
        dids: I,
+
    ) -> Result<FoundObjects, FindObjectsError>
+
    where
+
        I: Iterator<Item = &'b Did>,
+
    {
+
        let mut objects = BTreeMap::new();
+
        let mut missing_refs = BTreeSet::new();
+
        let mut missing_objects = BTreeMap::new();
+
        for did in dids {
+
            let name = &refname.with_namespace(did.as_key().into());
+
            let reference = match self.find_reference(name.as_str()) {
+
                Ok(reference) => reference,
+
                Err(e) if git::ext::is_not_found_err(&e) => {
+
                    missing_refs.insert(name.to_owned());
+
                    continue;
+
                }
+
                Err(e) => {
+
                    return Err(FindObjectsError::find_reference(name.to_owned(), e));
+
                }
+
            };
+
            let Some(oid) = reference.target().map(Oid::from) else {
+
                log::warn!(target: "radicle", "Missing target for reference `{name}`");
+
                continue;
+
            };
+
            let object = match self.find_object(*oid, None) {
+
                Ok(object) => Object::new(&object).ok_or_else(|| {
+
                    FindObjectsError::invalid_object_type(
+
                        *did,
+
                        oid,
+
                        object.kind().map(|kind| kind.to_string()),
+
                    )
+
                }),
+
                Err(err) if git::ext::is_not_found_err(&err) => {
+
                    missing_objects.insert(*did, oid);
+
                    continue;
+
                }
+
                Err(err) => Err(FindObjectsError::find_object(oid, err)),
+
            };
+
            objects.insert(*did, object?);
+
        }
+
        Ok(FoundObjects {
+
            objects,
+
            missing_refs,
+
            missing_objects,
+
        })
+
    }
+
}
modified crates/radicle/src/git/canonical/error.rs
@@ -1,17 +1,20 @@
-
use std::path::PathBuf;
-

use thiserror::Error;

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

-
use super::CanonicalObjectType;
+
use super::{effects, ObjectType};
+
pub use effects::{FindObjectsError, MergeBaseError};

-
/// Error that can occur when calculation the [`Canonical::quorum`].
-
///
-
/// [`Canonical::quorum`]: super::Canonical
#[derive(Debug, Error)]
pub enum QuorumError {
-
    /// Could not determine a quorum [`Oid`], due to diverging tips.
+
    #[error("could not determine target for canonical reference '{refname}', found objects of different types")]
+
    DifferentTypes { refname: String },
+
    #[error(transparent)]
+
    Convergence(#[from] ConvergesError),
+
    #[error(transparent)]
+
    MergeBase(#[from] MergeBaseError),
+
    #[error("could not determine target for canonical reference '{refname}', no object with at least {threshold} vote(s) found (threshold not met)")]
+
    NoCandidates { refname: String, threshold: usize },
    #[error("could not determine target commit for canonical reference '{refname}', found diverging commits {longest} and {head}, with base commit {base} and threshold {threshold}")]
    DivergingCommits {
        refname: String,
@@ -26,178 +29,27 @@ pub enum QuorumError {
        threshold: usize,
        candidates: Vec<Oid>,
    },
-
    #[error("could not determine target for canonical reference '{refname}', found objects of different types")]
-
    DifferentTypes { refname: String },
-
    /// Could not determine a base candidate from the given set of delegates.
-
    #[error("could not determine target for canonical reference '{refname}', no object with at least {threshold} vote(s) found (threshold not met)")]
-
    NoCandidates { refname: String, threshold: usize },
-
    /// An error occurred from [`git2`].
-
    #[error(transparent)]
-
    Git(#[from] git2::Error),
-
}
-

-
#[derive(Debug, Error)]
-
#[error("failed to check if {head} is an ancestor of {canonical} due to: {source}")]
-
pub struct GraphDescendant {
-
    head: Oid,
-
    canonical: Oid,
-
    source: raw::Error,
}

#[derive(Debug, Error)]
-
#[error("the commit {commit} for {did} is missing from the repository {repo:?}")]
-
pub struct MissingObject {
-
    repo: PathBuf,
-
    did: Did,
-
    commit: Oid,
-
    source: raw::Error,
-
}
-

-
#[derive(Debug, Error)]
-
#[error("could not determine whether the commit {commit} for {did} is part of the repository {repo:?} due to: {source}")]
-
pub struct InvalidObject {
-
    repo: PathBuf,
-
    did: Did,
-
    commit: Oid,
-
    source: raw::Error,
-
}
-

-
#[derive(Debug, Error)]
-
#[error("the object {oid} for {did} in the repository {repo:?} is of unexpected type {kind:?}")]
-
pub struct InvalidObjectType {
-
    repo: PathBuf,
-
    did: Did,
-
    oid: Oid,
-
    kind: Option<git2::ObjectType>,
-
}
-

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

-
#[derive(Debug, Error)]
-
pub enum CanonicalError {
-
    #[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 },
-
    #[error("failed to find reference {name} due to: {source}")]
-
    FindReference { name: String, source: git2::Error },
-
}
-

-
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, oid: Oid, err: git2::Error) -> Self {
-
        MissingObject {
-
            repo,
-
            did,
-
            commit: oid,
-
            source: err,
-
        }
-
        .into()
-
    }
-

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

-
    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: oid,
-
            source: err,
-
        }
-
        .into()
-
    }
-

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

#[derive(Debug, Error)]
pub enum ConvergesError {
    #[error(transparent)]
-
    GraphDescendant(#[from] GraphDescendant),
+
    GraphDescendant(#[from] effects::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: CanonicalObjectType,
-
        expected: CanonicalObjectType,
-
    ) -> Self {
+
    pub(super) fn mismatched_object(oid: Oid, found: ObjectType, expected: ObjectType) -> Self {
        Self::MismatchedObject(MismatchedObject {
-
            repo,
            oid,
            found,
            expected,
added crates/radicle/src/git/canonical/quorum.rs
@@ -0,0 +1,317 @@
+
use std::{cmp::Ordering, collections::BTreeMap};
+

+
use crate::git::Oid;
+

+
use super::voting::{CommitVoting, TagVoting};
+
use super::{MergeBase, Object};
+

+
/// [`TagQuorum`] encapsulates the process of voting on tag objects and
+
/// producing a quorum, if any.
+
#[derive(Debug)]
+
pub struct TagQuorum {
+
    threshold: usize,
+
    voting: TagVoting,
+
}
+

+
impl TagQuorum {
+
    /// Construct a new [`TagQuorum`] given a set of [`Object`]s and a
+
    /// `threshold`.
+
    pub fn new<'a, I>(objects: I, threshold: usize) -> Self
+
    where
+
        I: Iterator<Item = &'a Object>,
+
    {
+
        let voting = TagVoting::from_targets(objects.filter_map(|object| match object {
+
            Object::Commit { .. } => None,
+
            Object::Tag { id } => Some(*id),
+
        }));
+
        Self { threshold, voting }
+
    }
+

+
    /// Perform the quorum calculation and produce the [`Oid`] of the Git tag
+
    /// that passes the quorum, if any.
+
    pub fn find_quorum(self) -> Result<Oid, TagQuorumFailure> {
+
        let mut votes = self.voting.votes();
+
        votes.candidates_past_threshold(self.threshold);
+
        if votes.number_of_candidates() > 1 {
+
            Err(TagQuorumFailure::DivergingTags {
+
                candidates: votes.candidates().cloned().collect(),
+
            })
+
        } else {
+
            votes
+
                .max_candidate()
+
                .cloned()
+
                .ok_or(TagQuorumFailure::NoCandidates)
+
        }
+
    }
+
}
+

+
#[derive(Debug, PartialEq, Eq)]
+
pub enum TagQuorumFailure {
+
    NoCandidates,
+
    DivergingTags { candidates: Vec<Oid> },
+
}
+

+
/// [`CommitQuorum`] encapsulates the process of voting on commit objects and
+
/// producing a quorum, if any.
+
///
+
/// Once constructed with [`CommitQuorum::new`],
+
/// [`CommitQuorum::next_candidate`] should be called. This produces a candidate
+
/// commit, and for each of the other commits, a merge base should be
+
/// calculated.
+
///
+
/// When a set of [`MergeBase`]s are found, it should be recorded using
+
/// [`CommitQuorum::found_merge_bases`].
+
///
+
/// Finally, [`CommitQuorum::find_quorum`] is used to calculate if there is a
+
/// quorum commit.
+
#[derive(Debug)]
+
pub struct CommitQuorum {
+
    threshold: usize,
+
    voting: CommitVoting,
+
    merge_bases: MergeBases,
+
}
+

+
/// The `MergeBaseKey` ensures that our [`MergeBases`] lookup table is
+
/// commutative when looking up a given merge base pair.
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+
struct MergeBaseKey {
+
    a: Oid,
+
    b: Oid,
+
}
+

+
impl MergeBaseKey {
+
    /// Ensure the ordering is always stable
+
    fn new(a: Oid, b: Oid) -> Self {
+
        if a < b {
+
            Self { a, b }
+
        } else {
+
            Self { a: b, b: a }
+
        }
+
    }
+
}
+

+
impl PartialOrd for MergeBaseKey {
+
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+
        Some(self.cmp(other))
+
    }
+
}
+

+
impl Ord for MergeBaseKey {
+
    fn cmp(&self, other: &Self) -> Ordering {
+
        match self.a.cmp(&other.a) {
+
            Ordering::Equal => self.b.cmp(&other.b),
+
            o => o,
+
        }
+
    }
+
}
+

+
/// A lookup table for merge bases, that is commutative in its keys. That is,
+
/// the following invariant should hold:
+
/// ```text, no_run
+
/// MergeBases::lookup(a, b) == MergeBases::lookup(b, a)
+
/// ```
+
#[derive(Clone, Debug, Default, PartialEq, Eq)]
+
struct MergeBases {
+
    lookup: BTreeMap<MergeBaseKey, Oid>,
+
}
+

+
impl MergeBases {
+
    /// Mark a [`MergeBase`] as found in the lookup table.
+
    fn found(
+
        &mut self,
+
        MergeBase {
+
            a: candidate,
+
            b: other,
+
            base,
+
        }: MergeBase,
+
    ) {
+
        self.lookup
+
            .insert(MergeBaseKey::new(candidate, other), base);
+
    }
+

+
    /// Lookup the base for two commits, `a` and `b` – note that this operation
+
    /// is commutative.
+
    fn lookup(&self, a: Oid, b: Oid) -> Option<&Oid> {
+
        self.lookup.get(&MergeBaseKey::new(a, b))
+
    }
+
}
+

+
impl CommitQuorum {
+
    /// Construct a new [`CommitQuorum`] given a set of [`Object`]s and a
+
    /// `threshold`.
+
    pub fn new<'a, I>(objects: I, threshold: usize) -> Self
+
    where
+
        I: Clone + Iterator<Item = &'a Object>,
+
    {
+
        let voting = CommitVoting::from_targets(objects.filter_map(|object| match object {
+
            Object::Commit { id } => Some(*id),
+
            Object::Tag { .. } => None,
+
        }));
+
        Self {
+
            threshold,
+
            voting,
+
            merge_bases: MergeBases::default(),
+
        }
+
    }
+

+
    /// Produces an iterator of the candidate commit paired with commits to
+
    /// compare against.
+
    ///
+
    /// A [`MergeBase`] should be calculated for each, and these should be
+
    /// recorded using [`CommitQuorum::found_merge_bases`].
+
    pub fn next_candidate(&mut self) -> Option<impl Iterator<Item = (Oid, Oid)>> {
+
        self.voting.next_candidate()
+
    }
+

+
    /// Record the [`MergeBase`]s for the [`CommitQuorum`].
+
    pub fn found_merge_bases(&mut self, bases: impl IntoIterator<Item = MergeBase>) {
+
        for base in bases {
+
            self.voting.found_merge_base(base);
+
            self.merge_bases.found(base);
+
        }
+
    }
+

+
    /// Perform the quorum calculation and produce the [`Oid`] of the Git commit
+
    /// that passes the quorum, if any.
+
    pub fn find_quorum(self) -> Result<Oid, CommitQuorumFailure> {
+
        let mut votes = self.voting.votes();
+
        votes.candidates_past_threshold(self.threshold);
+
        let mut longest = votes
+
            .pop_first_candidate()
+
            .ok_or(CommitQuorumFailure::NoCandidates)?;
+
        for candidate in votes.candidates() {
+
            let base = self.merge_bases.lookup(*candidate, longest).ok_or(
+
                CommitQuorumFailure::NoMergeBase {
+
                    a: *candidate,
+
                    b: longest,
+
                },
+
            )?;
+
            if *base == longest {
+
                // `head` is a successor of `longest`. Update `longest`.
+
                //
+
                //   o head
+
                //   |
+
                //   o longest (base)
+
                //   |
+
                //
+
                longest = *candidate;
+
            } else if base == candidate || *candidate == longest {
+
                // `head` is an ancestor of `longest`, or equal to it. Do nothing.
+
                //
+
                //   o longest             o longest, head (base)
+
                //   |                     |
+
                //   o head (base)   OR    o
+
                //   |                     |
+
                //
+
            } else {
+
                // The merge base between `head` and `longest` (`base`)
+
                // is neither `head` nor `longest`. Therefore, the branches have
+
                // diverged.
+
                //
+
                //    longest   head
+
                //           \ /
+
                //            o (base)
+
                //            |
+
                //
+
                return Err(CommitQuorumFailure::DivergingCommits {
+
                    base: *base,
+
                    longest,
+
                    candidate: *candidate,
+
                });
+
            }
+
        }
+
        Ok(longest)
+
    }
+
}
+

+
#[derive(Debug, PartialEq, Eq)]
+
pub enum CommitQuorumFailure {
+
    NoCandidates,
+
    DivergingCommits {
+
        base: Oid,
+
        longest: Oid,
+
        candidate: Oid,
+
    },
+
    NoMergeBase {
+
        a: Oid,
+
        b: Oid,
+
    },
+
}
+

+
#[allow(clippy::unwrap_used)]
+
#[cfg(test)]
+
mod test {
+
    use crate::git::{canonical::MergeBase, Oid};
+

+
    use super::MergeBases;
+

+
    fn commit(id: &str) -> Oid {
+
        id.parse().unwrap()
+
    }
+

+
    #[test]
+
    fn merge_base_commutative() {
+
        let c0 = commit("f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354");
+
        let c1 = commit("bfb1a513e420eade90b0e6be64117b861b16ecb5");
+
        let c2 = commit("8fc5160702365f231c77732a8fa162379e54f57a");
+

+
        let mut bases = MergeBases::default();
+
        bases.found(MergeBase {
+
            a: c2,
+
            b: c1,
+
            base: c0,
+
        });
+
        bases.found(MergeBase {
+
            a: c1,
+
            b: c2,
+
            base: c0,
+
        });
+
        assert_eq!(bases.lookup(c1, c2), Some(&c0));
+
        assert_eq!(bases.lookup(c2, c1), Some(&c0));
+
    }
+

+
    #[test]
+
    fn test_merge_bases() {
+
        let c0 = commit("f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354");
+
        let c1 = commit("bfb1a513e420eade90b0e6be64117b861b16ecb5");
+
        let c2 = commit("8fc5160702365f231c77732a8fa162379e54f57a");
+
        let b2 = commit("037a148170e3d41524b7c482a4798e5c2daeaa00");
+

+
        // B2 C2
+
        //   \|
+
        //   C1
+
        //   |
+
        //  C0
+
        let input = [
+
            MergeBase {
+
                a: b2,
+
                b: c2,
+
                base: c1,
+
            },
+
            MergeBase {
+
                a: c2,
+
                b: c1,
+
                base: c1,
+
            },
+
            MergeBase {
+
                a: b2,
+
                b: c1,
+
                base: c1,
+
            },
+
            MergeBase {
+
                a: c1,
+
                b: c0,
+
                base: c0,
+
            },
+
            MergeBase::refl(b2),
+
            MergeBase::refl(c2),
+
            MergeBase::refl(c1),
+
            MergeBase::refl(c0),
+
        ];
+
        let mut merge_bases = MergeBases::default();
+
        for i in input {
+
            merge_bases.found(i);
+
        }
+
        assert_eq!(merge_bases.lookup(b2, c2), Some(&c1));
+
    }
+
}
modified crates/radicle/src/git/canonical/rules.rs
@@ -26,7 +26,6 @@ use crate::git::fmt::{refname, RefString};
use crate::git::refspec::QualifiedPattern;
use crate::git::Qualified;
use crate::identity::{doc, Did};
-
use crate::storage::git::Repository;

const ASTERISK: char = '*';

@@ -631,16 +630,19 @@ impl Rules {
    ///
    /// N.b. it will find the first rule that is most specific for the given
    /// `refname`.
-
    pub fn canonical<'a, 'b>(
+
    pub fn canonical<'a, 'b, 'r, R>(
        &'a self,
        refname: Qualified<'b>,
-
        repo: &Repository,
-
    ) -> 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 {
-
            Ok(None)
-
        }
+
        repo: &'r R,
+
    ) -> Option<Canonical<'b, 'a, 'r, R, canonical::Initial>>
+
    where
+
        R: canonical::effects::Ancestry
+
            + canonical::effects::FindMergeBase
+
            + canonical::effects::FindObjects,
+
    {
+
        self.matches(&refname)
+
            .next()
+
            .map(|(_, rule)| Canonical::new(refname, rule, repo))
    }
}

@@ -1196,18 +1198,22 @@ mod tests {
        for (refname, oid) in tags.into_iter() {
            let canonical = rules
                .canonical(refname.clone(), &stored)
-
                .unwrap()
                .unwrap_or_else(|| {
                    panic!("there should be a matching rule for {refname}, rules: {rules:#?}")
                });
            if refname == failing {
-
                assert!(canonical.quorum(&repo).is_err());
+
                assert!(canonical.find_objects().unwrap().quorum().is_err());
            } else {
                assert_eq!(
                    canonical
-
                        .quorum(&repo)
+
                        .find_objects()
+
                        .unwrap()
+
                        .quorum()
                        .unwrap_or_else(|e| panic!("quorum error for {refname}: {e}")),
-
                    (refname, git::raw::ObjectType::Tag, oid),
+
                    canonical::Quorum {
+
                        refname,
+
                        object: canonical::Object::Tag { id: oid },
+
                    }
                )
            }
        }
added crates/radicle/src/git/canonical/voting.rs
@@ -0,0 +1,152 @@
+
use std::collections::BTreeMap;
+

+
use crate::git::Oid;
+

+
use super::MergeBase;
+

+
/// Keep track of [`Votes`] for quorums involving tag objects.
+
#[derive(Debug, Default)]
+
pub struct TagVoting {
+
    votes: Votes,
+
}
+

+
impl TagVoting {
+
    /// Build the initial set of votes given the set of `targets`. Each [`Oid`]
+
    /// will provide a single vote, where repeated [`Oid`]s will increment the
+
    /// vote count.
+
    pub fn from_targets(targets: impl Iterator<Item = Oid>) -> Self {
+
        let votes = targets.fold(Votes::default(), |mut votes, oid| {
+
            votes.vote(oid);
+
            votes
+
        });
+
        Self { votes }
+
    }
+

+
    /// Finish the voting process and get the [`Votes`] from the
+
    /// [`TagVoting`].
+
    pub 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, Default)]
+
pub struct CommitVoting {
+
    candidates: Vec<(Oid, Vec<Oid>)>,
+
    votes: Votes,
+
}
+

+
impl CommitVoting {
+
    /// Build the initial set of votes given the set of `targets`. Each [`Oid`]
+
    /// will provide a single vote, where repeated [`Oid`]s will increment the
+
    /// vote count.
+
    ///
+
    /// It will also build the candidates which can be produced using the
+
    /// [`CommitVoting::next_candidate`] method.
+
    pub 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)
+
            },
+
        );
+
        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`].
+
    pub 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 it counts towards the
+
    /// result.
+
    pub fn found_merge_base(
+
        &mut self,
+
        MergeBase {
+
            a: candidate,
+
            b: 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`].
+
    pub fn votes(self) -> Votes {
+
        self.votes
+
    }
+
}
+

+
/// 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)]
+
pub 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) {
+
        let votes = self.inner.entry(oid).or_default();
+
        *votes = votes.saturating_add(1);
+
    }
+

+
    /// Filter the candidates by the ones that have a number of votes that pass
+
    /// the `threshold`.
+
    #[inline]
+
    pub fn candidates_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]
+
    pub fn number_of_candidates(&self) -> usize {
+
        self.inner.len()
+
    }
+

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

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

+
    /// Get the candidate with the most votes.
+
    #[inline]
+
    pub fn max_candidate(&self) -> Option<&Oid> {
+
        self.inner
+
            .iter()
+
            .max_by(|(_, x), (_, y)| x.cmp(y))
+
            .map(|(oid, _)| oid)
+
    }
+
}
modified crates/radicle/src/storage.rs
@@ -127,7 +127,7 @@ pub enum RepositoryError {
    #[error("failed to get canonical reference rules: {0}")]
    CanonicalRefs(#[from] doc::CanonicalRefsError),
    #[error(transparent)]
-
    Canonical(#[from] canonical::error::CanonicalError),
+
    FindObjects(#[from] canonical::effects::FindObjectsError),
}

impl RepositoryError {
modified crates/radicle/src/storage/git.rs
@@ -11,6 +11,7 @@ use std::{fs, io};
use crypto::Verified;
use tempfile::TempDir;

+
use crate::git::canonical::Quorum;
use crate::identity::crefs::GetCanonicalRefs as _;
use crate::identity::doc::DocError;
use crate::identity::{CanonicalRefs, Doc, DocAt, RepoId};
@@ -284,6 +285,39 @@ pub struct Repository {
    pub backend: git2::Repository,
}

+
impl git::canonical::effects::Ancestry for Repository {
+
    fn graph_ahead_behind(
+
        &self,
+
        commit: Oid,
+
        upstream: Oid,
+
    ) -> Result<git::canonical::GraphAheadBehind, git::canonical::effects::GraphDescendant> {
+
        git::canonical::effects::Ancestry::graph_ahead_behind(&self.backend, commit, upstream)
+
    }
+
}
+

+
impl git::canonical::effects::FindMergeBase for Repository {
+
    fn merge_base(
+
        &self,
+
        a: Oid,
+
        b: Oid,
+
    ) -> Result<git::canonical::MergeBase, git::canonical::effects::MergeBaseError> {
+
        git::canonical::effects::FindMergeBase::merge_base(&self.backend, a, b)
+
    }
+
}
+

+
impl git::canonical::effects::FindObjects for Repository {
+
    fn find_objects<'a, 'b, I>(
+
        &self,
+
        refname: &Qualified<'a>,
+
        dids: I,
+
    ) -> Result<git::canonical::FoundObjects, git::canonical::effects::FindObjectsError>
+
    where
+
        I: Iterator<Item = &'b crate::prelude::Did>,
+
    {
+
        git::canonical::effects::FindObjects::find_objects(&self.backend, refname, dids)
+
    }
+
}
+

/// A set of [`Validation`] errors that a caller **must use**.
#[must_use]
#[derive(Debug, Default)]
@@ -761,10 +795,15 @@ impl ReadRepository for Repository {
        };
        Ok(crefs
            .rules()
-
            .canonical(refname, self)?
+
            .canonical(refname, self)
            .ok_or(RepositoryError::MissingBranchRule)?
-
            .quorum(self.raw())?)
-
        .map(|(refname, _, oid)| (refname, oid))
+
            .find_objects()?
+
            .quorum()?)
+
        .map(
+
            |Quorum {
+
                 refname, object, ..
+
             }| (refname, object.id()),
+
        )
    }

    fn identity_head(&self) -> Result<Oid, RepositoryError> {