radicle: canonical references payload

This commit is contained in:
Fintan Halpenny 2025-06-12 14:06:49 +02:00 committed by Lorenz Leutgeb
parent af6cf03acd
commit 7f646666bc
22 changed files with 579 additions and 285 deletions

View File

@ -21,7 +21,7 @@ $ git commit --amend -m "Neue Änderungen" --allow-empty -q
``` ~alice (stderr)
$ git push rad master -f
✓ Canonical head updated to 9170c8795d3a78f0381a0ffafb20ea69fb0f5b6b
✓ Canonical reference refs/heads/master updated to 9170c8795d3a78f0381a0ffafb20ea69fb0f5b6b
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+ fb25886...9170c87 master -> master (forced update)

View File

@ -91,8 +91,7 @@ commit:
``` ~alice (stderr)
$ git push rad -f
warn: could not determine canonical tip for `refs/heads/master`
warn: no commit found with at least 3 vote(s) (threshold not met)
warn: could not determine commit for canonical reference 'refs/heads/master', no commit with at least 3 vote(s) found (threshold not met)
warn: it is recommended to find a commit to agree upon
✓ Synced with 2 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
@ -117,7 +116,7 @@ become the canonical `master`.
``` ~bob (stderr)
$ git push rad
✓ Canonical head updated to 3a75f66dd0020c9a0355cc6ec21f15de989e2001
✓ Canonical reference refs/heads/master updated to 3a75f66dd0020c9a0355cc6ec21f15de989e2001
✓ Synced with 2 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
2a37862..0f9bd80 master -> master
@ -138,7 +137,7 @@ HEAD is now at 0f9bd80 Merge remote-tracking branch 'eve/master'
``` ~eve (stderr)
$ git push rad
✓ Canonical head updated to 0f9bd8035c04b3f73f5408e73e8454879b20800b
✓ Canonical reference refs/heads/master updated to 0f9bd8035c04b3f73f5408e73e8454879b20800b
✓ Synced with 2 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkux1aUQD2voWWukVb5nNUR7thrHveQG4pDQua8nVhib7Z
3a75f66..0f9bd80 master -> master

View File

@ -44,7 +44,7 @@ integrate Bob's changes before pushing ours:
``` ~alice (stderr) (fail) RAD_HINT=1
$ git push rad
hint: you are attempting to push a commit that would cause your upstream to diverge from the canonical head
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 branch to commit that is not a descendant of canonical head
error: failed to push some refs to 'rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
@ -62,7 +62,7 @@ f2de534 Second commit
```
``` ~alice RAD_SOCKET=/dev/null (stderr)
$ git push rad
✓ Canonical head updated to f6cff86594495e9beccfeda7c20173e55c1dd9fc
✓ Canonical reference refs/heads/master updated to f6cff86594495e9beccfeda7c20173e55c1dd9fc
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
f2de534..f6cff86 master -> master
```
@ -75,7 +75,7 @@ $ git reset --hard HEAD^ -q
```
``` ~alice RAD_SOCKET=/dev/null (stderr)
$ git push -f
✓ Canonical head updated to 319a7dc3b195368ded4b099f8c90bbb80addccd3
✓ Canonical reference refs/heads/master updated to 319a7dc3b195368ded4b099f8c90bbb80addccd3
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+ f6cff86...319a7dc master -> master (forced update)
```

View File

@ -35,7 +35,7 @@ Fast-forward
``` ~alice (stderr)
$ git push rad
✓ Canonical head updated to 319a7dc3b195368ded4b099f8c90bbb80addccd3
✓ Canonical reference refs/heads/master updated to 319a7dc3b195368ded4b099f8c90bbb80addccd3
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
f2de534..319a7dc master -> master
@ -54,7 +54,7 @@ push and the new canonical head becomes the previous commit again:
``` ~alice (stderr)
$ git push rad -f
✓ Canonical head updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
✓ Canonical reference refs/heads/master updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+ 319a7dc...f2de534 master -> master (forced update)

View File

@ -64,7 +64,7 @@ $ git commit -v -m "Define power requirements"
``` ~alice (stderr) RAD_SOCKET=/dev/null
$ git push rad master
✓ Canonical head updated to 3e674d1a1df90807e934f9ae5da2591dd6848a33
✓ Canonical reference refs/heads/master updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
f2de534..3e674d1 master -> master
```

View File

@ -16,7 +16,7 @@ $ git commit --amend --allow-empty -q -m "Amended change"
$ git checkout master -q
$ git merge feature/1 -q
$ git push rad master
✓ Canonical head updated to 954bcdb5008447ce294a61a21d7eb87afbe7f4a6
✓ Canonical reference refs/heads/master updated to 954bcdb5008447ce294a61a21d7eb87afbe7f4a6
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
f2de534..954bcdb master -> master
```

View File

@ -37,7 +37,7 @@ Finally, we push master and expect the patch to be merged.
``` (stderr) RAD_SOCKET=/dev/null
$ git push rad master
✓ Patch 696ec5508494692899337afe6713fe1796d0315c merged
✓ Canonical head updated to 737a10cfa29111afeb0d43cf3545cee386b939ec
✓ Canonical reference refs/heads/master updated to 737a10cfa29111afeb0d43cf3545cee386b939ec
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
f2de534..737a10c master -> master
```

View File

@ -70,7 +70,7 @@ When we push to `rad/master`, we automatically merge the patches:
$ git push rad master
✓ Patch 356f73863a8920455ff6e77cd9c805d68910551b merged
✓ Patch 696ec5508494692899337afe6713fe1796d0315c merged
✓ Canonical head updated to d6399c71702b40bae00825b3c444478d06b4e91c
✓ Canonical reference refs/heads/master updated to d6399c71702b40bae00825b3c444478d06b4e91c
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
f2de534..d6399c7 master -> master
```
@ -148,7 +148,7 @@ the first patch, even though they were pushed together.
$ git reset --hard HEAD^
$ git push -f rad
! Patch 356f73863a8920455ff6e77cd9c805d68910551b reverted at revision 356f738
✓ Canonical head updated to 20aa5dde6210796c3a2f04079b42316a31d02689
✓ Canonical reference refs/heads/master updated to 20aa5dde6210796c3a2f04079b42316a31d02689
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+ d6399c7...20aa5dd master -> master (forced update)
```

