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) ``` ~alice (stderr)
$ git push rad master -f $ git push rad master -f
✓ Canonical head updated to 9170c8795d3a78f0381a0ffafb20ea69fb0f5b6b ✓ Canonical reference refs/heads/master updated to 9170c8795d3a78f0381a0ffafb20ea69fb0f5b6b
✓ Synced with 1 seed(s) ✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+ fb25886...9170c87 master -> master (forced update) + fb25886...9170c87 master -> master (forced update)

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@ $ git commit --amend --allow-empty -q -m "Amended change"
$ git checkout master -q $ git checkout master -q
$ git merge feature/1 -q $ git merge feature/1 -q
$ git push rad master $ git push rad master
✓ Canonical head updated to 954bcdb5008447ce294a61a21d7eb87afbe7f4a6 ✓ Canonical reference refs/heads/master updated to 954bcdb5008447ce294a61a21d7eb87afbe7f4a6
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
f2de534..954bcdb master -> master 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 ``` (stderr) RAD_SOCKET=/dev/null
$ git push rad master $ git push rad master
✓ Patch 696ec5508494692899337afe6713fe1796d0315c merged ✓ Patch 696ec5508494692899337afe6713fe1796d0315c merged
✓ Canonical head updated to 737a10cfa29111afeb0d43cf3545cee386b939ec ✓ Canonical reference refs/heads/master updated to 737a10cfa29111afeb0d43cf3545cee386b939ec
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
f2de534..737a10c master -> master 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 $ git push rad master
✓ Patch 356f73863a8920455ff6e77cd9c805d68910551b merged ✓ Patch 356f73863a8920455ff6e77cd9c805d68910551b merged
✓ Patch 696ec5508494692899337afe6713fe1796d0315c merged ✓ Patch 696ec5508494692899337afe6713fe1796d0315c merged
✓ Canonical head updated to d6399c71702b40bae00825b3c444478d06b4e91c ✓ Canonical reference refs/heads/master updated to d6399c71702b40bae00825b3c444478d06b4e91c
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
f2de534..d6399c7 master -> master f2de534..d6399c7 master -> master
``` ```
@ -148,7 +148,7 @@ the first patch, even though they were pushed together.
$ git reset --hard HEAD^ $ git reset --hard HEAD^
$ git push -f rad $ git push -f rad
! Patch 356f73863a8920455ff6e77cd9c805d68910551b reverted at revision 356f738 ! Patch 356f73863a8920455ff6e77cd9c805d68910551b reverted at revision 356f738
✓ Canonical head updated to 20aa5dde6210796c3a2f04079b42316a31d02689 ✓ Canonical reference refs/heads/master updated to 20aa5dde6210796c3a2f04079b42316a31d02689
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+ d6399c7...20aa5dd master -> master (forced update) + d6399c7...20aa5dd master -> master (forced update)
``` ```

View File

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

View File

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

View File

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

View File

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

View File

