Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
radicle: the great Canonical rewrite
Lorenz Leutgeb committed 8 months ago
commit 119a1248979564f85a4cf42c3a9e7ad918557d8b
parent 8a66e4d04492e3a3db410ace03ecc39e79dbb45c
16 files changed +2314 -1097
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);
+
                            let canonical = canonical::Canonical::new(me, object, canonical)?;
                            match canonical.quorum(&working) {
                                Ok(quorum) => set_canonical_refs.push(quorum),
                                Err(e) => canonical::io::handle_error(e, &dst, hints)?,
                            }
                        }
-
                        push(src, &dst, *force, &nid, &working, stored, patches, &signer)
+
                        Ok(explorer)
                    }
                }
            }
@@ -386,7 +387,9 @@ pub fn run(
    if !ok.is_empty() {
        let _ = stored.sign_refs(&signer)?;

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

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

use super::error;

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

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

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

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

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

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

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