View File

@ -14,7 +14,7 @@ $ git checkout master -q
$ git merge feature/1
$ git push rad master
✓ Patch 8dfb4dcafc4346158c8160410dd3f2b0616ad4fe merged
✓ Canonical head updated to 20aa5dde6210796c3a2f04079b42316a31d02689
✓ Canonical reference refs/heads/master updated to 20aa5dde6210796c3a2f04079b42316a31d02689
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
f2de534..20aa5dd master -> master
```

View File

@ -38,7 +38,7 @@ $ git checkout master -q
$ git merge changes -q
$ git push rad master
✓ Patch acab0ec777a97d013f30be5d5d1aec32562ecb02 merged
✓ Canonical head updated to b2b6432af93f8fe188e32d400263021b602cfec8
✓ Canonical reference refs/heads/master updated to b2b6432af93f8fe188e32d400263021b602cfec8
✓ Synced with 1 seed(s)
https://app.radicle.xyz/nodes/[..]/rad:z3yXbb1sR6UG6ixxV2YF9jUP7ABra/tree/b2b6432af93f8fe188e32d400263021b602cfec8

View File

@ -12,7 +12,7 @@ Switched to branch 'master'
$ git merge feature/1
$ git push rad master
✓ Patch 696ec5508494692899337afe6713fe1796d0315c merged
✓ Canonical head updated to 20aa5dde6210796c3a2f04079b42316a31d02689
✓ Canonical reference refs/heads/master updated to 20aa5dde6210796c3a2f04079b42316a31d02689
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
f2de534..20aa5dd master -> master
```
@ -50,7 +50,7 @@ When pushing, notice that we're told our patch is reverted.
``` (stderr) RAD_SOCKET=/dev/null
$ git push rad master --force
! Patch 696ec5508494692899337afe6713fe1796d0315c reverted at revision 696ec55
✓ Canonical head updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
✓ Canonical reference refs/heads/master updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+ 20aa5dd...f2de534 master -> master (forced update)
```

View File

