radicle: the great Canonical rewrite

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

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

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

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

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

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

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

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

This resulted in the semantic change of requiring that the
`radicle-remote-helper` pushes the candidate commit, irregardless of whether it
will be a fast-forward. For now, this is something that will be accepted while
the UX can be improved in the future by providing detailed warnings of the
divergence and ways to fix it. The benefit is that the tooling will never stop
someone from diverging if that is in fact what they want to do.
This commit is contained in:
Lorenz Leutgeb 2025-07-23 22:04:40 +02:00
parent 8a66e4d044
commit 119a124897
16 changed files with 2374 additions and 1157 deletions

View File

@ -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

View File

@ -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`.

View File

@ -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"]);

View File

@ -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,

View File

@ -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)",
)?;

View File

@ -1,36 +1,41 @@
use radicle::git;
use radicle::git::canonical;
use radicle::git::canonical::effects;
use radicle::git::raw::Repository;
use radicle::prelude::Did;
use super::error;
pub(crate) struct Vote {
did: Did,
oid: git::Oid,
kind: git::canonical::CanonicalObjectType,
object: canonical::Object,
}
impl Vote {
pub(crate) fn id(&self) -> git::Oid {
self.object.id()
}
}
/// Validates a vote to update a canonical reference during push.
pub(crate) struct Canonical<'a, 'b> {
pub(crate) struct Canonical<'a, 'b, 'r, R> {
vote: Vote,
canonical: git::canonical::Canonical<'a, 'b>,
canonical: canonical::CanonicalWithConvergence<'a, 'b, 'r, R>,
}
impl<'a, 'b> Canonical<'a, 'b> {
impl<'a, 'b, 'r, R> Canonical<'a, 'b, 'r, R>
where
R: effects::Ancestry + effects::FindMergeBase + effects::FindObjects,
{
pub fn new(
me: Did,
head: git::Oid,
kind: git::canonical::CanonicalObjectType,
canonical: git::canonical::Canonical<'a, 'b>,
) -> Self {
Self {
vote: Vote {
did: me,
oid: head,
kind,
},
canonical,
}
object: canonical::Object,
canonical: canonical::Canonical<'a, 'b, 'r, R, canonical::Initial>,
) -> Result<Self, canonical::error::FindObjectsError> {
let canonical = canonical.find_objects()?;
Ok(Self {
vote: Vote { object },
canonical: canonical.with_convergence(me, object),
})
}
/// Calculates the quorum of the [`git::canonical::Canonical`] provided.
@ -51,31 +56,26 @@ impl<'a, 'b> Canonical<'a, 'b> {
///
/// [`head`]: crate::push::canonical::Canonical::head
pub fn quorum(
mut self,
self,
working: &Repository,
) -> Result<(git::Qualified<'a>, git::raw::ObjectType, git::Oid), error::Canonical> {
let converges = self
.canonical
.converges(working, (&self.vote.did, &self.vote.oid))?;
if converges || self.canonical.has_no_tips() || self.canonical.is_only(&self.vote.did) {
self.canonical
.modify_vote(self.vote.did, self.vote.oid, self.vote.kind);
}
match self.canonical.quorum(working) {
Ok((cref, quorum_type, quorum_head)) => {
) -> Result<(git::Qualified<'a>, canonical::Object), error::Canonical> {
match self.canonical.quorum() {
Ok(result) => {
let canonical::QuorumWithConvergence { quorum, converges } = result;
let canonical::Quorum { refname, object } = quorum;
let quorum_head = object.id();
// Canonical head is an ancestor of head.
let is_ff = self.vote.oid == quorum_head
let is_ff = self.vote.id() == quorum_head
|| working
.graph_descendant_of(*self.vote.oid, *quorum_head)
.graph_descendant_of(*self.vote.id(), *quorum_head)
.map_err(|err| {
error::Canonical::graph_descendant(self.vote.oid, quorum_head, err)
error::Canonical::graph_descendant(self.vote.id(), quorum_head, err)
})?;
if !is_ff && !converges {
Err(error::Canonical::heads_diverge(self.vote.oid, quorum_head))
Err(error::Canonical::heads_diverge(self.vote.id(), quorum_head))
} else {
Ok((cref, quorum_type, quorum_head))
Ok((refname, object))
}
}
Err(err) => Err(err.into()),
@ -100,7 +100,6 @@ pub(crate) mod io {
) -> Result<(), error::CanonicalUnrecoverable> {
match e {
error::Canonical::GraphDescendant(e) => Err(e.into()),
error::Canonical::Converges(e) => Err(e.into()),
error::Canonical::HeadsDiverge(e) => {
if hints {
hint(
@ -114,31 +113,32 @@ pub(crate) mod io {
}
Err(e.into())
}
error::Canonical::Quorum(e) => match e {
e @ QuorumError::DivergingCommits { .. } => {
warn(e.to_string());
warn("it is recommended to find a commit to agree upon");
Ok(())
error::Canonical::Quorum(e) => {
match e {
QuorumError::Convergence(err) => Err(err.into()),
QuorumError::MergeBase(err) => Err(err.into()),
e @ QuorumError::DivergingCommits { .. } => {
warn(e.to_string());
warn("it is recommended to find a commit to agree upon");
Ok(())
}
e @ QuorumError::DivergingTags { .. } => {
warn(e.to_string());
warn("it is recommended to find a tag to agree upon");
Ok(())
}
e @ QuorumError::DifferentTypes { .. } => {
warn(e.to_string());
warn("it is recommended to find an object type (either commit or tag) to agree upon");
Ok(())
}
e @ QuorumError::NoCandidates { .. } => {
warn(e.to_string());
warn("it is recommended to find an object (either commit or tag) to agree upon");
Ok(())
}
}
e @ QuorumError::DivergingTags { .. } => {
warn(e.to_string());
warn("it is recommended to find a tag to agree upon");
Ok(())
}
e @ QuorumError::DifferentTypes { .. } => {
warn(e.to_string());
warn("it is recommended to find an object type (either commit or tag) to agree upon");
Ok(())
}
e @ QuorumError::NoCandidates { .. } => {
warn(e.to_string());
warn(
"it is recommended to find an object (either commit or tag) to agree upon",
);
Ok(())
}
QuorumError::Git(err) => Err(error::CanonicalUnrecoverable::Git { source: err }),
},
}
}
}
}

View File

@ -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),

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,125 @@
use std::{fmt, ops::ControlFlow};
use crate::git::Oid;
use crate::prelude::Did;
use super::{effects, error, Object};
/// Checks for convergence and ensures that compared objects are of the same
/// type, i.e. commit or tag, to the [`Candidate`].
pub(super) struct Convergence<'r, R> {
repo: &'r R,
checker: Candidate,
}
impl<'r, R> fmt::Debug for Convergence<'r, R> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Convergence")
.field("checker", &self.checker)
.finish()
}
}
impl<'r, R> Convergence<'r, R>
where
R: effects::Ancestry,
{
pub fn new(repo: &'r R, candidate: Did, object: Object) -> Self {
Self {
repo,
checker: Candidate::new(candidate, object),
}
}
/// For each voter in `voters`:
/// 1. If the [`Object`] is a commit:
///
/// a. Ensure that the candidate and voting commit have a linear
/// relationship.
/// b. That [`Object`]'s type matches type of the [`Candidate`].
///
/// 2. If the [`Object`] is a tag, then ensure the [`Candidate`] object is
/// a tag.
/// 3. Always skip a vote that is the same as the [`Candidate`].
pub fn check<'a, I>(self, voters: I) -> Result<Option<(Did, Object)>, error::ConvergesError>
where
I: Iterator<Item = (&'a Did, &'a Object)>,
{
let mut converges = false;
for (did, object) in voters {
match self.checker.compare_to_candidate(did, *object) {
ControlFlow::Continue(c) => match c {
Effect::GraphCheck { commit, upstream } => {
converges |= self.repo.graph_ahead_behind(commit, upstream)?.is_linear();
}
Effect::TagConverges => {
converges = true;
continue;
}
Effect::SkipSelf => continue,
},
ControlFlow::Break(ConvergenceMismatch { expected, found }) => {
return Err(error::ConvergesError::mismatched_object(
expected.id(),
found.object_type(),
expected.object_type(),
));
}
}
}
Ok(converges.then_some((self.checker.candidate, self.checker.object)))
}
}
/// The candidate and their [`Object`] they are attempting to converge with.
#[derive(Debug)]
struct Candidate {
candidate: Did,
object: Object,
}
/// The "effect" that needs to be performed due to the result of
/// [`Candidate::compare_to_candidate`].
enum Effect {
/// Perform a check of the commit graph using the `commit` and `upstream`.
GraphCheck { commit: Oid, upstream: Oid },
/// Mark that tags always converge there is no ancestry check.
TagConverges,
/// Skip the [`Did`] since it is the same as the [`Candidate`].
SkipSelf,
}
/// The two [`Object`]s have different types.
pub(super) struct ConvergenceMismatch {
expected: Object,
found: Object,
}
impl Candidate {
fn new(candidate: Did, object: Object) -> Self {
Self { candidate, object }
}
fn compare_to_candidate(
&self,
did: &Did,
object: Object,
) -> ControlFlow<ConvergenceMismatch, Effect> {
if &self.candidate == did {
return ControlFlow::Continue(Effect::SkipSelf);
}
match (self.object, object) {
(e @ Object::Commit { .. }, f @ Object::Tag { .. })
| (e @ Object::Tag { .. }, f @ Object::Commit { .. }) => {
ControlFlow::Break(ConvergenceMismatch {
expected: e,
found: f,
})
}
(Object::Commit { id: commit }, Object::Commit { id: upstream }) => {
ControlFlow::Continue(Effect::GraphCheck { commit, upstream })
}
(Object::Tag { .. }, Object::Tag { .. }) => ControlFlow::Continue(Effect::TagConverges),
}
}
}

View File

@ -0,0 +1,252 @@
use std::collections::{BTreeMap, BTreeSet};
use crate::git;
use crate::git::{Oid, Qualified};
use crate::prelude::Did;
use super::{FoundObjects, GraphAheadBehind, MergeBase, Object};
/// Find objects for the canonical computation.
///
/// Typically implemented by a Git repository.
pub trait FindObjects {
/// Find the objects for the given [`Qualified`] reference name, for each
/// [`Did`]'s namespace.
///
/// The resulting [`FoundObjects`] includes all objects that were found, the
/// references that were missing, and the objects that were missing (if the
/// reference was found).
fn find_objects<'a, 'b, I>(
&self,
refname: &Qualified<'a>,
dids: I,
) -> Result<FoundObjects, FindObjectsError>
where
I: Iterator<Item = &'b Did>;
}
/// Error produced by the [`FindObjects::find_objects`] method.
#[derive(Debug, thiserror::Error)]
pub enum FindObjectsError {
#[error(transparent)]
InvalidObjectType(#[from] InvalidObjectType),
#[error(transparent)]
MissingObject(#[from] MissingObject),
#[error("failed to find object {oid} due to: {source}")]
FindObject {
oid: Oid,
source: Box<dyn std::error::Error + Send + Sync + 'static>,
},
#[error("failed to find reference {refname} due to: {source}")]
FindReference {
refname: git::Namespaced<'static>,
source: Box<dyn std::error::Error + Send + Sync + 'static>,
},
#[error("failed to find objects")]
Other {
source: Box<dyn std::error::Error + Send + Sync + 'static>,
},
}
impl FindObjectsError {
pub fn find_object<E>(oid: Oid, err: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
Self::FindObject {
oid,
source: Box::new(err),
}
}
pub fn find_reference<E>(refname: git::Namespaced<'static>, err: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
Self::FindReference {
refname,
source: Box::new(err),
}
}
pub fn missing_object<E>(did: Did, oid: Oid, err: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
MissingObject {
did,
commit: oid,
source: Box::new(err),
}
.into()
}
pub fn invalid_object_type(did: Did, oid: Oid, kind: Option<String>) -> Self {
InvalidObjectType { did, oid, kind }.into()
}
pub fn other<E>(err: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
Self::Other {
source: Box::new(err),
}
}
}
#[derive(Debug, thiserror::Error)]
#[error("the object {oid} for {did} is of unexpected type {kind:?}")]
pub struct InvalidObjectType {
did: Did,
oid: Oid,
kind: Option<String>,
}
#[derive(Debug, thiserror::Error)]
#[error("the commit {commit} for {did} is missing")]
pub struct MissingObject {
did: Did,
commit: Oid,
source: Box<dyn std::error::Error + Send + Sync + 'static>,
}
/// Find the merge base of two commits.
///
/// Typically implemented by a Git repository.
pub trait FindMergeBase {
/// Produce the [`MergeBase`] of commits `a` and `b`.
fn merge_base(&self, a: Oid, b: Oid) -> Result<MergeBase, MergeBaseError>;
}
#[derive(Debug, thiserror::Error)]
#[error("failed to find merge base for {a} and {b} due to: {source}")]
pub struct MergeBaseError {
a: Oid,
b: Oid,
source: Box<dyn std::error::Error + Send + Sync + 'static>,
}
impl MergeBaseError {
pub fn new<E>(a: Oid, b: Oid, source: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
Self {
a,
b,
source: Box::new(source),
}
}
}
/// Calculate the ancestry of two commits.
///
/// Typically implemented by a Git repository.
pub trait Ancestry {
/// Produce the [`GraphAheadBehind`] of `commit` and `upstream`.
///
/// The result should provide how many commits are ahead and behind when
/// comparing the `commit` and `upstream`.
fn graph_ahead_behind(
&self,
commit: Oid,
upstream: Oid,
) -> Result<GraphAheadBehind, GraphDescendant>;
}
#[derive(Debug, thiserror::Error)]
#[error("failed to check if {commit} is an ancestor of {upstream} due to: {source}")]
pub struct GraphDescendant {
commit: Oid,
upstream: Oid,
source: Box<dyn std::error::Error + Send + Sync + 'static>,
}
// ===========================================
// `git2` implementations of the above effects
// ===========================================
impl FindMergeBase for git2::Repository {
fn merge_base(&self, a: Oid, b: Oid) -> Result<MergeBase, MergeBaseError> {
self.merge_base(*a, *b)
.map_err(|err| MergeBaseError {
a,
b,
source: Box::new(err),
})
.map(|base| MergeBase {
a,
b,
base: base.into(),
})
}
}
impl Ancestry for git2::Repository {
fn graph_ahead_behind(
&self,
commit: Oid,
upstream: Oid,
) -> Result<GraphAheadBehind, GraphDescendant> {
self.graph_ahead_behind(*commit, *upstream)
.map_err(|err| GraphDescendant {
commit,
upstream,
source: Box::new(err),
})
.map(|(ahead, behind)| GraphAheadBehind { ahead, behind })
}
}
impl FindObjects for git2::Repository {
fn find_objects<'a, 'b, I>(
&self,
refname: &Qualified,
dids: I,
) -> Result<FoundObjects, FindObjectsError>
where
I: Iterator<Item = &'b Did>,
{
let mut objects = BTreeMap::new();
let mut missing_refs = BTreeSet::new();
let mut missing_objects = BTreeMap::new();
for did in dids {
let name = &refname.with_namespace(did.as_key().into());
let reference = match self.find_reference(name.as_str()) {
Ok(reference) => reference,
Err(e) if git::ext::is_not_found_err(&e) => {
missing_refs.insert(name.to_owned());
continue;
}
Err(e) => {
return Err(FindObjectsError::find_reference(name.to_owned(), e));
}
};
let Some(oid) = reference.target().map(Oid::from) else {
log::warn!(target: "radicle", "Missing target for reference `{name}`");
continue;
};
let object = match self.find_object(*oid, None) {
Ok(object) => Object::new(&object).ok_or_else(|| {
FindObjectsError::invalid_object_type(
*did,
oid,
object.kind().map(|kind| kind.to_string()),
)
}),
Err(err) if git::ext::is_not_found_err(&err) => {
missing_objects.insert(*did, oid);
continue;
}
Err(err) => Err(FindObjectsError::find_object(oid, err)),
};
objects.insert(*did, object?);
}
Ok(FoundObjects {
objects,
missing_refs,
missing_objects,
})
}
}

View File

@ -1,17 +1,20 @@
use std::path::PathBuf;
use thiserror::Error;
use crate::{git::raw, git::Oid, prelude::Did};
use crate::git::Oid;
use super::CanonicalObjectType;
use super::{effects, ObjectType};
pub use effects::{FindObjectsError, MergeBaseError};
/// Error that can occur when calculation the [`Canonical::quorum`].
///
/// [`Canonical::quorum`]: super::Canonical
#[derive(Debug, Error)]
pub enum QuorumError {
/// Could not determine a quorum [`Oid`], due to diverging tips.
#[error("could not determine target for canonical reference '{refname}', found objects of different types")]
DifferentTypes { refname: String },
#[error(transparent)]
Convergence(#[from] ConvergesError),
#[error(transparent)]
MergeBase(#[from] MergeBaseError),
#[error("could not determine target for canonical reference '{refname}', no object with at least {threshold} vote(s) found (threshold not met)")]
NoCandidates { refname: String, threshold: usize },
#[error("could not determine target commit for canonical reference '{refname}', found diverging commits {longest} and {head}, with base commit {base} and threshold {threshold}")]
DivergingCommits {
refname: String,
@ -26,178 +29,27 @@ pub enum QuorumError {
threshold: usize,
candidates: Vec<Oid>,
},
#[error("could not determine target for canonical reference '{refname}', found objects of different types")]
DifferentTypes { refname: String },
/// Could not determine a base candidate from the given set of delegates.
#[error("could not determine target for canonical reference '{refname}', no object with at least {threshold} vote(s) found (threshold not met)")]
NoCandidates { refname: String, threshold: usize },
/// An error occurred from [`git2`].
#[error(transparent)]
Git(#[from] git2::Error),
}
#[derive(Debug, Error)]
#[error("failed to check if {head} is an ancestor of {canonical} due to: {source}")]
pub struct GraphDescendant {
head: Oid,
canonical: Oid,
source: raw::Error,
}
#[derive(Debug, Error)]
#[error("the commit {commit} for {did} is missing from the repository {repo:?}")]
pub struct MissingObject {
repo: PathBuf,
did: Did,
commit: Oid,
source: raw::Error,
}
#[derive(Debug, Error)]
#[error("could not determine whether the commit {commit} for {did} is part of the repository {repo:?} due to: {source}")]
pub struct InvalidObject {
repo: PathBuf,
did: Did,
commit: Oid,
source: raw::Error,
}
#[derive(Debug, Error)]
#[error("the object {oid} for {did} in the repository {repo:?} is of unexpected type {kind:?}")]
pub struct InvalidObjectType {
repo: PathBuf,
did: Did,
oid: Oid,
kind: Option<git2::ObjectType>,
}
#[derive(Debug, Error)]
#[error("the object {oid} in the repository {repo:?} is of unexpected type {found} and was expected to be {expected}")]
#[error("the object {oid} is of unexpected type {found} and was expected to be {expected}")]
pub struct MismatchedObject {
repo: PathBuf,
oid: Oid,
found: CanonicalObjectType,
expected: CanonicalObjectType,
}
#[derive(Debug, Error)]
pub enum CanonicalError {
#[error(transparent)]
InvalidObjectType(#[from] InvalidObjectType),
#[error(transparent)]
MissingObject(#[from] MissingObject),
#[error("failed to find object {oid} due to: {source}")]
FindObject { oid: Oid, source: git2::Error },
#[error("failed to find reference {name} due to: {source}")]
FindReference { name: String, source: git2::Error },
}
impl CanonicalError {
pub(super) fn invalid_object_type(
repo: PathBuf,
did: Did,
oid: Oid,
kind: Option<git2::ObjectType>,
) -> Self {
InvalidObjectType {
repo,
did,
oid,
kind,
}
.into()
}
pub(super) fn missing_object(repo: PathBuf, did: Did, oid: Oid, err: git2::Error) -> Self {
MissingObject {
repo,
did,
commit: oid,
source: err,
}
.into()
}
pub(super) fn find_object(oid: Oid, err: git2::Error) -> Self {
Self::FindObject { oid, source: err }
}
pub(crate) fn find_reference(name: &str, e: git2::Error) -> CanonicalError {
Self::FindReference {
name: name.to_string(),
source: e,
}
}
}
#[derive(Debug, Error)]
pub enum FindObjectError {
#[error(transparent)]
InvalidObjectType(#[from] InvalidObjectType),
#[error(transparent)]
MissingObject(#[from] MissingObject),
#[error("failed to find object {oid} due to: {source}")]
FindObject { oid: Oid, source: git2::Error },
}
impl FindObjectError {
pub(super) fn find_object(oid: Oid, err: git2::Error) -> Self {
Self::FindObject { oid, source: err }
}
pub(super) fn missing_object(repo: PathBuf, did: Did, oid: Oid, err: git2::Error) -> Self {
MissingObject {
repo,
did,
commit: oid,
source: err,
}
.into()
}
pub(super) fn invalid_object_type(
repo: PathBuf,
did: Did,
oid: Oid,
kind: Option<git2::ObjectType>,
) -> Self {
InvalidObjectType {
repo,
did,
oid,
kind,
}
.into()
}
found: ObjectType,
expected: ObjectType,
}
#[derive(Debug, Error)]
pub enum ConvergesError {
#[error(transparent)]
GraphDescendant(#[from] GraphDescendant),
GraphDescendant(#[from] effects::GraphDescendant),
#[error(transparent)]
MismatchedObject(#[from] MismatchedObject),
#[error(transparent)]
FindObjectError(#[from] FindObjectError),
}
impl ConvergesError {
pub(super) fn graph_descendant(head: Oid, canonical: Oid, source: raw::Error) -> Self {
Self::GraphDescendant(GraphDescendant {
head,
canonical,
source,
})
}
pub(super) fn mismatched_object(
repo: PathBuf,
oid: Oid,
found: CanonicalObjectType,
expected: CanonicalObjectType,
) -> Self {
pub(super) fn mismatched_object(oid: Oid, found: ObjectType, expected: ObjectType) -> Self {
Self::MismatchedObject(MismatchedObject {
repo,
oid,
found,
expected,

View File

@ -0,0 +1,315 @@
use std::{cmp::Ordering, collections::BTreeMap};
use crate::git::Oid;
use super::voting::{CommitVoting, TagVoting};
use super::{MergeBase, Object};
/// [`TagQuorum`] encapsulates the process of voting on tag objects and
/// producing a quorum, if any.
#[derive(Debug)]
pub struct TagQuorum {
threshold: usize,
voting: TagVoting,
}
impl TagQuorum {
/// Construct a new [`TagQuorum`] given a set of [`Object`]s and a
/// `threshold`.
pub fn new<'a, I>(objects: I, threshold: usize) -> Self
where
I: Iterator<Item = &'a Object>,
{
let voting = TagVoting::from_targets(objects.filter_map(|object| match object {
Object::Commit { .. } => None,
Object::Tag { id } => Some(*id),
}));
Self { threshold, voting }
}
/// Perform the quorum calculation and produce the [`Oid`] of the Git tag
/// that passes the quorum, if any.
pub fn find_quorum(self) -> Result<Oid, TagQuorumFailure> {
let mut votes = self.voting.votes();
votes.candidates_past_threshold(self.threshold);
if votes.number_of_candidates() > 1 {
Err(TagQuorumFailure::DivergingTags {
candidates: votes.candidates().cloned().collect(),
})
} else {
votes
.max_candidate()
.cloned()
.ok_or(TagQuorumFailure::NoCandidates)
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum TagQuorumFailure {
NoCandidates,
DivergingTags { candidates: Vec<Oid> },
}
/// [`CommitQuorum`] encapsulates the process of voting on commit objects and
/// producing a quorum, if any.
///
/// Once constructed with [`CommitQuorum::new`],
/// [`CommitQuorum::next_candidate`] should be called. This produces a candidate
/// commit, and for each of the other commits, a merge base should be
/// calculated.
///
/// When a set of [`MergeBase`]s are found, it should be recorded using
/// [`CommitQuorum::found_merge_bases`].
///
/// Finally, [`CommitQuorum::find_quorum`] is used to calculate if there is a
/// quorum commit.
#[derive(Debug)]
pub struct CommitQuorum {
threshold: usize,
voting: CommitVoting,
merge_bases: MergeBases,
}
/// The `MergeBaseKey` ensures that our [`MergeBases`] lookup table is
/// commutative when looking up a given merge base pair.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct MergeBaseKey {
a: Oid,
b: Oid,
}
impl MergeBaseKey {
/// Ensure the ordering is always stable
fn new(a: Oid, b: Oid) -> Self {
if a < b {
Self { a, b }
} else {
Self { a: b, b: a }
}
}
}
impl PartialOrd for MergeBaseKey {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for MergeBaseKey {
fn cmp(&self, other: &Self) -> Ordering {
(self.a, self.b).cmp(&(other.a, other.b))
}
}
/// A lookup table for merge bases, that is commutative in its keys. That is,
/// the following invariant should hold:
/// ```text, no_run
/// MergeBases::lookup(a, b) == MergeBases::lookup(b, a)
/// ```
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct MergeBases {
lookup: BTreeMap<MergeBaseKey, Oid>,
}
impl MergeBases {
/// Mark a [`MergeBase`] as found in the lookup table.
fn found(
&mut self,
MergeBase {
a: candidate,
b: other,
base,
}: MergeBase,
) {
self.lookup
.insert(MergeBaseKey::new(candidate, other), base);
}
/// Lookup the base for two commits, `a` and `b` note that this operation
/// is commutative.
fn lookup(&self, a: Oid, b: Oid) -> Option<&Oid> {
self.lookup.get(&MergeBaseKey::new(a, b))
}
}
impl CommitQuorum {
/// Construct a new [`CommitQuorum`] given a set of [`Object`]s and a
/// `threshold`.
pub fn new<'a, I>(objects: I, threshold: usize) -> Self
where
I: Clone + Iterator<Item = &'a Object>,
{
let voting = CommitVoting::from_targets(objects.filter_map(|object| match object {
Object::Commit { id } => Some(*id),
Object::Tag { .. } => None,
}));
Self {
threshold,
voting,
merge_bases: MergeBases::default(),
}
}
/// Produces an iterator of the candidate commit paired with commits to
/// compare against.
///
/// A [`MergeBase`] should be calculated for each, and these should be
/// recorded using [`CommitQuorum::found_merge_bases`].
pub fn next_candidate(&mut self) -> Option<impl Iterator<Item = (Oid, Oid)>> {
self.voting.next_candidate()
}
/// Record the [`MergeBase`]s for the [`CommitQuorum`].
pub fn found_merge_bases(&mut self, bases: impl IntoIterator<Item = MergeBase>) {
for base in bases {
self.voting.found_merge_base(base);
self.merge_bases.found(base);
}
}
/// Perform the quorum calculation and produce the [`Oid`] of the Git commit
/// that passes the quorum, if any.
pub fn find_quorum(self) -> Result<Oid, CommitQuorumFailure> {
let mut votes = self.voting.votes();
votes.candidates_past_threshold(self.threshold);
let mut longest = votes
.pop_first_candidate()
.ok_or(CommitQuorumFailure::NoCandidates)?;
for candidate in votes.candidates() {
let base = self.merge_bases.lookup(*candidate, longest).ok_or(
CommitQuorumFailure::NoMergeBase {
a: *candidate,
b: longest,
},
)?;
if *base == longest {
// `head` is a successor of `longest`. Update `longest`.
//
// o head
// |
// o longest (base)
// |
//
longest = *candidate;
} else if base == candidate || *candidate == longest {
// `head` is an ancestor of `longest`, or equal to it. Do nothing.
//
// o longest o longest, head (base)
// | |
// o head (base) OR o
// | |
//
continue;
} else {
// The merge base between `head` and `longest` (`base`)
// is neither `head` nor `longest`. Therefore, the branches have
// diverged.
//
// longest head
// \ /
// o (base)
// |
//
return Err(CommitQuorumFailure::DivergingCommits {
base: *base,
longest,
candidate: *candidate,
});
}
}
Ok(longest)
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum CommitQuorumFailure {
NoCandidates,
DivergingCommits {
base: Oid,
longest: Oid,
candidate: Oid,
},
NoMergeBase {
a: Oid,
b: Oid,
},
}
#[allow(clippy::unwrap_used)]
#[cfg(test)]
mod test {
use crate::git::{canonical::MergeBase, Oid};
use super::MergeBases;
fn commit(id: &str) -> Oid {
id.parse().unwrap()
}
#[test]
fn merge_base_commutative() {
let c0 = commit("f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354");
let c1 = commit("bfb1a513e420eade90b0e6be64117b861b16ecb5");
let c2 = commit("8fc5160702365f231c77732a8fa162379e54f57a");
let mut bases = MergeBases::default();
bases.found(MergeBase {
a: c2,
b: c1,
base: c0,
});
bases.found(MergeBase {
a: c1,
b: c2,
base: c0,
});
assert_eq!(bases.lookup(c1, c2), Some(&c0));
assert_eq!(bases.lookup(c2, c1), Some(&c0));
}
#[test]
fn test_merge_bases() {
let c0 = commit("f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354");
let c1 = commit("bfb1a513e420eade90b0e6be64117b861b16ecb5");
let c2 = commit("8fc5160702365f231c77732a8fa162379e54f57a");
let b2 = commit("037a148170e3d41524b7c482a4798e5c2daeaa00");
// B2 C2
// \|
// C1
// |
// C0
let input = [
MergeBase {
a: b2,
b: c2,
base: c1,
},
MergeBase {
a: c2,
b: c1,
base: c1,
},
MergeBase {
a: b2,
b: c1,
base: c1,
},
MergeBase {
a: c1,
b: c0,
base: c0,
},
MergeBase::trivial(b2),
MergeBase::trivial(c2),
MergeBase::trivial(c1),
MergeBase::trivial(c0),
];
let mut merge_bases = MergeBases::default();
for i in input {
merge_bases.found(i);
}
assert_eq!(merge_bases.lookup(b2, c2), Some(&c1));
}
}

View File

@ -26,7 +26,6 @@ use crate::git::fmt::{refname, RefString};
use crate::git::refspec::QualifiedPattern;
use crate::git::Qualified;
use crate::identity::{doc, Did};
use crate::storage::git::Repository;
const ASTERISK: char = '*';
@ -631,16 +630,19 @@ impl Rules {
///
/// N.b. it will find the first rule that is most specific for the given
/// `refname`.
pub fn canonical<'a, 'b>(
pub fn canonical<'a, 'b, 'r, R>(
&'a self,
refname: Qualified<'b>,
repo: &Repository,
) -> Result<Option<Canonical<'b, 'a>>, canonical::error::CanonicalError> {
if let Some((_, rule)) = self.matches(&refname).next() {
Ok(Some(Canonical::new(&repo.backend, refname, rule)?))
} else {
Ok(None)
}
repo: &'r R,
) -> Option<Canonical<'b, 'a, 'r, R, canonical::Initial>>
where
R: canonical::effects::Ancestry
+ canonical::effects::FindMergeBase
+ canonical::effects::FindObjects,
{
self.matches(&refname)
.next()
.map(|(_, rule)| Canonical::new(refname, rule, repo))
}
}
@ -1196,18 +1198,22 @@ mod tests {
for (refname, oid) in tags.into_iter() {
let canonical = rules
.canonical(refname.clone(), &stored)
.unwrap()
.unwrap_or_else(|| {
panic!("there should be a matching rule for {refname}, rules: {rules:#?}")
});
if refname == failing {
assert!(canonical.quorum(&repo).is_err());
assert!(canonical.find_objects().unwrap().quorum().is_err());
} else {
assert_eq!(
canonical
.quorum(&repo)
.find_objects()
.unwrap()
.quorum()
.unwrap_or_else(|e| panic!("quorum error for {refname}: {e}")),
(refname, git::raw::ObjectType::Tag, oid),
canonical::Quorum {
refname,
object: canonical::Object::Tag { id: oid },
}
)
}
}

View File

@ -0,0 +1,144 @@
use std::collections::BTreeMap;
use crate::git::Oid;
use super::MergeBase;
/// Keep track of [`Votes`] for quorums involving tag objects.
#[derive(Debug, Default)]
pub struct TagVoting {
votes: Votes,
}
impl TagVoting {
/// Build the initial set of votes given the set of `targets`. Each [`Oid`]
/// will provide a single vote, where repeated [`Oid`]s will increment the
/// vote count.
pub fn from_targets(targets: impl Iterator<Item = Oid>) -> Self {
let votes = targets.fold(Votes::default(), |mut votes, oid| {
votes.vote(oid);
votes
});
Self { votes }
}
/// Finish the voting process and get the [`Votes`] from the
/// [`TagVoting`].
pub fn votes(self) -> Votes {
self.votes
}
}
/// Keep track of [`Votes`] for quorums involving commit objects.
///
/// Build a list of candidate commits and count how many "votes" each of them
/// has. Commits get a point for each direct vote, as well as for being part of
/// the ancestry of a commit given to this function.
#[derive(Debug, Default)]
pub struct CommitVoting {
candidates: Vec<(Oid, Vec<Oid>)>,
votes: Votes,
}
impl CommitVoting {
/// Build the initial set of votes given the set of `targets`. Each [`Oid`]
/// will provide a single vote, where repeated [`Oid`]s will increment the
/// vote count.
///
/// It will also build the candidates which can be produced using the
/// [`CommitVoting::next_candidate`] method.
pub fn from_targets(targets: impl Iterator<Item = Oid> + Clone) -> Self {
let ts = targets.clone();
let (candidates, votes) = targets.enumerate().fold(
(Vec::new(), Votes::default()),
|(mut candidates, mut votes), (i, oid)| {
candidates.push((oid, ts.clone().skip(i + 1).collect()));
votes.vote(oid);
(candidates, votes)
},
);
Self { candidates, votes }
}
/// Get the next candidate to be considered for ancestry votes.
///
/// The first of each pair will be the candidate commit, which should be
/// compared to the other commit to see what their common merge base is. The
/// merge base is then recorded using [`MergeBase`] and is recorded using
/// [`CommitVoting::found_merge_base`].
pub fn next_candidate(&mut self) -> Option<impl Iterator<Item = (Oid, Oid)>> {
self.candidates
.pop()
.map(|(oid, others)| others.into_iter().map(move |other| (oid, other)))
}
/// Record a merge base, and add to the vote if it counts towards the
/// result.
pub fn found_merge_base(&mut self, merge_base: MergeBase) {
// Avoid double counting the same commits
if let Some(oid) = merge_base.linear() {
self.votes.vote(oid)
}
}
/// Finish the voting process and get the [`Votes`] from the
/// [`CommitVoting`].
pub fn votes(self) -> Votes {
self.votes
}
}
/// Count the number of votes per [`Oid`].
///
/// Note that the count cannot exceed 255, since that is the maximum number the
/// `threshold` value can be.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Votes {
inner: BTreeMap<Oid, u8>,
}
impl Votes {
/// Increase the vote count for `oid`.
///
/// If `oid` does not exist in the set of [`Votes`] yet, then no vote will
/// be added.
#[inline]
fn vote(&mut self, oid: Oid) {
let votes = self.inner.entry(oid).or_default();
*votes = votes.saturating_add(1);
}
/// Filter the candidates by the ones that have a number of votes that pass
/// the `threshold`.
#[inline]
pub fn candidates_past_threshold(&mut self, threshold: usize) {
self.inner.retain(|_, votes| *votes as usize >= threshold);
}
/// Get the number of candidates this set of votes has.
#[inline]
pub fn number_of_candidates(&self) -> usize {
self.inner.len()
}
/// Get the set candidates.
#[inline]
pub fn candidates(&self) -> impl Iterator<Item = &Oid> {
self.inner.keys()
}
/// Pop off the first candidate from the set of votes.
#[inline]
pub fn pop_first_candidate(&mut self) -> Option<Oid> {
self.inner.pop_first().map(|(oid, _)| oid)
}
/// Get the candidate with the most votes.
#[inline]
pub fn max_candidate(&self) -> Option<&Oid> {
self.inner
.iter()
.max_by(|(_, x), (_, y)| x.cmp(y))
.map(|(oid, _)| oid)
}
}

View File

@ -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 {

View File

@ -11,6 +11,7 @@ use std::{fs, io};
use crypto::Verified;
use tempfile::TempDir;
use crate::git::canonical::Quorum;
use crate::identity::crefs::GetCanonicalRefs as _;
use crate::identity::doc::DocError;
use crate::identity::{CanonicalRefs, Doc, DocAt, RepoId};
@ -284,6 +285,39 @@ pub struct Repository {
pub backend: git2::Repository,
}
impl git::canonical::effects::Ancestry for Repository {
fn graph_ahead_behind(
&self,
commit: Oid,
upstream: Oid,
) -> Result<git::canonical::GraphAheadBehind, git::canonical::effects::GraphDescendant> {
git::canonical::effects::Ancestry::graph_ahead_behind(&self.backend, commit, upstream)
}
}
impl git::canonical::effects::FindMergeBase for Repository {
fn merge_base(
&self,
a: Oid,
b: Oid,
) -> Result<git::canonical::MergeBase, git::canonical::effects::MergeBaseError> {
git::canonical::effects::FindMergeBase::merge_base(&self.backend, a, b)
}
}
impl git::canonical::effects::FindObjects for Repository {
fn find_objects<'a, 'b, I>(
&self,
refname: &Qualified<'a>,
dids: I,
) -> Result<git::canonical::FoundObjects, git::canonical::effects::FindObjectsError>
where
I: Iterator<Item = &'b crate::prelude::Did>,
{
git::canonical::effects::FindObjects::find_objects(&self.backend, refname, dids)
}
}
/// A set of [`Validation`] errors that a caller **must use**.
#[must_use]
#[derive(Debug, Default)]
@ -761,10 +795,15 @@ impl ReadRepository for Repository {
};
Ok(crefs
.rules()
.canonical(refname, self)?
.canonical(refname, self)
.ok_or(RepositoryError::MissingBranchRule)?
.quorum(self.raw())?)
.map(|(refname, _, oid)| (refname, oid))
.find_objects()?
.quorum()?)
.map(
|Quorum {
refname, object, ..
}| (refname, object.id()),
)
}
fn identity_head(&self) -> Result<Oid, RepositoryError> {