Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
radicle: the great Canonical rewrite
Merged fintohaps opened 8 months ago

This change was inspired by the a story as old as time:

𝕃𝕖𝕥 𝕦𝕤 𝕞𝕚𝕩 𝕠𝕦𝕣 𝕓𝕦𝕤𝕚𝕟𝕖𝕤𝕤 𝕝𝕠𝕘𝕚𝕔 𝕨𝕚𝕥𝕙 𝕠𝕦𝕣 𝕀𝕆!

It was motivated by the fact that the canonical quorum logic was spread across two modules and also two different repositories (in the API sense). The radicle-remote-helper contained special logic for computing the quorum, and also relied on the logic with radicle itself. It would also mix using the Radicle storage repository and the working copy repository – resulting in issues where objects could exist in one and not the other.

The change begins by separating away the IO away from any of the business logic in the git::canonical module. To follow along, there are two important submodules:

  1. voting captures the different type of voting processes for commits and tags a. Tags are simple, where one Oid means one vote b. Commits are slightly more complicated. They begin with one Oid means one vote, but then it is expected that merge bases are calculated for pairs of commits. These merge bases are used to increase a vote for an Oid if it is the merge base of another commit.
  2. quorum builds on top of voting and uses the voting processes as well as the threshold to find the quorum for the tag or commit reference. a. For tags, the first past the threshold wins, but if multiple pass then it is an error. b. For commits, the merge base process should be used to increase the votes, until the caller is ready to find the quorum. At this point, the commits that pass the threshold are then compared to find the commit that is the child-most commit of all other candidates. If they diverge, then an error is returned.

There is also a convergence module that captures the logic that is required for the radicle-remote-helper. It essentially checks if a candidate object matches the expected objects, tags or commits, and performs the necessary convergence logic – checking that the candidate commit is converging with at least of the other Dids.

These two quorum processes, and the convergence process, essentially act as state machines and can be driven by the use of a Git repository for finding the merge bases. This is where the effects module comes in. The effects capture the necessary traits that are required to drive the state machines of the quorum processes. It is expected that a Git repository implements these, and in fact, a git2::Repository implementation is provided. The traits are useful, since it means that a git2::Repository can be swapped out and the logic would stay the same.

This all culminates into the new and improved Canonical and CanonicalWithConvergence. Both of which have methods find_quorum for performing the quorum process using a single provided repository.

This resulted in the semantic change of requiring that the radicle-remote-helper pushes the candidate commit, irregardless of whether it will be a fast-forward. For now, this is something that will be accepted while the UX can be improved in the future by providing detailed warnings of the divergence and ways to fix it. The benefit is that the tooling will never stop someone from diverging if that is in fact what they want to do.

17 files changed +2304 -1164 8a66e4d0 690f6b02
modified crates/radicle-cli/examples/git/git-push-canonical-annotated-tags.md
@@ -93,6 +93,8 @@ Now, Alice will create an annotated tag and push it:
$ 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
@@ -462,47 +462,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);
-
                            match canonical.quorum(&working) {
+
                            let canonical = canonical::Canonical::new(me, object, canonical)?;
+
                            match canonical.quorum() {
                                Ok(quorum) => set_canonical_refs.push(quorum),
-
                                Err(e) => canonical::io::handle_error(e, &dst, hints)?,
+
                                Err(e) => canonical::io::handle_error(e)?,
                            }
                        }
-
                        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,28 @@
use radicle::git;
-
use radicle::git::raw::Repository;
+
use radicle::git::canonical;
+
use radicle::git::canonical::effects;
+
use radicle::git::canonical::error::QuorumError;
+
use radicle::git::canonical::QuorumWithConvergence;
use radicle::prelude::Did;

-
use super::error;
-

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

/// Validates a vote to update a canonical reference during push.
-
pub(crate) struct Canonical<'a, 'b> {
-
    vote: Vote,
-
    canonical: git::canonical::Canonical<'a, 'b>,
+
pub(crate) struct Canonical<'a, 'b, 'r, R> {
+
    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 {
+
            canonical: canonical.with_convergence(me, object),
+
        })
    }

    /// Calculates the quorum of the [`git::canonical::Canonical`] provided.
@@ -50,95 +42,48 @@ impl<'a, 'b> Canonical<'a, 'b> {
    /// Ensures that the new head and the canonical commit do not diverge.
    ///
    /// [`head`]: crate::push::canonical::Canonical::head
-
    pub fn quorum(
-
        mut 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)) => {
-
                // Canonical head is an ancestor of head.
-
                let is_ff = self.vote.oid == quorum_head
-
                    || working
-
                        .graph_descendant_of(*self.vote.oid, *quorum_head)
-
                        .map_err(|err| {
-
                            error::Canonical::graph_descendant(self.vote.oid, quorum_head, err)
-
                        })?;
-

-
                if !is_ff && !converges {
-
                    Err(error::Canonical::heads_diverge(self.vote.oid, quorum_head))
-
                } else {
-
                    Ok((cref, quorum_type, quorum_head))
-
                }
-
            }
