diff --git a/crates/radicle-cli/examples/git/git-push-diverge.md b/crates/radicle-cli/examples/git/git-push-diverge.md index 424eab75..4a79a1aa 100644 --- a/crates/radicle-cli/examples/git/git-push-diverge.md +++ b/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 diff --git a/crates/radicle-cli/examples/rad-id-missing-commits.md b/crates/radicle-cli/examples/rad-id-missing-commits.md deleted file mode 100644 index f684811e..00000000 --- a/crates/radicle-cli/examples/rad-id-missing-commits.md +++ /dev/null @@ -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`. diff --git a/crates/radicle-cli/tests/commands.rs b/crates/radicle-cli/tests/commands.rs index 0d5e53ae..4529e615 100644 --- a/crates/radicle-cli/tests/commands.rs +++ b/crates/radicle-cli/tests/commands.rs @@ -461,47 +461,6 @@ fn rad_id_threshold_soft_fork() { .unwrap(); } -#[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"]); diff --git a/crates/radicle-node/src/worker/fetch.rs b/crates/radicle-node/src/worker/fetch.rs index 40eebfd8..53428355 100644 --- a/crates/radicle-node/src/worker/fetch.rs +++ b/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}"); - continue; - } + Some(canonical) => canonical, + None => continue, }; - match canonical.quorum(&repo.backend) { + 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() { 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, diff --git a/crates/radicle-remote-helper/src/push.rs b/crates/radicle-remote-helper/src/push.rs index 56b3678e..494875c1 100644 --- a/crates/radicle-remote-helper/src/push.rs +++ b/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)", )?; diff --git a/crates/radicle-remote-helper/src/push/canonical.rs b/crates/radicle-remote-helper/src/push/canonical.rs index 524402e4..2691b8ad 100644 --- a/crates/radicle-remote-helper/src/push/canonical.rs +++ b/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 { + 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 }), - }, + } } } } diff --git a/crates/radicle-remote-helper/src/push/error.rs b/crates/radicle-remote-helper/src/push/error.rs index 6ce8d145..abc2abc0 100644 --- a/crates/radicle-remote-helper/src/push/error.rs +++ b/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), diff --git a/crates/radicle/src/git/canonical.rs b/crates/radicle/src/git/canonical.rs index 3786e856..aab1d20e 100644 --- a/crates/radicle/src/git/canonical.rs +++ b/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`]. +/// 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. /// -/// The general construction of `Canonical` is by using the [`Canonical::new`] -/// constructor. +/// 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. /// -/// `Canonical` can then be used for performing calculations about the -/// canonicity of the reference, most importantly the [`Canonical::quorum`]. -/// -/// 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, + repo: &'r R, + objects: BTreeMap, + missing: Missing, + _marker: PhantomData, } -/// 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 fmt::Display for CanonicalObjectType { +impl<'a, 'b, 'r, R, T> fmt::Debug for Canonical<'a, 'b, 'r, R, T> { 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("Canonical") + .field("refname", &self.refname) + .field("rule", &self.rule) + .field("objects", &self.objects) + .field("missing", &self.missing) + .finish() } } -impl CanonicalObjectType { - /// Construct the [`CanonicalObjectType`] from a [`git2::ObjectType`]. - pub fn new(kind: git::raw::ObjectType) -> Option { - match kind { - ObjectType::Commit => Some(Self::Commit), - ObjectType::Tag => Some(Self::Tag), - _ => None, - } +/// 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 { + f.debug_struct("CanonicalWithConvergence") + .field("canonical", &self.canonical) + .field("convergence", &self.convergence) + .finish() } } -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 { - let mut tips = BTreeMap::new(); - for delegate in rule.allowed().iter() { - let name = &refname.with_namespace(delegate.as_key().into()); +impl<'a, 'b, 'r, R> AsRef> + for CanonicalWithConvergence<'a, 'b, 'r, R> +{ + fn as_ref(&self) -> &Canonical<'a, 'b, 'r, R, ObjectsFound> { + &self.canonical + } +} - 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, + repo, + missing: Missing::default(), + objects: BTreeMap::new(), + _marker: PhantomData, + } + } + + /// Find the objects for the [`Canonical`] computation, and prepare it to + /// find the quorum. + pub fn find_objects( + self, + ) -> Result, 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, + }) + } +} + +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, + } + } + + /// Find the [`Quorum`] for the canonical computation. + pub fn quorum(self) -> Result, 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, + )), + } + } + + /// 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 + } + + /// Mark the `objects` provided as found, removing them from the [`Missing`] + /// set. + pub fn found_objects(&mut self, objects: impl IntoIterator) { + let objects = objects.into_iter(); + for (did, object) in objects { + self.missing.found(&did, &self.refname); + self.objects.insert(did, object); + } + } + + 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; + + effects::MergeBaseError::new(a, b, NoMergeBase).into() + } + } + } +} + +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, 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 + } + None => false, + }; + let quorum = self.canonical.quorum()?; + Ok(QuorumWithConvergence { quorum, converges }) + } +} + +/// 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, +} + +/// 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, +} + +/// 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, +} + +impl<'a, 'b> QuorumFinder<'a, 'b> { + fn new<'c, I>(refname: Qualified<'a>, rule: &'b ValidRule, objects: I) -> Self + where + I: Iterator + 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, + } + } + + fn find_merge_bases(&mut self) -> ControlFlow<(), impl Iterator> { + match self.commit_quorum.next_candidate() { + Some(candidate) => ControlFlow::Continue(candidate), + None => ControlFlow::Break(()), + } + } + + fn found_merge_bases(&mut self, bases: I) + where + I: Iterator, + { + self.commit_quorum.found_merge_bases(bases); + } + + fn find_quorum( + self, + ) -> ( + Result, quorum::CommitQuorumFailure>, + Result, 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) + } +} + +/// 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 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 + } + } + + /// Collapses a non-trivial and linear result of a merge base computation + /// into a single commit, if possible. + pub fn linear(self) -> Option { + if self.is_trivial() || (self.a != self.base && self.b != self.base) { + None + } else { + Some(self.base) + } + } +} + +/// 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 Object { + pub fn new(obj: &crate::git::raw::Object) -> Option { + 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, }) } - pub fn find_object_for( - did: &Did, - oid: Oid, - repo: &raw::Repository, - ) -> Result { - 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)), + /// The [`Oid`] of the [`Object`] + pub fn id(&self) -> Oid { + match self { + Object::Commit { id } => *id, + Object::Tag { id } => *id, } } - /// Returns `true` if there were no tips found for any of the DIDs for - /// the given reference. + /// Checks if the object is a Git commit. + pub fn is_commit(&self) -> bool { + matches!(self, Self::Commit { .. }) + } + + /// Checks if the object is a Git tag. + pub fn is_tag(&self) -> bool { + matches!(self, Self::Commit { .. }) + } + + /// Returns the [`ObjectType`] of the [`Object`]. + pub fn object_type(&self) -> ObjectType { + match self { + Object::Commit { .. } => ObjectType::Commit, + Object::Tag { .. } => ObjectType::Tag, + } + } +} + +/// Supported Git object types for canonical computation +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ObjectType { + /// The Git object corresponds to a commit. + Commit, + /// The Git object corresponds to a tag. + Tag, +} + +impl fmt::Display for ObjectType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ObjectType::Commit => f.write_str("commit"), + ObjectType::Tag => f.write_str("tag"), + } + } +} + +/// 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, +} + +impl GraphAheadBehind { + /// Whether self represents a linear history between two commits. /// - /// 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 - } - - /// 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)); - } - - /// 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) - } - - /// 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) - } - - /// 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 { - /// Ensures [`Oid`]s are of the same object type - enum Objects { - Commits(NonEmpty), - Tags(NonEmpty), - } - - 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 insert( - mut self, - oid: Oid, - kind: CanonicalObjectType, - ) -> Result { - 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), - }, - } - } - } - - 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, - ) - })?) - } - } - } - - objects - }; - - 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) - } - } - } - - fn quorum_tag(&self) -> Result { - 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()); - - if votes.number_of_candidates() > 1 { - return Err(QuorumError::DivergingTags { - refname: self.refname.to_string(), - threshold: self.threshold(), - candidates: votes.candidates().cloned().collect(), - }); - } - - let tag = votes - .pop_first_candidate() - .ok_or(QuorumError::NoCandidates { - refname: self.refname.to_string(), - threshold: self.threshold(), - })?; - - Ok((*tag).into()) - } - - /// Computes the quorum or "canonical" tip based on the tips, of `Canonical`, - /// and the threshold. This can be described as the latest commit that is - /// included in at least `threshold` histories. In case there are multiple tips - /// passing the threshold, and they are divergent, an error is returned. - /// - /// Also returns an error if `heads` is empty or `threshold` cannot be - /// satisified with the number of heads given. - fn quorum_commit(&self, repo: &raw::Repository) -> Result { - let mut voting = - CommitVoting::from_targets(self.tips.values().filter_map(|(commit, kind)| { - (*kind == CanonicalObjectType::Commit).then_some(*commit) - })); - while let Some(targets) = voting.next_candidate() { - for (candidate, other) in targets { - let base = Oid::from(repo.merge_base(*candidate, *other)?); - voting.found_merge_base(MergeBase { - candidate, - other, - base, - }); - } - } - let mut votes = voting.votes(); - - // Keep commits which pass the threshold. - votes.votes_past_threshold(self.threshold()); - - let mut longest = votes - .pop_first_candidate() - .ok_or(QuorumError::NoCandidates { - refname: self.refname.to_string(), - threshold: self.threshold(), - })?; - - // Now that all scores are calculated, figure out what is the longest branch - // that passes the threshold. In case of divergence, return an error. - for head in votes.candidates() { - let base = repo.merge_base(**head, *longest)?; - - if base == *longest { - // `head` is a successor of `longest`. Update `longest`. - // - // o head - // | - // o longest (base) - // | - // - longest = *head; - } else if base == **head || *head == longest { - // `head` is an ancestor of `longest`, or equal to it. Do nothing. - // - // o longest o longest, head (base) - // | | - // o head (base) OR o - // | | - // - } else { - // The merge base between `head` and `longest` (`base`) - // is neither `head` nor `longest`. Therefore, the branches have - // diverged. - // - // longest head - // \ / - // o (base) - // | - // - return Err(QuorumError::DivergingCommits { - refname: self.refname.to_string(), - threshold: self.threshold(), - base: base.into(), - longest, - head: *head, - }); - } - } - - 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 threshold(&self) -> usize { - (*self.rule.threshold()).into() + /// 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 } } -/// Keep track of [`Votes`] for quorums involving tag objects. -struct TagVoting { - votes: Votes, +/// 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, + /// Any missing references while attempting to find the objects. + pub missing_refs: BTreeSet>, + // 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, } -impl TagVoting { - fn from_targets(targets: impl Iterator) -> Self { - let votes = targets.fold(Votes::default(), |mut votes, oid| { - votes.vote(oid); - votes - }); - Self { votes } - } - - fn votes(self) -> Votes { - self.votes - } +/// [`Missing`] marks whether there were any missing references or objects. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Missing { + pub refs: BTreeSet>, + pub objects: BTreeMap, } -/// 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)>, - votes: Votes, -} - -impl CommitVoting { - /// Build the initial set voting. - fn from_targets(targets: impl Iterator + Clone) -> Self { - let ts = targets.clone(); - let (candidates, votes) = targets.enumerate().fold( - (Vec::new(), Votes::default()), - |(mut candidates, mut votes), (i, oid)| { - candidates.push((oid, ts.clone().skip(i + 1).collect())); - votes.vote(oid); - (candidates, votes) - }, - ); - Self { candidates, votes } - } - - /// Get the next candidate to be considered for ancestry votes. - /// - /// The first of each pair will be the candidate commit, which should be - /// compared to the other commit to see what their common merge base is. The - /// merge base is then recorded using [`MergeBase`] and is recorded using - /// [`CommitVoting::found_merge_base`]. - fn next_candidate(&mut self) -> Option> { - self.candidates - .pop() - .map(|(oid, others)| others.into_iter().map(move |other| (oid, other))) - } - - /// Record a merge base, and add to the vote if necessary. - fn found_merge_base( - &mut self, - MergeBase { - candidate, - other, - base, - }: MergeBase, - ) { - // Avoid double counting the same commits - let is_same = candidate == other; - if !is_same && (base == candidate || base == other) { - self.votes.vote(base); - } - } - - /// Finish the voting process and get the [`Votes`] from the - /// [`CommitVoting`]. - fn votes(self) -> Votes { - self.votes - } -} - -/// Record a merge base between `candidate` and `other`. -struct MergeBase { - /// The candidate commit for the merge base. - candidate: Oid, - /// The commit that is being compared against for the merge base. - other: Oid, - /// The computed merge base commit. - base: Oid, -} - -/// Count the number of votes per [`Oid`]. -/// -/// Note that the count cannot exceed 255, since that is the maximum number the -/// `threshold` value can be. -#[derive(Debug, Default, PartialEq, Eq)] -struct Votes { - inner: BTreeMap, -} - -impl Votes { - /// Increase the vote count for `oid`. - /// - /// If `oid` does not exist in the set of [`Votes`] yet, then no vote will - /// be added. - #[inline] - fn vote(&mut self, oid: Oid) { - self.safe_inc(oid, 1); - } - - /// Filter the candidates by the ones that have a number of votes that pass - /// the `threshold`. - #[inline] - fn votes_past_threshold(&mut self, threshold: usize) { - self.inner.retain(|_, votes| *votes as usize >= threshold); - } - - /// Get the number of candidates this set of votes has. - #[inline] - fn number_of_candidates(&self) -> usize { - self.inner.len() - } - - /// Get the set candidates. - #[inline] - fn candidates(&self) -> impl Iterator { - self.inner.keys() - } - - /// Pop off the first candidate from the set of votes. - #[inline] - fn pop_first_candidate(&mut self) -> Option { - self.inner.pop_first().map(|(oid, _)| oid) - } - - #[inline] - fn safe_inc(&mut self, oid: Oid, n: u8) { - let votes = self.inner.entry(oid).or_default(); - *votes = votes.saturating_add(n); - } -} - -fn find_object_for( - did: &Did, - oid: Oid, - repo: &raw::Repository, -) -> Result { - 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 { - let tips: BTreeMap = 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); - - eprintln!("C0: {c0}"); - eprintln!("C1: {c1}"); - eprintln!("C2: {c2}"); + let t0 = fixtures::tag("v1", "", *c0, &repo); assert_matches!( - quorum(&[*c1, *c2, *c1, *c2], 2, &repo), - Err(QuorumError::DivergingCommits { .. }) - ); - - assert_matches!( - quorum(&[*c1, *c2], 1, &repo), - Err(QuorumError::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); - - assert_matches!( - quorum(&[*t1, *t2], 2, &repo), - Err(QuorumError::NoCandidates { .. }) - ); - - assert_matches!( - quorum(&[*t1, *c1], 1, &repo), + 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(&[*t1, *t2], 1, &repo), - Err(QuorumError::DivergingTags { .. }) + 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!( + cq.find_quorum(), + Err(CommitQuorumFailure::DivergingCommits { .. }) ); } #[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); + fn test_tag_quorum() { + let t1 = tag("0480391dd7312d35c79a455ec5d004657260b358"); + let t2 = tag("a2eec713ec5c287ecdf13a0180f68acfef7962d0"); - 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!( + TagQuorum::new([t1].iter(), 1).find_quorum().unwrap(), + t1.id() + ); + assert_eq!( + TagQuorum::new([t1, t1].iter(), 2).find_quorum().unwrap(), + t1.id() + ); + assert_matches!( + TagQuorum::new([t1, t2].iter(), 1).find_quorum(), + Err(TagQuorumFailure::DivergingTags { .. }) + ); + } - 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); + #[test] + 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 { .. }) + 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], 1, &repo), - Err(QuorumError::DivergingCommits { .. }) + 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], 1, &repo), - Err(QuorumError::DivergingCommits { .. }) + cq.find_quorum(), + Err(CommitQuorumFailure::DivergingCommits { .. }) ); + + // 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(&[*c2, *b2], 2, &repo), - Err(QuorumError::NoCandidates { .. }) - ); - assert_matches!( - quorum(&[*b2, *c2], 2, &repo), - Err(QuorumError::NoCandidates { .. }) - ); - 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); - 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 { .. }) + cq.find_quorum(), + Err(CommitQuorumFailure::DivergingCommits { .. }) ); + 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(&[*c2, *b2, *a1], 2, &repo), - Err(QuorumError::NoCandidates { .. }) + 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(&[*c2, *b2, *a1], 3, &repo), - Err(QuorumError::NoCandidates { .. }) + 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(&[*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([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(&[*a1, *a1, *c2, *c2, *c1], 2, &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(&[*a1, *a1, *c2, *c2, *c1], 1, &repo), - Err(QuorumError::DivergingCommits { .. }) - ); - assert_matches!( - quorum(&[*a1, *a1, *c2], 1, &repo), - Err(QuorumError::DivergingCommits { .. }) - ); - assert_matches!( - quorum(&[*b2, *b2, *c2, *c2], 1, &repo), - Err(QuorumError::DivergingCommits { .. }) - ); - 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); + 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], 1, &repo), - Err(QuorumError::DivergingCommits { .. }) + 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(&[*m2, *m1], 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, *m2], 2, &repo), - Err(QuorumError::NoCandidates { .. }) + 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, *m2, *c2], 1, &repo), - Err(QuorumError::DivergingCommits { .. }) + cq.find_quorum(), + Err(CommitQuorumFailure::DivergingCommits { .. }) ); - assert_matches!( - quorum(&[*m1, *a1], 1, &repo), - Err(QuorumError::DivergingCommits { .. }) - ); - assert_matches!( - quorum(&[*m1, *a1], 2, &repo), - Err(QuorumError::NoCandidates { .. }) - ); - 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 { .. }) + 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(&[*m1, *m3], 2, &repo), - Err(QuorumError::NoCandidates { .. }) + 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, *m1], 1, &repo), - Err(QuorumError::DivergingCommits { .. }) - ); - assert_matches!( - quorum(&[*m3, *m1], 2, &repo), - Err(QuorumError::NoCandidates { .. }) - ); - assert_matches!( - quorum(&[*m3, *m2], 1, &repo), - Err(QuorumError::DivergingCommits { .. }) - ); - 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)); } } diff --git a/crates/radicle/src/git/canonical/convergence.rs b/crates/radicle/src/git/canonical/convergence.rs new file mode 100644 index 00000000..ca5e2dea --- /dev/null +++ b/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, error::ConvergesError> + where + I: Iterator, + { + 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 { + 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), + } + } +} diff --git a/crates/radicle/src/git/canonical/effects.rs b/crates/radicle/src/git/canonical/effects.rs new file mode 100644 index 00000000..c985f7c6 --- /dev/null +++ b/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 + where + I: Iterator; +} + +/// 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, + }, + #[error("failed to find reference {refname} due to: {source}")] + FindReference { + refname: git::Namespaced<'static>, + source: Box, + }, + #[error("failed to find objects")] + Other { + source: Box, + }, +} + +impl FindObjectsError { + pub fn find_object(oid: Oid, err: E) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self::FindObject { + oid, + source: Box::new(err), + } + } + + pub fn find_reference(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(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) -> Self { + InvalidObjectType { did, oid, kind }.into() + } + + pub fn other(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, +} + +#[derive(Debug, thiserror::Error)] +#[error("the commit {commit} for {did} is missing")] +pub struct MissingObject { + did: Did, + commit: Oid, + source: Box, +} + +/// 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; +} + +#[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, +} + +impl MergeBaseError { + pub fn new(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; +} + +#[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, +} + +// =========================================== +// `git2` implementations of the above effects +// =========================================== + +impl FindMergeBase for git2::Repository { + fn merge_base(&self, a: Oid, b: Oid) -> Result { + 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 { + 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 + where + I: Iterator, + { + 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, + }) + } +} diff --git a/crates/radicle/src/git/canonical/error.rs b/crates/radicle/src/git/canonical/error.rs index 4903f9a7..d6b1529c 100644 --- a/crates/radicle/src/git/canonical/error.rs +++ b/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, }, - #[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, -} - -#[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, - ) -> 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, - ) -> 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, diff --git a/crates/radicle/src/git/canonical/quorum.rs b/crates/radicle/src/git/canonical/quorum.rs new file mode 100644 index 00000000..d3a9521f --- /dev/null +++ b/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, + { + 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 { + 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 }, +} + +/// [`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 { + 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, +} + +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, + { + 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> { + self.voting.next_candidate() + } + + /// Record the [`MergeBase`]s for the [`CommitQuorum`]. + pub fn found_merge_bases(&mut self, bases: impl IntoIterator) { + 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 { + 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)); + } +} diff --git a/crates/radicle/src/git/canonical/rules.rs b/crates/radicle/src/git/canonical/rules.rs index 59233456..ed0f6271 100644 --- a/crates/radicle/src/git/canonical/rules.rs +++ b/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>, 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> + 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 }, + } ) } } diff --git a/crates/radicle/src/git/canonical/voting.rs b/crates/radicle/src/git/canonical/voting.rs new file mode 100644 index 00000000..51c23d0b --- /dev/null +++ b/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) -> 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)>, + 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 + 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> { + 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, +} + +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 { + self.inner.keys() + } + + /// Pop off the first candidate from the set of votes. + #[inline] + pub fn pop_first_candidate(&mut self) -> Option { + 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) + } +} diff --git a/crates/radicle/src/storage.rs b/crates/radicle/src/storage.rs index 7f487df9..92892959 100644 --- a/crates/radicle/src/storage.rs +++ b/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 { diff --git a/crates/radicle/src/storage/git.rs b/crates/radicle/src/storage/git.rs index be025611..92bb19b5 100644 --- a/crates/radicle/src/storage/git.rs +++ b/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::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::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 + where + I: Iterator, + { + 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 {