@ -7,15 +7,18 @@ use localtime::LocalTime;
use radicle::cob::TypedId; use radicle::cob::TypedId;
use radicle::crypto::PublicKey; use radicle::crypto::PublicKey;
use radicle::identity::crefs::GetCanonicalRefs as _;
use radicle::identity::DocAt; use radicle::identity::DocAt;
use radicle::prelude::NodeId; use radicle::prelude::NodeId;
use radicle::prelude::RepoId; use radicle::prelude::RepoId;
use radicle::storage::git::Repository;
use radicle::storage::refs::RefsAt; use radicle::storage::refs::RefsAt;
use radicle::storage::{ use radicle::storage::{
ReadRepository, ReadStorage as _, RefUpdate, RemoteRepository, RepositoryError, ReadRepository, ReadStorage as _, RefUpdate, RemoteRepository, RepositoryError,
WriteRepository as _, WriteRepository as _,
}; };
use radicle::{cob, git, node, Storage}; use radicle::{cob, git, node, Storage};
use radicle_fetch::git::refs::Applied;
use radicle_fetch::{Allowed, BlockList, FetchLimit}; use radicle_fetch::{Allowed, BlockList, FetchLimit};
use super::channels::ChannelsFlush; use super::channels::ChannelsFlush;
@ -89,8 +92,6 @@ impl Handle {
remote: PublicKey, remote: PublicKey,
refs_at: Option<Vec<RefsAt>>, refs_at: Option<Vec<RefsAt>>,
) -> Result<FetchResult, error::Fetch> { ) -> Result<FetchResult, error::Fetch> {
use git::canonical::QuorumError::{Diverging, NoCandidates};
let (result, clone, notifs) = match self { let (result, clone, notifs) = match self {
Self::Clone { mut handle, tmp } => { Self::Clone { mut handle, tmp } => {
log::debug!(target: "worker", "{} cloning from {remote}", handle.local()); log::debug!(target: "worker", "{} cloning from {remote}", handle.local());
@ -145,15 +146,19 @@ impl Handle {
log::trace!(target: "worker", "Set HEAD to {}", head.new); log::trace!(target: "worker", "Set HEAD to {}", head.new);
} }
} }
Err(RepositoryError::Quorum(Diverging(e))) => { Err(RepositoryError::Quorum(radicle::git::canonical::QuorumError::Git(e))) => {
log::warn!(target: "worker", "Fetch could not set HEAD: {e}") return Err(e.into())
} }
Err(RepositoryError::Quorum(NoCandidates(e))) => { Err(RepositoryError::Quorum(e)) => {
log::warn!(target: "worker", "Fetch could not set HEAD: {e}") log::warn!(target: "worker", "Fetch could not set HEAD: {e}")
} }
Err(e) => return Err(e.into()), 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. // Notifications are only posted for pulls, not clones.
if let Some(mut store) = notifs { if let Some(mut store) = notifs {
// Only create notifications for repos that we have // Only create notifications for repos that we have
@ -377,3 +382,71 @@ where
Ok(()) 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)] #[error(transparent)]
Repository(#[from] radicle::storage::RepositoryError), 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::str::FromStr;
use std::{assert_eq, io}; use std::{assert_eq, io};
use radicle::identity::crefs::GetCanonicalRefs as _;
use radicle::identity::doc::DefaultBranchRuleError;
use radicle::node::device::Device; use radicle::node::device::Device;
use thiserror::Error; use thiserror::Error;
@ -15,8 +17,7 @@ use radicle::cob::patch::cache::Patches as _;
use radicle::crypto; use radicle::crypto;
use radicle::explorer::ExplorerResource; use radicle::explorer::ExplorerResource;
use radicle::git::canonical; use radicle::git::canonical;
use radicle::git::canonical::Canonical; use radicle::identity::{CanonicalRefs, Did};
use radicle::identity::Did;
use radicle::node; use radicle::node;
use radicle::node::{Handle, NodeId}; use radicle::node::{Handle, NodeId};
use radicle::storage; use radicle::storage;
@ -116,6 +117,8 @@ pub enum Error {
/// Quorum error. /// Quorum error.
#[error(transparent)] #[error(transparent)]
Quorum(#[from] radicle::git::canonical::QuorumError), Quorum(#[from] radicle::git::canonical::QuorumError),
#[error(transparent)]
DefaultBranchRule(#[from] radicle::identity::doc::DefaultBranchRuleError),
} }
/// Push command. /// Push command.
@ -203,6 +206,10 @@ pub fn run(
} }
} }
let delegates = stored.delegates()?; 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 each refspec, push a ref or delete a ref.
for spec in specs { for spec in specs {
@ -262,8 +269,11 @@ pub fn run(
) )
} else { } else {
let identity = stored.identity()?; let identity = stored.identity()?;
let project = identity.project()?; let crefs = identity.canonical_refs_or_default(|| {
let canonical_ref = git::refs::branch(project.default_branch()); let rule = identity.doc().default_branch_rule()?;
Ok::<_, DefaultBranchRuleError>(CanonicalRefs::from_iter([rule]))
})?;
let rules = crefs.rules();
let me = Did::from(nid); let me = Did::from(nid);
// If we're trying to update the canonical head, make sure // 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 // Note that we *do* allow rolling back to a previous commit on the
// canonical branch. // canonical branch.
if dst == canonical_ref && delegates.contains(&me) && delegates.len() > 1 { if let Some(mut canonical) = rules.canonical(dst.clone(), stored)? {
let head = working.find_reference(src.as_str())?; if canonical.is_allowed(&me) {
let head = head.peel_to_commit()?.id(); 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( // If `canonical` is empty then we're creating a new reference.
stored, // If we're the only delegate then we need to modify our vote.
&project, if converges || canonical.has_no_tips() || canonical.is_only(&me) {
identity.delegates().as_ref(), canonical.modify_vote(me, head.into());
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());
}
match canonical.quorum(&working) { match canonical.quorum(&working) {
Ok(canonical_oid) => { Ok((dst, canonical_oid)) => {
// Canonical head is an ancestor of head. // Canonical head is an ancestor of head.
let is_ff = head == *canonical_oid let is_ff = head == *canonical_oid
|| working.graph_descendant_of(head, *canonical_oid)?; || working.graph_descendant_of(head, *canonical_oid)?;
if !is_ff && !converges { if !is_ff && !converges {
if hints { if hints {
hint( hint(
"you are attempting to push a commit that would cause \ format!("you are attempting to push a commit that would cause \
your upstream to diverge from the canonical head", your upstream to diverge from the canonical reference {dst}"),
); );
hint( hint(
"to integrate the remote changes, run `git pull --rebase` \ "to integrate the remote changes, run `git pull --rebase` \
and try again", and try again",
); );
}
return Err(Error::HeadsDiverge(
head.into(),
canonical_oid,
));
} }
return Err(Error::HeadsDiverge( set_canonical_refs
head.into(), .push((dst.clone().to_owned(), canonical_oid));
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) push(src, &dst, *force, &nid, &working, stored, patches, &signer)
} }
@ -354,16 +349,50 @@ pub fn run(
if !ok.is_empty() { if !ok.is_empty() {
let _ = stored.sign_refs(&signer)?; let _ = stored.sign_refs(&signer)?;
// N.b. if an error occurs then there may be no quorum for (refname, oid) in &set_canonical_refs {
if let Ok(head) = stored.set_head() { let print_update = || {
if head.is_updated() {
eprintln!( eprintln!(
"{} Canonical head updated to {}", "{} Canonical reference {} updated to {}",
term::format::positive(""), 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 !opts.no_sync {
if profile.policies()?.is_seeding(&stored.id)? { if profile.policies()?.is_seeding(&stored.id)? {

View File

@ -2,126 +2,64 @@ pub mod rules;
pub use rules::{MatchedRule, RawRule, Rules, ValidRule}; pub use rules::{MatchedRule, RawRule, Rules, ValidRule};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fmt;
use nonempty::NonEmpty;
use raw::Repository; use raw::Repository;
use thiserror::Error; use thiserror::Error;
use crate::prelude::Did; use crate::prelude::Did;
use crate::prelude::Project;
use crate::storage::ReadRepository; use crate::storage::ReadRepository;
use super::raw; 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 /// A collection of [`Did`]s and their [`Oid`]s that is the tip for a given
/// reference for that [`Did`]. /// reference for that [`Did`].
/// ///
/// The general construction of `Canonical` is by using the /// The general construction of `Canonical` is by using the [`Canonical::new`]
/// [`Canonical::reference`] constructor. For the default branch of a /// constructor.
/// [`Project`], use [`Canonical::default_branch`].
/// ///
/// `Canonical` can then be used for performing calculations about the /// `Canonical` can then be used for performing calculations about the
/// canonicity of the reference, most importantly the [`Canonical::quorum`]. /// 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)] #[derive(Debug)]
pub struct Canonical { pub struct Canonical<'a, 'b> {
refname: Qualified<'a>,
rule: &'b ValidRule,
tips: BTreeMap<Did, Oid>, tips: BTreeMap<Did, Oid>,
threshold: usize,
} }
/// Error that can occur when calculation the [`Canonical::quorum`]. /// Error that can occur when calculation the [`Canonical::quorum`].
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum QuorumError { pub enum QuorumError {
/// Could not determine a quorum [`Oid`], due to diverging tips. /// Could not determine a quorum [`Oid`], due to diverging tips.
#[error("could not determine canonical reference tip, {0}")] #[error("could not determine commit for canonical reference '{refname}', found diverging commits {longest} and {head}, with base commit {base} and threshold {threshold}")]
Diverging(Diverging), Diverging {
refname: String,
threshold: usize,
base: Oid,
longest: Oid,
head: Oid,
},
/// Could not determine a base candidate from the given set of delegates. /// Could not determine a base candidate from the given set of delegates.
#[error("could not determine canonical reference tip, {0}")] #[error("could not determine commit for canonical reference '{refname}', no commit with at least {threshold} vote(s) found (threshold not met)")]
NoCandidates(NoCandidates), NoCandidates { refname: String, threshold: usize },
/// An error occurred from [`git2`]. /// An error occurred from [`git2`].
#[error(transparent)] #[error(transparent)]
Git(#[from] git2::Error), Git(#[from] git2::Error),
} }
/// No candidates were found for the [`Canonical::quorum`] calculation. impl<'a, 'b> Canonical<'a, 'b> {
/// /// Construct the set of canonical tips given for the given `rule` and
/// The [`fmt::Display`] is used in [`QuorumError`], to provide information on /// the reference `refname`.
/// the threshold and delegates in the calculation. pub fn new<S>(repo: &S, refname: Qualified<'a>, rule: &'b ValidRule) -> Result<Self, raw::Error>
#[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>
where where
S: ReadRepository, S: ReadRepository,
{ {
let mut tips = BTreeMap::new(); let mut tips = BTreeMap::new();
for delegate in delegates.iter() { for delegate in rule.allowed().iter() {
match repo.reference_oid(delegate, refname) { match repo.reference_oid(delegate, &refname) {
Ok(tip) => { Ok(tip) => {
tips.insert(*delegate, tip); tips.insert(*delegate, tip);
} }
@ -135,7 +73,11 @@ impl Canonical {
Err(e) => return Err(e), Err(e) => return Err(e),
} }
} }
Ok(Canonical { tips, threshold }) Ok(Canonical {
refname,
tips,
rule,
})
} }
/// Return the set of [`Did`]s and their [`Oid`] tip. /// Return the set of [`Did`]s and their [`Oid`] tip.
@ -143,36 +85,18 @@ impl Canonical {
self.tips.iter() 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. /// the given reference.
/// ///
/// N.b. this may be the case when a new reference is being created. /// 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() self.tips.is_empty()
} }
}
/// Check that a given `target` converges with any of the provided `tips`. pub fn refname(&self) -> &Qualified {
/// &self.refname
/// 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),
(_, _) => {}
}
} }
Ok(false)
}
impl Canonical {
/// In some cases, we allow the vote to be modified. For example, when the /// 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 /// `did` is pushing a new commit, we may want to see if the new commit will
/// reach a quorum. /// reach a quorum.
@ -180,6 +104,41 @@ impl Canonical {
self.tips.insert(did, new); 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`, /// 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 /// 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 /// 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 /// Also returns an error if `heads` is empty or `threshold` cannot be
/// satisified with the number of heads given. /// 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(); let mut candidates = BTreeMap::<_, usize>::new();
// Build a list of candidate commits and count how many "votes" each of them has. // 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. // Keep commits which pass the threshold.
candidates.retain(|_, votes| *votes >= self.threshold); candidates.retain(|_, votes| *votes >= self.threshold());
let (mut longest, _) = let (mut longest, _) = candidates.pop_first().ok_or(QuorumError::NoCandidates {
candidates refname: self.refname.to_string(),
.pop_first() threshold: self.threshold(),
.ok_or(QuorumError::NoCandidates(NoCandidates { })?;
threshold: self.threshold,
}))?;
// Now that all scores are calculated, figure out what is the longest branch // Now that all scores are calculated, figure out what is the longest branch
// that passes the threshold. In case of divergence, return an error. // that passes the threshold. In case of divergence, return an error.
@ -253,15 +210,20 @@ impl Canonical {
// o (base) // o (base)
// | // |
// //
return Err(QuorumError::Diverging(Diverging { return Err(QuorumError::Diverging {
threshold: self.threshold, refname: self.refname.to_string(),
threshold: self.threshold(),
base: base.into(), base: base.into(),
longest, longest,
head: *head, 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, threshold: usize,
repo: &git::raw::Repository, repo: &git::raw::Repository,
) -> Result<Oid, QuorumError> { ) -> Result<Oid, QuorumError> {
let tips = heads let tips: BTreeMap<Did, Oid> = heads
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, head)| { .map(|(i, head)| {
@ -290,7 +252,24 @@ mod tests {
(did, (*head).into()) (did, (*head).into())
}) })
.collect(); .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] #[test]
@ -352,9 +331,6 @@ mod tests {
assert_eq!(quorum(&[*c0], 1, &repo).unwrap(), c0); assert_eq!(quorum(&[*c0], 1, &repo).unwrap(), c0);
assert_eq!(quorum(&[*c1], 1, &repo).unwrap(), c1); assert_eq!(quorum(&[*c1], 1, &repo).unwrap(), c1);
assert_eq!(quorum(&[*c2], 1, &repo).unwrap(), c2); 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 // C1
// | // |
@ -380,23 +356,23 @@ mod tests {
// C0 // C0
assert_matches!( assert_matches!(
quorum(&[*c1, *c2, *b2], 1, &repo), quorum(&[*c1, *c2, *b2], 1, &repo),
Err(QuorumError::Diverging(_)) Err(QuorumError::Diverging { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*c2, *b2], 1, &repo), quorum(&[*c2, *b2], 1, &repo),
Err(QuorumError::Diverging(_)) Err(QuorumError::Diverging { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*b2, *c2], 1, &repo), quorum(&[*b2, *c2], 1, &repo),
Err(QuorumError::Diverging(_)) Err(QuorumError::Diverging { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*c2, *b2], 2, &repo), quorum(&[*c2, *b2], 2, &repo),
Err(QuorumError::NoCandidates(_)) Err(QuorumError::NoCandidates { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*b2, *c2], 2, &repo), 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], 2, &repo).unwrap(), c1);
assert_eq!(quorum(&[*c1, *c2, *b2], 3, &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_eq!(quorum(&[*b2, *c2, *c2], 2, &repo).unwrap(), c2);
assert_matches!( assert_matches!(
quorum(&[*b2, *b2, *c2, *c2], 2, &repo), quorum(&[*b2, *b2, *c2, *c2], 2, &repo),
Err(QuorumError::Diverging(_)) Err(QuorumError::Diverging { .. })
); );
// B2 C2 C3 // B2 C2 C3
@ -415,15 +391,15 @@ mod tests {
assert_eq!(quorum(&[*b2, *c2, *c2], 2, &repo).unwrap(), c2); assert_eq!(quorum(&[*b2, *c2, *c2], 2, &repo).unwrap(), c2);
assert_matches!( assert_matches!(
quorum(&[*b2, *c2, *c2], 3, &repo), quorum(&[*b2, *c2, *c2], 3, &repo),
Err(QuorumError::NoCandidates(_)) Err(QuorumError::NoCandidates { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*b2, *c2, *b2, *c2], 3, &repo), quorum(&[*b2, *c2, *b2, *c2], 3, &repo),
Err(QuorumError::NoCandidates(_)) Err(QuorumError::NoCandidates { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*c3, *b2, *c2, *b2, *c2, *c3], 3, &repo), quorum(&[*c3, *b2, *c2, *b2, *c2, *c3], 3, &repo),
Err(QuorumError::NoCandidates(_)) Err(QuorumError::NoCandidates { .. })
); );
// B2 C2 // B2 C2
@ -433,19 +409,19 @@ mod tests {
// C0 // C0
assert_matches!( assert_matches!(
quorum(&[*c2, *b2, *a1], 1, &repo), quorum(&[*c2, *b2, *a1], 1, &repo),
Err(QuorumError::Diverging(_)) Err(QuorumError::Diverging { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*c2, *b2, *a1], 2, &repo), quorum(&[*c2, *b2, *a1], 2, &repo),
Err(QuorumError::NoCandidates(_)) Err(QuorumError::NoCandidates { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*c2, *b2, *a1], 3, &repo), quorum(&[*c2, *b2, *a1], 3, &repo),
Err(QuorumError::NoCandidates(_)) Err(QuorumError::NoCandidates { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*c1, *c2, *b2, *a1], 4, &repo), 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], 2, &repo).unwrap(), c1,);
assert_eq!(quorum(&[*c0, *c1, *c2, *b2, *a1], 3, &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_eq!(quorum(&[*c0, *c1, *c2, *b2, *a1], 4, &repo).unwrap(), c0,);
assert_matches!( assert_matches!(
quorum(&[*a1, *a1, *c2, *c2, *c1], 2, &repo), quorum(&[*a1, *a1, *c2, *c2, *c1], 2, &repo),
Err(QuorumError::Diverging(_)) Err(QuorumError::Diverging { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*a1, *a1, *c2, *c2, *c1], 1, &repo), quorum(&[*a1, *a1, *c2, *c2, *c1], 1, &repo),
Err(QuorumError::Diverging(_)) Err(QuorumError::Diverging { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*a1, *a1, *c2], 1, &repo), quorum(&[*a1, *a1, *c2], 1, &repo),
Err(QuorumError::Diverging(_)) Err(QuorumError::Diverging { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*b2, *b2, *c2, *c2], 1, &repo), quorum(&[*b2, *b2, *c2, *c2], 1, &repo),
Err(QuorumError::Diverging(_)) Err(QuorumError::Diverging { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*b2, *b2, *c2, *c2, *a1], 1, &repo), quorum(&[*b2, *b2, *c2, *c2, *a1], 1, &repo),
Err(QuorumError::Diverging(_)) Err(QuorumError::Diverging { .. })
); );
// M2 M1 // M2 M1
@ -482,27 +458,27 @@ mod tests {
assert_eq!(quorum(&[*m1], 1, &repo).unwrap(), m1); assert_eq!(quorum(&[*m1], 1, &repo).unwrap(), m1);
assert_matches!( assert_matches!(
quorum(&[*m1, *m2], 1, &repo), quorum(&[*m1, *m2], 1, &repo),
Err(QuorumError::Diverging(_)) Err(QuorumError::Diverging { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*m2, *m1], 1, &repo), quorum(&[*m2, *m1], 1, &repo),
Err(QuorumError::Diverging(_)) Err(QuorumError::Diverging { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*m1, *m2], 2, &repo), quorum(&[*m1, *m2], 2, &repo),
Err(QuorumError::NoCandidates(_)) Err(QuorumError::NoCandidates { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*m1, *m2, *c2], 1, &repo), quorum(&[*m1, *m2, *c2], 1, &repo),
Err(QuorumError::Diverging(_)) Err(QuorumError::Diverging { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*m1, *a1], 1, &repo), quorum(&[*m1, *a1], 1, &repo),
Err(QuorumError::Diverging(_)) Err(QuorumError::Diverging { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*m1, *a1], 2, &repo), 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, *m2, *b2, *c1], 4, &repo).unwrap(), c1);
assert_eq!(quorum(&[*m1, *m1, *b2], 2, &repo).unwrap(), m1); assert_eq!(quorum(&[*m1, *m1, *b2], 2, &repo).unwrap(), m1);
@ -541,11 +517,11 @@ mod tests {
// C0 // C0
assert_matches!( assert_matches!(
quorum(&[*m1, *m2], 1, &repo), quorum(&[*m1, *m2], 1, &repo),
Err(QuorumError::Diverging(_)) Err(QuorumError::Diverging { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*m1, *m2], 2, &repo), quorum(&[*m1, *m2], 2, &repo),
Err(QuorumError::NoCandidates(_)) Err(QuorumError::NoCandidates { .. })
); );
let m3 = fixtures::commit("M3", &[*c2, *c1], &repo); let m3 = fixtures::commit("M3", &[*c2, *c1], &repo);
@ -557,27 +533,27 @@ mod tests {
// C0 // C0
assert_matches!( assert_matches!(
quorum(&[*m1, *m3], 1, &repo), quorum(&[*m1, *m3], 1, &repo),
Err(QuorumError::Diverging(_)) Err(QuorumError::Diverging { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*m1, *m3], 2, &repo), quorum(&[*m1, *m3], 2, &repo),
Err(QuorumError::NoCandidates(_)) Err(QuorumError::NoCandidates { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*m3, *m1], 1, &repo), quorum(&[*m3, *m1], 1, &repo),
Err(QuorumError::Diverging(_)) Err(QuorumError::Diverging { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*m3, *m1], 2, &repo), quorum(&[*m3, *m1], 2, &repo),
Err(QuorumError::NoCandidates(_)) Err(QuorumError::NoCandidates { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*m3, *m2], 1, &repo), quorum(&[*m3, *m2], 1, &repo),
Err(QuorumError::Diverging(_)) Err(QuorumError::Diverging { .. })
); );
assert_matches!( assert_matches!(
quorum(&[*m3, *m2], 2, &repo), 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 { pub fn threshold(&self) -> &doc::Threshold {
self.rule().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 /// 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 { impl From<Rules> for RawRules {
fn from(Rules { rules }: Rules) -> Self { fn from(Rules { rules }: Rules) -> Self {
Self { Self {
@ -615,18 +611,32 @@ impl Rules {
Ok(Self { rules: valid }) 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 /// N.b. it will find the first rule that is most specific for the given
/// `refname`. /// `refname`.
pub fn matches<'a>(&self, refname: Qualified<'a>) -> Option<MatchedRule<'a>> { pub fn canonical<'a, 'b>(
self.rules &'a self,
.iter() refname: Qualified<'b>,
.find(|(pattern, _)| pattern.matches(&refname)) repo: &Repository,
.map(|(_, rule)| MatchedRule { ) -> Result<Option<Canonical<'b, 'a>>, git::raw::Error> {
refname, if let Some((_, rule)) = self.matches(&refname).next() {
rule: rule.clone(), Ok(Some(Canonical::new(repo, refname, rule)?))
}) } else {
Ok(None)
}
} }
} }
@ -1179,19 +1189,21 @@ mod tests {
// candidates tag. // candidates tag.
let stored = storage.repository(rid).unwrap(); let stored = storage.repository(rid).unwrap();
let failing = git::Qualified::from(git::lit::refs_tags(failing_tag)); let failing = git::Qualified::from(git::lit::refs_tags(failing_tag));
for (refname, oid) in tags.iter() { for (refname, oid) in tags.into_iter() {
let matched = rules.matches(refname.clone()).unwrap_or_else(|| { let canonical = rules
panic!("there should be a matching rule for {refname}, rules: {rules:#?}") .canonical(refname.clone(), &stored)
}); .unwrap()
let canonical = matched.canonical(&stored).unwrap(); .unwrap_or_else(|| {
if *refname == failing { panic!("there should be a matching rule for {refname}, rules: {rules:#?}")
});
if refname == failing {
assert!(canonical.quorum(&repo).is_err()); assert!(canonical.quorum(&repo).is_err());
} else { } else {
assert_eq!( assert_eq!(
canonical canonical
.quorum(&repo) .quorum(&repo)
.unwrap_or_else(|e| panic!("quorum error for {refname}: {e}")), .unwrap_or_else(|e| panic!("quorum error for {refname}: {e}")),
*oid, (refname, oid),
) )
} }
} }

View File

@ -1,8 +1,10 @@
#![warn(clippy::unwrap_used)] #![warn(clippy::unwrap_used)]
pub mod crefs;
pub mod did; pub mod did;
pub mod doc; pub mod doc;
pub mod project; pub mod project;
pub use crefs::CanonicalRefs;
pub use crypto::PublicKey; pub use crypto::PublicKey;
pub use did::Did; pub use did::Did;
pub use doc::{Doc, DocAt, DocError, IdError, PayloadError, RawDoc, RepoId, Visibility}; 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;
use crate::crypto::Signature; use crate::crypto::Signature;
use crate::git; use crate::git;
use crate::git::canonical::rules;
use crate::identity::{project::Project, Did}; use crate::identity::{project::Project, Did};
use crate::node::device::Device; use crate::node::device::Device;
use crate::storage; use crate::storage;
@ -27,6 +28,9 @@ use crate::storage::{ReadRepository, RepositoryError};
pub use crypto::PublicKey; pub use crypto::PublicKey;
pub use id::*; pub use id::*;
use super::crefs::{self, RawCanonicalRefs};
use super::CanonicalRefs;
/// Path to the identity document in the identity branch. /// Path to the identity document in the identity branch.
pub static PATH: LazyLock<&Path> = LazyLock::new(|| Path::new("radicle.json")); pub static PATH: LazyLock<&Path> = LazyLock::new(|| Path::new("radicle.json"));
/// Maximum length of a string in the identity document. /// 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. /// The version number of the identity document.
/// ///
/// It is used to ensure compatibility when parsing identity documents. /// It is used to ensure compatibility when parsing identity documents.
@ -215,10 +227,20 @@ impl PayloadId {
.expect("PayloadId::project: type name is valid"), .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)] #[derive(Debug, Error)]
pub enum PayloadError { pub enum PayloadError {
#[error(transparent)]
CanonicalRefs(#[from] rules::ValidationError),
#[error("json: {0}")] #[error("json: {0}")]
Json(#[from] serde_json::Error), Json(#[from] serde_json::Error),
#[error("payload '{0}' not found in identity document")] #[error("payload '{0}' not found in identity document")]
@ -536,6 +558,11 @@ impl Delegates {
self.0.contains(did) 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. /// Get the number of delegates in the set.
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.0.len() self.0.len()
@ -715,6 +742,19 @@ impl Doc {
Ok(proj) 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. /// Return the associated [`Visibility`] of this document.
pub fn visibility(&self) -> &Visibility { pub fn visibility(&self) -> &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)] #[cfg(test)]
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
mod test { mod test {

View File

@ -18,7 +18,7 @@ use crate::cob;
use crate::collections::RandomMap; use crate::collections::RandomMap;
use crate::git::{canonical, ext as git_ext}; use crate::git::{canonical, ext as git_ext};
use crate::git::{refspec::Refspec, PatternString, Qualified, RefError, RefStr, RefString}; 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::{Doc, DocAt, DocError};
use crate::identity::{Identity, RepoId}; use crate::identity::{Identity, RepoId};
use crate::node::device::Device; use crate::node::device::Device;
@ -120,6 +120,10 @@ pub enum RepositoryError {
Quorum(#[from] canonical::QuorumError), Quorum(#[from] canonical::QuorumError),
#[error(transparent)] #[error(transparent)]
Refs(#[from] refs::Error), 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 { impl RepositoryError {

View File

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