-
            Err(err) => Err(err.into()),
-
        }
+
    pub fn quorum(self) -> Result<(git::Qualified<'a>, canonical::Object), QuorumError> {
+
        self.canonical
+
            .quorum()
+
            .map(|QuorumWithConvergence { quorum, .. }| (quorum.refname, quorum.object))
    }
}

pub(crate) mod io {
-
    use radicle::git;
    use radicle::git::canonical::error::QuorumError;

    use crate::push::error;
-
    use crate::{hint, warn};
+
    use crate::warn;

    /// Handle recoverable errors, printing relevant information to the
    /// terminal. Otherwise, convert the error into an unrecoverable error
    /// [`error::CanonicalUnrecoverable`].
-
    pub fn handle_error(
-
        e: error::Canonical,
-
        canonical: &git::Qualified,
-
        hints: bool,
-
    ) -> Result<(), error::CanonicalUnrecoverable> {
+
    pub fn handle_error(e: QuorumError) -> 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(
-
                        format!("you are attempting to push a commit that would cause \
-
                                                 your upstream to diverge from the canonical reference {canonical}"),
-
                    );
-
                    hint(
-
                        "to integrate the remote changes, run `git pull --rebase` \
-
                                                 and try again",
-
                    );
-
                }
-
                Err(e.into())
+
            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(())
            }
-
            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(())
-
                }
-
                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,38 +9,16 @@ 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 },
}

#[derive(Debug, Error)]
-
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),
-
}
-

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

-
    pub fn heads_diverge(head: git::Oid, canonical: git::Oid) -> Self {
-
        Self::HeadsDiverge(HeadsDiverge { head, canonical })
-
    }
-
}
-

-
#[derive(Debug, Error)]
#[error("failed to check if {head} is an ancestor of {canonical} due to: {source}")]
pub struct GraphDescendant {
    head: git::Oid,
modified crates/radicle/src/git/canonical.rs
@@ -1,564 +1,500 @@
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();
-

-
        // Keep tags which pass the threshold.
-
        votes.votes_past_threshold(self.threshold());
+
/// 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,
+
}

-
        if votes.number_of_candidates() > 1 {
-
            return Err(QuorumError::DivergingTags {
-
                refname: self.refname.to_string(),
-
                threshold: self.threshold(),
-
                candidates: votes.candidates().cloned().collect(),
-
            });
-
        }
+
/// 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,
+
}

-
        let tag = votes
-
            .pop_first_candidate()
-
            .ok_or(QuorumError::NoCandidates {
-
                refname: self.refname.to_string(),
-
                threshold: self.threshold(),
-
            })?;
+
/// 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,
+
}

-
        Ok((*tag).into())
+
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,
+
        }
    }

-
    /// 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,
-
                });
-
            }
+
    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(()),
        }
-
        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,
-
                });
-
            }
-
        }
-

-
        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 found_merge_bases<I>(&mut self, bases: I)
+
    where
+
        I: Iterator<Item = MergeBase>,
+
    {
+
        self.commit_quorum.found_merge_bases(bases);
    }

-
    fn threshold(&self) -> usize {
-
        (*self.rule.threshold()).into()
+
    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 },
+
        });
+
        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 tag objects.
-
struct TagVoting {
-
    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 TagVoting {
-
    fn from_targets(targets: impl Iterator<Item = Oid>) -> Self {
-
        let votes = targets.fold(Votes::default(), |mut votes, oid| {
-
            votes.vote(oid);
-
            votes
-
        });
-
        Self { votes }
+
impl MergeBase {
+
    /// The merge base of the same commit is the commit itself.
+
    #[cfg(test)]
+
    pub fn trivial(oid: Oid) -> Self {
+
        Self {
+
            a: oid,
+
            b: oid,
+
            base: oid,
+
        }
+
    }
+

+
    /// The result of a merge base computation is trivial.
+
    pub fn is_trivial(&self) -> bool {
+
        if self.a == self.b {
+
            // By definition, the merge base of a commit and itself is itself.
+
            // These asserts will catch our fall in case we set an invalid
+
            // base in this case.
+
            debug_assert_eq!(self.a, self.base);
+
            debug_assert_eq!(self.b, self.base);
+
            true
+
        } else {
+
            false
+
        }
    }

-
    fn votes(self) -> Votes {
-
        self.votes
+
    /// Collapses a non-trivial and linear result of a merge base computation
+
    /// into a single commit, if possible.
+
    pub fn linear(self) -> Option<Oid> {
+
        if self.is_trivial() || (self.a != self.base && self.b != self.base) {
+
            None
+
        } else {
+
            Some(self.base)
+
        }
    }
}

-
/// 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,
+
/// 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 },
}

-
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 }
+
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,
+
        })
    }

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