@ -92,7 +92,7 @@ Fast-forward
``` (stderr)
$ git push rad master
✓ Patch e4934b6d9dbe01ce3c7fbb5b77a80d5f1dacdc46 merged at revision 9d62420
✓ Canonical head updated to f567f695d25b4e8fb63b5f5ad2a584529826e908
✓ Canonical reference refs/heads/master updated to f567f695d25b4e8fb63b5f5ad2a584529826e908
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
f2de534..f567f69 master -> master

View File

@ -7,15 +7,18 @@ use localtime::LocalTime;
use radicle::cob::TypedId;
use radicle::crypto::PublicKey;
use radicle::identity::crefs::GetCanonicalRefs as _;
use radicle::identity::DocAt;
use radicle::prelude::NodeId;
use radicle::prelude::RepoId;
use radicle::storage::git::Repository;
use radicle::storage::refs::RefsAt;
use radicle::storage::{
ReadRepository, ReadStorage as _, RefUpdate, RemoteRepository, RepositoryError,
WriteRepository as _,
};
use radicle::{cob, git, node, Storage};
use radicle_fetch::git::refs::Applied;
use radicle_fetch::{Allowed, BlockList, FetchLimit};
use super::channels::ChannelsFlush;
@ -89,8 +92,6 @@ impl Handle {
remote: PublicKey,
refs_at: Option<Vec<RefsAt>>,
) -> Result<FetchResult, error::Fetch> {
use git::canonical::QuorumError::{Diverging, NoCandidates};
let (result, clone, notifs) = match self {
Self::Clone { mut handle, tmp } => {
log::debug!(target: "worker", "{} cloning from {remote}", handle.local());
@ -145,15 +146,19 @@ impl Handle {
log::trace!(target: "worker", "Set HEAD to {}", head.new);
}
}
Err(RepositoryError::Quorum(Diverging(e))) => {
log::warn!(target: "worker", "Fetch could not set HEAD: {e}")
Err(RepositoryError::Quorum(radicle::git::canonical::QuorumError::Git(e))) => {
return Err(e.into())
}
Err(RepositoryError::Quorum(NoCandidates(e))) => {
Err(RepositoryError::Quorum(e)) => {
log::warn!(target: "worker", "Fetch could not set HEAD: {e}")
}
Err(e) => return Err(e.into()),
}
if let Err(e) = set_canonical_refs(&repo, &applied) {
log::warn!(target: "worker", "Failed to set canonical references: {e}");
}
// Notifications are only posted for pulls, not clones.
if let Some(mut store) = notifs {
// Only create notifications for repos that we have
@ -377,3 +382,71 @@ where
Ok(())
}
fn set_canonical_refs(repo: &Repository, applied: &Applied) -> Result<(), error::Canonical> {
let identity = repo.identity()?;
let rules = match identity
.canonical_refs()?
.map(|crefs| crefs.rules().clone())
.filter(|rules| !rules.is_empty())
{
None => return Ok(()),
Some(rules) => rules,
};
for update in applied.updated.iter() {
let name = match update {
RefUpdate::Updated { name, .. } | RefUpdate::Created { name, .. } => name,
_ => {
log::trace!(target: "worker", "Skipping update {update}");
continue;
}
};
let Some(name) = name.clone().into_qualified() else {
log::warn!(target: "worker", "Skipping update for canonical reference '{name}' because it is not qualified.");
continue;
};
let Some(name) = name.to_namespaced() else {
log::warn!(target: "worker", "Skipping update for canonical reference '{name}' because it is not namespaced.");
continue;
};
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;
}
};
match canonical.quorum(&repo.backend) {
Err(err) => {
log::warn!(
target: "worker",
"Failed to calculate canonical reference: {}",
err,
);
continue;
}
Ok((refname, oid)) => {
if let Err(e) = repo.backend.reference(
refname.clone().as_str(),
*oid,
true,
"set-canonical-reference from fetch (radicle)",
) {
log::warn!(
target: "worker",
"Failed to set canonical reference {}->{}: {e}",
refname,
oid
);
}
}
}
}
Ok(())
}

View File

@ -65,3 +65,11 @@ pub enum Handle {
#[error(transparent)]
Repository(#[from] radicle::storage::RepositoryError),
}
#[derive(Debug, Error)]
pub enum Canonical {
#[error(transparent)]
Identity(#[from] radicle::storage::RepositoryError),
#[error(transparent)]
Payload(#[from] radicle::identity::PayloadError),
}

View File

@ -5,6 +5,8 @@ use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::{assert_eq, io};
use radicle::identity::crefs::GetCanonicalRefs as _;
use radicle::identity::doc::DefaultBranchRuleError;
use radicle::node::device::Device;
use thiserror::Error;
@ -15,8 +17,7 @@ use radicle::cob::patch::cache::Patches as _;
use radicle::crypto;
use radicle::explorer::ExplorerResource;
use radicle::git::canonical;
use radicle::git::canonical::Canonical;
use radicle::identity::Did;
use radicle::identity::{CanonicalRefs, Did};
use radicle::node;
use radicle::node::{Handle, NodeId};
use radicle::storage;
@ -116,6 +117,8 @@ pub enum Error {
/// Quorum error.
#[error(transparent)]
Quorum(#[from] radicle::git::canonical::QuorumError),
#[error(transparent)]
DefaultBranchRule(#[from] radicle::identity::doc::DefaultBranchRuleError),
}
/// Push command.
@ -203,6 +206,10 @@ pub fn run(
}
}
let delegates = stored.delegates()?;
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::Oid)> = Vec::with_capacity(specs.len());
// For each refspec, push a ref or delete a ref.
for spec in specs {
@ -262,8 +269,11 @@ pub fn run(
)
} else {
let identity = stored.identity()?;
let project = identity.project()?;
let canonical_ref = git::refs::branch(project.default_branch());
let crefs = identity.canonical_refs_or_default(|| {
let rule = identity.doc().default_branch_rule()?;
Ok::<_, DefaultBranchRuleError>(CanonicalRefs::from_iter([rule]))
})?;
let rules = crefs.rules();
let me = Did::from(nid);
// If we're trying to update the canonical head, make sure
@ -272,66 +282,51 @@ pub fn run(
//
// Note that we *do* allow rolling back to a previous commit on the
// canonical branch.
if dst == canonical_ref && delegates.contains(&me) && delegates.len() > 1 {
let head = working.find_reference(src.as_str())?;
let head = head.peel_to_commit()?.id();
if let Some(mut canonical) = rules.canonical(dst.clone(), stored)? {
if canonical.is_allowed(&me) {
let head = working.find_reference(src.as_str())?;
let head = head.peel_to_commit()?.id();
let converges =
canonical.converges(&working, (&me, &head.into()))?;
let mut canonical = Canonical::default_branch(
stored,
&project,
identity.delegates().as_ref(),
identity.threshold(),
)?;
let converges = canonical::converges(
canonical
.tips()
.filter_map(|(did, tip)| (*did != me).then_some(tip)),
head.into(),
&working,
)?;
if converges {
canonical.modify_vote(me, head.into());
}
// If `canonical` is empty then we're creating a new reference.
// If we're the only delegate then we need to modify our vote.
if converges || canonical.has_no_tips() || canonical.is_only(&me) {
canonical.modify_vote(me, head.into());
}
match canonical.quorum(&working) {
Ok(canonical_oid) => {
// Canonical head is an ancestor of head.
let is_ff = head == *canonical_oid
|| working.graph_descendant_of(head, *canonical_oid)?;
match canonical.quorum(&working) {
Ok((dst, canonical_oid)) => {
// Canonical head is an ancestor of head.
let is_ff = head == *canonical_oid
|| working.graph_descendant_of(head, *canonical_oid)?;
if !is_ff && !converges {
if hints {
hint(
"you are attempting to push a commit that would cause \
your upstream to diverge from the canonical head",
);
hint(
"to integrate the remote changes, run `git pull --rebase` \
and try again",
);
if !is_ff && !converges {
if hints {
hint(
format!("you are attempting to push a commit that would cause \
your upstream to diverge from the canonical reference {dst}"),
);
hint(
"to integrate the remote changes, run `git pull --rebase` \
and try again",
);
}
return Err(Error::HeadsDiverge(
head.into(),
canonical_oid,
));
}
return Err(Error::HeadsDiverge(
head.into(),
canonical_oid,
));
set_canonical_refs
.push((dst.clone().to_owned(), canonical_oid));
}
Err(canonical::QuorumError::Git(e)) => return Err(e.into()),
Err(e) => {
warn(e.to_string());
warn("it is recommended to find a commit to agree upon");
}
}
Err(canonical::QuorumError::Diverging(e)) => {
warn(format!(
"could not determine canonical tip for `{canonical_ref}`"
));
warn(e.to_string());
warn("it is recommended to find a commit to agree upon");
}
Err(canonical::QuorumError::NoCandidates(e)) => {
warn(format!(
"could not determine canonical tip for `{canonical_ref}`"
));
warn(e.to_string());
warn("it is recommended to find a commit to agree upon");
}
Err(e) => return Err(e.into()),
};
}
}
push(src, &dst, *force, &nid, &working, stored, patches, &signer)
}
@ -354,16 +349,50 @@ pub fn run(
if !ok.is_empty() {
let _ = stored.sign_refs(&signer)?;
// N.b. if an error occurs then there may be no quorum
if let Ok(head) = stored.set_head() {
if head.is_updated() {
for (refname, oid) in &set_canonical_refs {
let print_update = || {
eprintln!(
"{} Canonical head updated to {}",
"{} Canonical reference {} updated to {}",
term::format::positive(""),
term::format::secondary(head.new),
);
term::format::secondary(refname),
term::format::secondary(oid),
)
};
// N.b. special case for handling the canonical ref, since it
// creates a symlink to HEAD
if *refname == canonical_ref
&& stored
.set_head()
.map(|head| head.is_updated())
.unwrap_or(false)
{
print_update();
continue;
}
};
match stored.backend.refname_to_id(refname.as_str()) {
Ok(new) if new != **oid => {
stored.backend.reference(
refname.as_str(),
**oid,
true,
"set-canonical-reference from git-push (radicle)",
)?;
print_update();
}
Err(e) if e.code() == git::raw::ErrorCode::NotFound => {
stored.backend.reference(
refname.as_str(),
**oid,
true,
"set-canonical-reference from git-push (radicle)",
)?;
print_update();
}
_ => {}
}
}
if !opts.no_sync {
if profile.policies()?.is_seeding(&stored.id)? {

View File

@ -2,126 +2,64 @@ pub mod rules;
pub use rules::{MatchedRule, RawRule, Rules, ValidRule};
use std::collections::BTreeMap;
use std::fmt;
use nonempty::NonEmpty;
use raw::Repository;
use thiserror::Error;
use crate::prelude::Did;
use crate::prelude::Project;
use crate::storage::ReadRepository;
use super::raw;
use super::{lit, Oid, Qualified};
use super::{Oid, Qualified};
/// A collection of [`Did`]s and their [`Oid`]s that is the tip for a given
/// reference for that [`Did`].
///
/// The general construction of `Canonical` is by using the
/// [`Canonical::reference`] constructor. For the default branch of a
/// [`Project`], use [`Canonical::default_branch`].
/// The general construction of `Canonical` is by using the [`Canonical::new`]
/// constructor.
///
/// `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 {
pub struct Canonical<'a, 'b> {
refname: Qualified<'a>,
rule: &'b ValidRule,
tips: BTreeMap<Did, Oid>,
threshold: usize,
}
/// Error that can occur when calculation the [`Canonical::quorum`].
#[derive(Debug, Error)]
pub enum QuorumError {
/// Could not determine a quorum [`Oid`], due to diverging tips.
#[error("could not determine canonical reference tip, {0}")]
Diverging(Diverging),
#[error("could not determine commit for canonical reference '{refname}', found diverging commits {longest} and {head}, with base commit {base} and threshold {threshold}")]
Diverging {
refname: String,
threshold: usize,
base: Oid,
longest: Oid,
head: Oid,
},
/// Could not determine a base candidate from the given set of delegates.
#[error("could not determine canonical reference tip, {0}")]
NoCandidates(NoCandidates),
#[error("could not determine commit for canonical reference '{refname}', no commit 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),
}
/// No candidates were found for the [`Canonical::quorum`] calculation.
///
/// The [`fmt::Display`] is used in [`QuorumError`], to provide information on
/// the threshold and delegates in the calculation.
#[derive(Debug)]
pub struct NoCandidates {
threshold: usize,
}
impl fmt::Display for NoCandidates {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let NoCandidates { threshold } = self;
write!(
f,
"no commit found with at least {threshold} vote(s) (threshold not met)"
)
}
}
/// Diverging commits were found during the [`Canonical::quorum`] calculation.
///
/// The [`fmt::Display`] is used in [`QuorumError`], to provide information on
/// the threshold, base commit, and the two diverging commits, in the
/// calculation.
#[derive(Debug)]
pub struct Diverging {
threshold: usize,
base: Oid,
longest: Oid,
head: Oid,
}
impl fmt::Display for Diverging {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Diverging {
threshold,
base,
longest,
head,
} = self;
write!(f, "found diverging commits {longest} and {head}, with base commit {base} and threshold {threshold}")
}
}
impl Canonical {
/// Construct the set of canonical tips of the `Project::default_branch` for
/// the given `delegates`.
pub fn default_branch<S>(
repo: &S,
project: &Project,
delegates: &NonEmpty<Did>,
threshold: usize,
) -> Result<Self, raw::Error>
where
S: ReadRepository,
{
Self::reference(
repo,
&lit::refs_heads(project.default_branch()).into(),
delegates,
threshold,
)
}
/// Construct the set of canonical tips given for the given `delegates` and
/// the reference `name`.
pub fn reference<S>(
repo: &S,
refname: &Qualified,
delegates: &NonEmpty<Did>,
threshold: usize,
) -> Result<Self, raw::Error>
impl<'a, 'b> Canonical<'a, 'b> {
/// Construct the set of canonical tips given for the given `rule` and
/// the reference `refname`.
pub fn new<S>(repo: &S, refname: Qualified<'a>, rule: &'b ValidRule) -> Result<Self, raw::Error>
where
S: ReadRepository,
{
let mut tips = BTreeMap::new();
for delegate in delegates.iter() {
match repo.reference_oid(delegate, refname) {
for delegate in rule.allowed().iter() {
match repo.reference_oid(delegate, &refname) {
Ok(tip) => {
tips.insert(*delegate, tip);
}
@ -135,7 +73,11 @@ impl Canonical {
Err(e) => return Err(e),
}
}
Ok(Canonical { tips, threshold })
Ok(Canonical {
refname,
tips,
rule,
})
}
/// Return the set of [`Did`]s and their [`Oid`] tip.
@ -143,36 +85,18 @@ impl Canonical {
self.tips.iter()
}
/// Returns `true` is there were no tips found for any of the delegates for
/// Returns `true` if there were no tips found for any of the DIDs for
/// the given reference.
///
/// N.b. this may be the case when a new reference is being created.
pub fn is_empty(&self) -> bool {
pub fn has_no_tips(&self) -> bool {
self.tips.is_empty()
}
}
/// Check that a given `target` converges with any of the provided `tips`.
///
/// It converges if the `target` is either equal to, ahead of, or behind any of
/// the tips.
pub fn converges<'a>(
tips: impl Iterator<Item = &'a Oid>,
target: Oid,
repo: &Repository,
) -> Result<bool, raw::Error> {
for tip in tips {
match repo.graph_ahead_behind(*target, **tip)? {
(0, 0) => return Ok(true),
(ahead, behind) if ahead > 0 && behind == 0 => return Ok(true),
(ahead, behind) if behind > 0 && ahead == 0 => return Ok(true),
(_, _) => {}
}
pub fn refname(&self) -> &Qualified {
&self.refname
}
Ok(false)
}
impl Canonical {
/// 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.
@ -180,6 +104,41 @@ impl Canonical {
self.tips.insert(did, new);
}
/// 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: (&Did, &Oid),
) -> Result<bool, raw::Error> {
for tip in self
.tips
.iter()
.filter_map(|(did, tip)| (did != candidate.0).then_some(tip))
{
let (ahead, behind) = repo.graph_ahead_behind(**candidate.1, **tip)?;
if ahead * behind == 0 {
return Ok(true);
}
}
Ok(false)
}
/// 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
@ -187,7 +146,7 @@ impl Canonical {
///
/// 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<Oid, QuorumError> {
pub fn quorum(self, repo: &raw::Repository) -> Result<(Qualified<'a>, Oid), QuorumError> {
let mut candidates = BTreeMap::<_, usize>::new();
// Build a list of candidate commits and count how many "votes" each of them has.
@ -212,14 +171,12 @@ impl Canonical {
}
}
// Keep commits which pass the threshold.
candidates.retain(|_, votes| *votes >= self.threshold);
candidates.retain(|_, votes| *votes >= self.threshold());
let (mut longest, _) =
candidates
.pop_first()
.ok_or(QuorumError::NoCandidates(NoCandidates {
threshold: self.threshold,
}))?;
let (mut longest, _) = candidates.pop_first().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.
@ -253,15 +210,20 @@ impl Canonical {
// o (base)
// |
//
return Err(QuorumError::Diverging(Diverging {
threshold: self.threshold,
return Err(QuorumError::Diverging {
refname: self.refname.to_string(),
threshold: self.threshold(),
base: base.into(),
longest,
head: *head,
}));
});
}
}
Ok((*longest).into())
Ok((self.refname, (*longest).into()))
}
fn threshold(&self) -> usize {
(*self.rule.threshold()).into()
}
}
@ -281,7 +243,7 @@ mod tests {
threshold: usize,
repo: &git::raw::Repository,
) -> Result<Oid, QuorumError> {
let tips = heads
let tips: BTreeMap<Did, Oid> = heads
.iter()
.enumerate()
.map(|(i, head)| {
@ -290,7 +252,24 @@ mod tests {
(did, (*head).into())
})
.collect();
Canonical { tips, threshold }.quorum(repo)
let refname =
git::refs::branch(git_ext::ref_format::RefStr::try_from_str("master").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 rule = rule.validate(&mut || delegates.clone()).unwrap();
Canonical {
refname,
tips,
rule: &rule,
}
.quorum(repo)
.map(|(_, oid)| oid)
}
#[test]
@ -352,9 +331,6 @@ mod tests {
assert_eq!(quorum(&[*c0], 1, &repo).unwrap(), c0);
assert_eq!(quorum(&[*c1], 1, &repo).unwrap(), c1);
assert_eq!(quorum(&[*c2], 1, &repo).unwrap(), c2);
assert_eq!(quorum(&[*c0], 0, &repo).unwrap(), c0);
assert_matches!(quorum(&[], 0, &repo), Err(QuorumError::NoCandidates(_)));
assert_matches!(quorum(&[*c0], 2, &repo), Err(QuorumError::NoCandidates(_)));
// C1
// |
@ -380,23 +356,23 @@ mod tests {
// C0
assert_matches!(
quorum(&[*c1, *c2, *b2], 1, &repo),
Err(QuorumError::Diverging(_))
Err(QuorumError::Diverging { .. })
);
assert_matches!(
quorum(&[*c2, *b2], 1, &repo),
Err(QuorumError::Diverging(_))
Err(QuorumError::Diverging { .. })
);
assert_matches!(
quorum(&[*b2, *c2], 1, &repo),
Err(QuorumError::Diverging(_))
Err(QuorumError::Diverging { .. })
);
assert_matches!(
quorum(&[*c2, *b2], 2, &repo),
Err(QuorumError::NoCandidates(_))
Err(QuorumError::NoCandidates { .. })
);
assert_matches!(
quorum(&[*b2, *c2], 2, &repo),
Err(QuorumError::NoCandidates(_))
Err(QuorumError::NoCandidates { .. })
);
assert_eq!(quorum(&[*c1, *c2, *b2], 2, &repo).unwrap(), c1);
assert_eq!(quorum(&[*c1, *c2, *b2], 3, &repo).unwrap(), c1);
@ -404,7 +380,7 @@ mod tests {
assert_eq!(quorum(&[*b2, *c2, *c2], 2, &repo).unwrap(), c2);
assert_matches!(
quorum(&[*b2, *b2, *c2, *c2], 2, &repo),
Err(QuorumError::Diverging(_))
Err(QuorumError::Diverging { .. })
);
// B2 C2 C3
@ -415,15 +391,15 @@ mod tests {
assert_eq!(quorum(&[*b2, *c2, *c2], 2, &repo).unwrap(), c2);
assert_matches!(
quorum(&[*b2, *c2, *c2], 3, &repo),
Err(QuorumError::NoCandidates(_))
Err(QuorumError::NoCandidates { .. })
);
assert_matches!(
quorum(&[*b2, *c2, *b2, *c2], 3, &repo),
Err(QuorumError::NoCandidates(_))
Err(QuorumError::NoCandidates { .. })
);
assert_matches!(
quorum(&[*c3, *b2, *c2, *b2, *c2, *c3], 3, &repo),
Err(QuorumError::NoCandidates(_))
Err(QuorumError::NoCandidates { .. })
);
// B2 C2
@ -433,19 +409,19 @@ mod tests {
// C0
assert_matches!(
quorum(&[*c2, *b2, *a1], 1, &repo),
Err(QuorumError::Diverging(_))
Err(QuorumError::Diverging { .. })
);
assert_matches!(
quorum(&[*c2, *b2, *a1], 2, &repo),
Err(QuorumError::NoCandidates(_))
Err(QuorumError::NoCandidates { .. })
);
assert_matches!(
quorum(&[*c2, *b2, *a1], 3, &repo),
Err(QuorumError::NoCandidates(_))
Err(QuorumError::NoCandidates { .. })
);
assert_matches!(
quorum(&[*c1, *c2, *b2, *a1], 4, &repo),
Err(QuorumError::NoCandidates(_))
Err(QuorumError::NoCandidates { .. })
);
assert_eq!(quorum(&[*c0, *c1, *c2, *b2, *a1], 2, &repo).unwrap(), c1,);
assert_eq!(quorum(&[*c0, *c1, *c2, *b2, *a1], 3, &repo).unwrap(), c1,);
@ -453,23 +429,23 @@ mod tests {
assert_eq!(quorum(&[*c0, *c1, *c2, *b2, *a1], 4, &repo).unwrap(), c0,);
assert_matches!(
quorum(&[*a1, *a1, *c2, *c2, *c1], 2, &repo),
Err(QuorumError::Diverging(_))
Err(QuorumError::Diverging { .. })
);
assert_matches!(
quorum(&[*a1, *a1, *c2, *c2, *c1], 1, &repo),
Err(QuorumError::Diverging(_))
Err(QuorumError::Diverging { .. })
);
assert_matches!(
quorum(&[*a1, *a1, *c2], 1, &repo),
Err(QuorumError::Diverging(_))
Err(QuorumError::Diverging { .. })
);
assert_matches!(
quorum(&[*b2, *b2, *c2, *c2], 1, &repo),
Err(QuorumError::Diverging(_))
Err(QuorumError::Diverging { .. })
);
assert_matches!(
quorum(&[*b2, *b2, *c2, *c2, *a1], 1, &repo),
Err(QuorumError::Diverging(_))
Err(QuorumError::Diverging { .. })
);
// M2 M1
@ -482,27 +458,27 @@ mod tests {
assert_eq!(quorum(&[*m1], 1, &repo).unwrap(), m1);
assert_matches!(
quorum(&[*m1, *m2], 1, &repo),
Err(QuorumError::Diverging(_))
Err(QuorumError::Diverging { .. })
);
assert_matches!(
quorum(&[*m2, *m1], 1, &repo),
Err(QuorumError::Diverging(_))
Err(QuorumError::Diverging { .. })
);
assert_matches!(
quorum(&[*m1, *m2], 2, &repo),
Err(QuorumError::NoCandidates(_))
Err(QuorumError::NoCandidates { .. })
);
assert_matches!(
quorum(&[*m1, *m2, *c2], 1, &repo),
Err(QuorumError::Diverging(_))
Err(QuorumError::Diverging { .. })
);
assert_matches!(
quorum(&[*m1, *a1], 1, &repo),
Err(QuorumError::Diverging(_))
Err(QuorumError::Diverging { .. })
);
assert_matches!(
quorum(&[*m1, *a1], 2, &repo),
Err(QuorumError::NoCandidates(_))
Err(QuorumError::NoCandidates { .. })
);
assert_eq!(quorum(&[*m1, *m2, *b2, *c1], 4, &repo).unwrap(), c1);
assert_eq!(quorum(&[*m1, *m1, *b2], 2, &repo).unwrap(), m1);
@ -541,11 +517,11 @@ mod tests {
// C0
assert_matches!(
quorum(&[*m1, *m2], 1, &repo),
Err(QuorumError::Diverging(_))
Err(QuorumError::Diverging { .. })
);
assert_matches!(
quorum(&[*m1, *m2], 2, &repo),
Err(QuorumError::NoCandidates(_))
Err(QuorumError::NoCandidates { .. })
);
let m3 = fixtures::commit("M3", &[*c2, *c1], &repo);
@ -557,27 +533,27 @@ mod tests {
// C0
assert_matches!(
quorum(&[*m1, *m3], 1, &repo),
Err(QuorumError::Diverging(_))
Err(QuorumError::Diverging { .. })
);
assert_matches!(
quorum(&[*m1, *m3], 2, &repo),
Err(QuorumError::NoCandidates(_))
Err(QuorumError::NoCandidates { .. })
);
assert_matches!(
quorum(&[*m3, *m1], 1, &repo),
Err(QuorumError::Diverging(_))
Err(QuorumError::Diverging { .. })
);
assert_matches!(
quorum(&[*m3, *m1], 2, &repo),
Err(QuorumError::NoCandidates(_))
Err(QuorumError::NoCandidates { .. })
);
assert_matches!(
quorum(&[*m3, *m2], 1, &repo),
Err(QuorumError::Diverging(_))
Err(QuorumError::Diverging { .. })
);
assert_matches!(
quorum(&[*m3, *m2], 2, &repo),
Err(QuorumError::NoCandidates(_))
Err(QuorumError::NoCandidates { .. })
);
}
}

View File

@ -526,16 +526,6 @@ impl MatchedRule<'_> {
pub fn threshold(&self) -> &doc::Threshold {
self.rule().threshold()
}
/// Return the [`Canonical`] representation for the matched rule.
pub fn canonical(&self, repo: &Repository) -> Result<Canonical, git::raw::Error> {
Canonical::reference(
repo,
&self.refname,
self.rule.allow.as_ref(),
self.rule.threshold.into(),
)
}
}
/// A set of valid [`Rule`]s, where the set of DIDs and threshold are fully
@ -577,6 +567,12 @@ impl IntoIterator for Rules {
}
}
impl Extend<(Pattern, ValidRule)> for Rules {
fn extend<T: IntoIterator<Item = (Pattern, ValidRule)>>(&mut self, iter: T) {
self.rules.extend(iter)
}
}
impl From<Rules> for RawRules {
fn from(Rules { rules }: Rules) -> Self {
Self {
@ -615,18 +611,32 @@ impl Rules {
Ok(Self { rules: valid })
}
/// Return the first matching rule for the given `refname`, if there is any.
/// Return the matching rules for the given `refname`.
pub fn matches<'a>(
&self,
refname: &Qualified<'a>,
) -> impl Iterator<Item = (&Pattern, &ValidRule)> + use<'a, '_> {
let refname_cloned = refname.clone();
self.rules
.iter()
.filter(move |(pattern, _)| pattern.matches(&refname_cloned))
}
/// Match given refname, take the most specific rule, and prepare evaluation
/// as [`Canonical`]
///
/// N.b. it will find the first rule that is most specific for the given
/// `refname`.
pub fn matches<'a>(&self, refname: Qualified<'a>) -> Option<MatchedRule<'a>> {
self.rules
.iter()
.find(|(pattern, _)| pattern.matches(&refname))
.map(|(_, rule)| MatchedRule {
refname,
rule: rule.clone(),
})
pub fn canonical<'a, 'b>(
&'a self,
refname: Qualified<'b>,
repo: &Repository,
) -> Result<Option<Canonical<'b, 'a>>, git::raw::Error> {
if let Some((_, rule)) = self.matches(&refname).next() {
Ok(Some(Canonical::new(repo, refname, rule)?))
} else {
Ok(None)
}
}
}
@ -1179,19 +1189,21 @@ mod tests {
// candidates tag.
let stored = storage.repository(rid).unwrap();
let failing = git::Qualified::from(git::lit::refs_tags(failing_tag));
for (refname, oid) in tags.iter() {
let matched = rules.matches(refname.clone()).unwrap_or_else(|| {
panic!("there should be a matching rule for {refname}, rules: {rules:#?}")
});
let canonical = matched.canonical(&stored).unwrap();
if *refname == failing {
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());
} else {
assert_eq!(
canonical
.quorum(&repo)
.unwrap_or_else(|e| panic!("quorum error for {refname}: {e}")),
*oid,
(refname, oid),
)
}
}

View File

@ -1,8 +1,10 @@
#![warn(clippy::unwrap_used)]
pub mod crefs;
pub mod did;
pub mod doc;
pub mod project;
pub use crefs::CanonicalRefs;
pub use crypto::PublicKey;
pub use did::Did;
pub use doc::{Doc, DocAt, DocError, IdError, PayloadError, RawDoc, RepoId, Visibility};

View File

@ -0,0 +1,124 @@
use serde::{Deserialize, Serialize};
use crate::git::canonical::{
rules::{self, RawRules, Rules, ValidationError},
ValidRule,
};
use super::doc::{Delegates, Payload};
/// Implemented by any data type or store that can return [`CanonicalRefs`] and
/// [`RawCanonicalRefs`].
pub trait GetCanonicalRefs {
type Error: std::error::Error + Send + Sync + 'static;
/// Retrieve the [`CanonicalRefs`], returning `Some` if they are not
/// present, and `None` if they are missing.
///
/// [`Self::Error`] is used to return any domain-specific error by the
/// implementing type.
fn canonical_refs(&self) -> Result<Option<CanonicalRefs>, Self::Error>;
/// Retrieve the [`RawCanonicalRefs`], returning `Some` if they are not
/// present, and `None` if they are missing.
///
/// [`Self::Error`] is used to return any domain-specific error by the
/// implementing type.
fn raw_canonical_refs(&self) -> Result<Option<RawCanonicalRefs>, Self::Error>;
/// Retrieve the [`CanonicalRefs`], and in the case of `None`, then use the
/// `default` function to return a default set of [`CanonicalRefs`].
fn canonical_refs_or_default<D, E>(&self, default: D) -> Result<CanonicalRefs, E>
where
D: Fn() -> Result<CanonicalRefs, E>,
E: From<Self::Error>,
{
match self.canonical_refs()? {
Some(crefs) => Ok(crefs),
None => Ok(default()?),
}
}
}
/// Configuration for canonical references and their rules.
///
/// `RawCanonicalRefs` are verified into [`CanonicalRefs`].
#[derive(Default, Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RawCanonicalRefs {
rules: RawRules,
}
impl RawCanonicalRefs {
/// Construct a new [`RawCanonicalRefs`] from a set of [`RawRules`].
pub fn new(rules: RawRules) -> Self {
Self { rules }
}
/// Return the [`RawRules`].
pub fn raw_rules(&self) -> &RawRules {
&self.rules
}
/// Validate the [`RawCanonicalRefs`] into a set of [`CanonicalRefs`].
pub fn try_into_canonical_refs<R>(
self,
resolve: &mut R,
) -> Result<CanonicalRefs, ValidationError>
where
R: Fn() -> Delegates,
{
let rules = Rules::from_raw(self.rules, resolve)?;
Ok(CanonicalRefs { rules })
}
}
/// Configuration for canonical references and their [`Rules`].
///
/// [`CanonicalRefs`] can be converted into a [`Payload`] using its [`From`]
/// implementation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CanonicalRefs {
rules: Rules,
}
impl CanonicalRefs {
/// Construct a new [`CanonicalRefs`] from a set of [`Rules`].
pub fn new(rules: Rules) -> Self {
CanonicalRefs { rules }
}
/// Return the [`Rules`].
pub fn rules(&self) -> &Rules {
&self.rules
}
}
impl FromIterator<(rules::Pattern, ValidRule)> for CanonicalRefs {
fn from_iter<T: IntoIterator<Item = (rules::Pattern, ValidRule)>>(iter: T) -> Self {
Self::new(Rules::from_iter(iter))
}
}
impl Extend<(rules::Pattern, ValidRule)> for CanonicalRefs {
fn extend<T: IntoIterator<Item = (rules::Pattern, ValidRule)>>(&mut self, iter: T) {
self.rules.extend(iter)
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CanonicalRefsPayloadError {
#[error("could not convert canonical references to JSON: {0}")]
Json(#[source] serde_json::Error),
}
impl TryFrom<CanonicalRefs> for Payload {
type Error = CanonicalRefsPayloadError;
fn try_from(crefs: CanonicalRefs) -> Result<Self, Self::Error> {
let value = serde_json::to_value(crefs).map_err(CanonicalRefsPayloadError::Json)?;
Ok(Self::from(value))
}
}

View File

@ -19,6 +19,7 @@ use crate::cob::identity;
use crate::crypto;
use crate::crypto::Signature;
use crate::git;
use crate::git::canonical::rules;
use crate::identity::{project::Project, Did};
use crate::node::device::Device;
use crate::storage;
@ -27,6 +28,9 @@ use crate::storage::{ReadRepository, RepositoryError};
pub use crypto::PublicKey;
pub use id::*;
use super::crefs::{self, RawCanonicalRefs};
use super::CanonicalRefs;
/// Path to the identity document in the identity branch.
pub static PATH: LazyLock<&Path> = LazyLock::new(|| Path::new("radicle.json"));
/// Maximum length of a string in the identity document.
@ -73,6 +77,14 @@ impl DocError {
}
}
#[derive(Debug, Error)]
pub enum DefaultBranchRuleError {
#[error("could not create rule due to the reference name being invalid: {0}")]
Pattern(#[from] rules::PatternError),
#[error("could not load `xyz.radicle.project` to get default branch name: {0}")]
Payload(#[from] PayloadError),
}
/// The version number of the identity document.
///
/// It is used to ensure compatibility when parsing identity documents.
@ -215,10 +227,20 @@ impl PayloadId {
.expect("PayloadId::project: type name is valid"),
)
}
pub fn canonical_refs() -> Self {
Self(
// SAFETY: We know this is valid.
TypeName::from_str("xyz.radicle.crefs")
.expect("PayloadId::canonical_refs: type name is valid"),
)
}
}
#[derive(Debug, Error)]
pub enum PayloadError {
#[error(transparent)]
CanonicalRefs(#[from] rules::ValidationError),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("payload '{0}' not found in identity document")]
@ -536,6 +558,11 @@ impl Delegates {
self.0.contains(did)
}
/// Check if the `did` is the only delegate of the repository.
pub fn is_only(&self, did: &Did) -> bool {
self.0.tail.is_empty() && &self.0.head == did
}
/// Get the number of delegates in the set.
pub fn len(&self) -> usize {
self.0.len()
@ -715,6 +742,19 @@ impl Doc {
Ok(proj)
}
pub fn default_branch_rule(
&self,
) -> Result<(rules::Pattern, rules::ValidRule), DefaultBranchRuleError> {
let proj = self.project()?;
let refname = proj.default_branch();
let pattern = rules::Pattern::try_from(git::refs::branch(refname).to_owned())?;
let rule = rules::Rule::new(
rules::ResolvedDelegates::Delegates(self.delegates.clone()),
self.threshold,
);
Ok((pattern, rule))
}
/// Return the associated [`Visibility`] of this document.
pub fn visibility(&self) -> &Visibility {
&self.visibility
@ -872,6 +912,28 @@ impl Doc {
}
}
impl crefs::GetCanonicalRefs for Doc {
type Error = PayloadError;
fn canonical_refs(&self) -> Result<Option<CanonicalRefs>, Self::Error> {
self.raw_canonical_refs().and_then(|raw| {
raw.map(|raw| {
raw.try_into_canonical_refs(&mut || self.delegates.clone())
.map_err(PayloadError::from)
})
.transpose()
})
}
fn raw_canonical_refs(&self) -> Result<Option<RawCanonicalRefs>, Self::Error> {
let value = self.payload.get(&PayloadId::canonical_refs());
let crefs = value
.map(|value| serde_json::from_value((**value).clone()).map_err(PayloadError::from))
.transpose()?;
Ok(crefs)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {

View File

@ -18,7 +18,7 @@ use crate::cob;
use crate::collections::RandomMap;
use crate::git::{canonical, ext as git_ext};
use crate::git::{refspec::Refspec, PatternString, Qualified, RefError, RefStr, RefString};
use crate::identity::{Did, PayloadError};
use crate::identity::{doc, Did, PayloadError};
use crate::identity::{Doc, DocAt, DocError};
use crate::identity::{Identity, RepoId};
use crate::node::device::Device;
@ -120,6 +120,10 @@ pub enum RepositoryError {
Quorum(#[from] canonical::QuorumError),
#[error(transparent)]
Refs(#[from] refs::Error),
#[error("missing canonical reference rule for default branch")]
MissingBranchRule,
#[error("could not get the default branch rule: {0}")]
DefaultBranchRule(#[from] doc::DefaultBranchRuleError),
}
impl RepositoryError {

View File

@ -11,9 +11,9 @@ use std::{fs, io};
use crypto::Verified;
use tempfile::TempDir;
use crate::git::canonical::Canonical;
use crate::identity::crefs::GetCanonicalRefs as _;
use crate::identity::doc::DocError;
use crate::identity::{Doc, DocAt, RepoId};
use crate::identity::{CanonicalRefs, Doc, DocAt, RepoId};
use crate::identity::{Identity, Project};
use crate::node::device::Device;
use crate::node::SyncedAt;
@ -749,13 +749,18 @@ impl ReadRepository for Repository {
fn canonical_head(&self) -> Result<(Qualified, Oid), RepositoryError> {
let doc = self.identity_doc()?;
let project = doc.project()?;
let branch_ref = git::refs::branch(project.default_branch());
let raw = self.raw();
let oid =
Canonical::default_branch(self, &project, doc.delegates().into(), doc.threshold())?
.quorum(raw)?;
Ok((branch_ref, oid))
let refname = git::refs::branch(doc.project()?.default_branch());
let crefs = match doc.canonical_refs()? {
Some(crefs) => crefs,
// Fallback to constructing the default branch via the project
// payload
None => CanonicalRefs::from_iter([doc.default_branch_rule()?]),
};
Ok(crefs
.rules()
.canonical(refname, self)?
.ok_or(RepositoryError::MissingBranchRule)?
.quorum(self.raw())?)
}
fn identity_head(&self) -> Result<Oid, RepositoryError> {