-
    /// Finish the voting process and get the [`Votes`] from the
-
    /// [`CommitVoting`].
-
    fn votes(self) -> Votes {
-
        self.votes
+
    /// Checks if the object is a Git commit.
+
    pub fn is_commit(&self) -> bool {
+
        matches!(self, Self::Commit { .. })
    }
-
}

-
/// 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,
-
}
+
    /// Checks if the object is a Git tag.
+
    pub fn is_tag(&self) -> bool {
+
        matches!(self, Self::Commit { .. })
+
    }

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

-
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);
-
    }
+
/// 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,
+
}

-
    /// 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);
+
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"),
+
        }
    }
+
}

-
    /// Get the number of candidates this set of votes has.
-
    #[inline]
-
    fn number_of_candidates(&self) -> usize {
-
        self.inner.len()
-
    }
+
/// The result of checking the relationship between two commits in the commit 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,
+
}

-
    /// Get the set candidates.
-
    #[inline]
-
    fn candidates(&self) -> impl Iterator<Item = &Oid> {
-
        self.inner.keys()
+
impl GraphAheadBehind {
+
    /// Whether self represents a linear history between two commits.
+
    ///
+
    /// The following three conditions are equivalent characterizations of
+
    /// a linear history:
+
    ///  1. One commit is ahead and not behind of the other.
+
    ///  2. One commit is behind and not ahead of the other.
+
    ///  3. One commit can be "fast-forwarded" to the other.
+
    pub fn is_linear(&self) -> bool {
+
        self.ahead * self.behind == 0
    }
+
}

-
    /// 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)
-
    }
+
/// 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>,
+
}

-
    #[inline]
-
    fn safe_inc(&mut self, oid: Oid, n: u8) {
-
        let votes = self.inner.entry(oid).or_default();
-
        *votes = votes.saturating_add(n);
-
    }
+
/// [`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>,
}

-
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)),
+
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 +514,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 +588,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}");
-

-
        assert_eq!(quorum(&[*t1], 1, &repo).unwrap(), t1);
-
        assert_eq!(quorum(&[*t1, *t1], 2, &repo).unwrap(), t1);
+
    fn test_tag_quorum() {
+
        let t1 = tag("0480391dd7312d35c79a455ec5d004657260b358");
+
        let t2 = tag("a2eec713ec5c287ecdf13a0180f68acfef7962d0");

-
        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::trivial(c2.id()),
+
            MergeBase::trivial(c1.id()),
+
            MergeBase::trivial(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::trivial(b2.id()),
+
            MergeBase::trivial(c2.id()),
+
            MergeBase::trivial(c1.id()),
+
            MergeBase::trivial(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::trivial(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::trivial(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::trivial(b2.id()),
+
            MergeBase::trivial(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::trivial(b2.id()),
+
            MergeBase::trivial(c2.id()),
+
            MergeBase::trivial(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::trivial(a1.id()),
+
            MergeBase::trivial(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::trivial(a1.id()),
+
            MergeBase::trivial(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::trivial(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::trivial(b2.id()),
+
            MergeBase::trivial(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::trivial(b2.id()),
+
            MergeBase::trivial(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 +1215,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::trivial(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::trivial(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::trivial(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::trivial(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::trivial(m1.id()),
+
            MergeBase::trivial(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::trivial(m1.id()),
+
            MergeBase::trivial(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,125 @@
+
use std::{fmt, ops::ControlFlow};
+

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

+
use super::{effects, error, 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 |= self.repo.graph_ahead_behind(commit, upstream)?.is_linear();
+
                    }
+
                    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 commit graph using the `commit` and `upstream`.
+
    GraphCheck { commit: Oid, upstream: Oid },
+
    /// Mark that tags always converge – there 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 { .. })
+
            | (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),
+
        }
+
    }
+
}
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} is 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,315 @@
+
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 {
+
        (self.a, self.b).cmp(&(other.a, other.b))
+
    }
+
}
+

+
/// 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
+
                //   |                     |
+
                //
+
                continue;
+
            } 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::trivial(b2),
+
            MergeBase::trivial(c2),
+
            MergeBase::trivial(c1),
+
            MergeBase::trivial(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,144 @@
+
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, merge_base: MergeBase) {
+
        // Avoid double counting the same commits
+
        if let Some(oid) = merge_base.linear() {
+
            self.votes.vote(oid)
+
        }
+
    }
+

+
    /// 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> {