From 7f646666bc9b830ab79c717bf541a6c80383599e Mon Sep 17 00:00:00 2001 From: Fintan Halpenny Date: Thu, 12 Jun 2025 14:06:49 +0200 Subject: [PATCH] radicle: canonical references payload --- .../examples/git/git-push-amend.md | 2 +- .../examples/git/git-push-converge.md | 7 +- .../examples/git/git-push-diverge.md | 6 +- .../examples/git/git-push-rollback.md | 4 +- .../radicle-cli/examples/rad-id-threshold.md | 2 +- .../examples/rad-merge-after-update.md | 2 +- .../radicle-cli/examples/rad-merge-no-ff.md | 2 +- .../examples/rad-merge-via-push.md | 4 +- .../examples/rad-patch-merge-draft.md | 2 +- .../examples/rad-patch-open-explore.md | 2 +- .../examples/rad-patch-revert-merge.md | 4 +- .../workflow/5-patching-maintainer.md | 2 +- crates/radicle-node/src/worker/fetch.rs | 83 ++++- crates/radicle-node/src/worker/fetch/error.rs | 8 + crates/radicle-remote-helper/src/push.rs | 159 ++++++---- crates/radicle/src/git/canonical.rs | 294 ++++++++---------- crates/radicle/src/git/canonical/rules.rs | 64 ++-- crates/radicle/src/identity.rs | 2 + crates/radicle/src/identity/crefs.rs | 124 ++++++++ crates/radicle/src/identity/doc.rs | 62 ++++ crates/radicle/src/storage.rs | 6 +- crates/radicle/src/storage/git.rs | 23 +- 22 files changed, 579 insertions(+), 285 deletions(-) create mode 100644 crates/radicle/src/identity/crefs.rs diff --git a/crates/radicle-cli/examples/git/git-push-amend.md b/crates/radicle-cli/examples/git/git-push-amend.md index b3cd985c..59d9dfbe 100644 --- a/crates/radicle-cli/examples/git/git-push-amend.md +++ b/crates/radicle-cli/examples/git/git-push-amend.md @@ -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) diff --git a/crates/radicle-cli/examples/git/git-push-converge.md b/crates/radicle-cli/examples/git/git-push-converge.md index 2ceca3ac..57c54bba 100644 --- a/crates/radicle-cli/examples/git/git-push-converge.md +++ b/crates/radicle-cli/examples/git/git-push-converge.md @@ -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 diff --git a/crates/radicle-cli/examples/git/git-push-diverge.md b/crates/radicle-cli/examples/git/git-push-diverge.md index 46d1fcb8..2364ec32 100644 --- a/crates/radicle-cli/examples/git/git-push-diverge.md +++ b/crates/radicle-cli/examples/git/git-push-diverge.md @@ -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) ``` diff --git a/crates/radicle-cli/examples/git/git-push-rollback.md b/crates/radicle-cli/examples/git/git-push-rollback.md index be55bc18..d54f3fac 100644 --- a/crates/radicle-cli/examples/git/git-push-rollback.md +++ b/crates/radicle-cli/examples/git/git-push-rollback.md @@ -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) diff --git a/crates/radicle-cli/examples/rad-id-threshold.md b/crates/radicle-cli/examples/rad-id-threshold.md index fe139deb..fe434a17 100644 --- a/crates/radicle-cli/examples/rad-id-threshold.md +++ b/crates/radicle-cli/examples/rad-id-threshold.md @@ -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 ``` diff --git a/crates/radicle-cli/examples/rad-merge-after-update.md b/crates/radicle-cli/examples/rad-merge-after-update.md index 0fe08a2a..929cf569 100644 --- a/crates/radicle-cli/examples/rad-merge-after-update.md +++ b/crates/radicle-cli/examples/rad-merge-after-update.md @@ -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 ``` diff --git a/crates/radicle-cli/examples/rad-merge-no-ff.md b/crates/radicle-cli/examples/rad-merge-no-ff.md index f6de66bc..58cca5ff 100644 --- a/crates/radicle-cli/examples/rad-merge-no-ff.md +++ b/crates/radicle-cli/examples/rad-merge-no-ff.md @@ -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 ``` diff --git a/crates/radicle-cli/examples/rad-merge-via-push.md b/crates/radicle-cli/examples/rad-merge-via-push.md index 27aa55cf..28ca61ea 100644 --- a/crates/radicle-cli/examples/rad-merge-via-push.md +++ b/crates/radicle-cli/examples/rad-merge-via-push.md @@ -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) ``` diff --git a/crates/radicle-cli/examples/rad-patch-merge-draft.md b/crates/radicle-cli/examples/rad-patch-merge-draft.md index f13f6e83..922bab23 100644 --- a/crates/radicle-cli/examples/rad-patch-merge-draft.md +++ b/crates/radicle-cli/examples/rad-patch-merge-draft.md @@ -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 ``` diff --git a/crates/radicle-cli/examples/rad-patch-open-explore.md b/crates/radicle-cli/examples/rad-patch-open-explore.md index adc9da82..f1c98013 100644 --- a/crates/radicle-cli/examples/rad-patch-open-explore.md +++ b/crates/radicle-cli/examples/rad-patch-open-explore.md @@ -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 diff --git a/crates/radicle-cli/examples/rad-patch-revert-merge.md b/crates/radicle-cli/examples/rad-patch-revert-merge.md index efb33561..a17ad836 100644 --- a/crates/radicle-cli/examples/rad-patch-revert-merge.md +++ b/crates/radicle-cli/examples/rad-patch-revert-merge.md @@ -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) ``` diff --git a/crates/radicle-cli/examples/workflow/5-patching-maintainer.md b/crates/radicle-cli/examples/workflow/5-patching-maintainer.md index 9951438d..ae7661e7 100644 --- a/crates/radicle-cli/examples/workflow/5-patching-maintainer.md +++ b/crates/radicle-cli/examples/workflow/5-patching-maintainer.md @@ -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 diff --git a/crates/radicle-node/src/worker/fetch.rs b/crates/radicle-node/src/worker/fetch.rs index fa2df3bc..f3ae1f44 100644 --- a/crates/radicle-node/src/worker/fetch.rs +++ b/crates/radicle-node/src/worker/fetch.rs @@ -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>, ) -> Result { - 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(()) +} diff --git a/crates/radicle-node/src/worker/fetch/error.rs b/crates/radicle-node/src/worker/fetch/error.rs index 406b1e23..a9111a6a 100644 --- a/crates/radicle-node/src/worker/fetch/error.rs +++ b/crates/radicle-node/src/worker/fetch/error.rs @@ -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), +} diff --git a/crates/radicle-remote-helper/src/push.rs b/crates/radicle-remote-helper/src/push.rs index eaeccb88..8d21062d 100644 --- a/crates/radicle-remote-helper/src/push.rs +++ b/crates/radicle-remote-helper/src/push.rs @@ -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)? { diff --git a/crates/radicle/src/git/canonical.rs b/crates/radicle/src/git/canonical.rs index 2f5e9282..eefcfd37 100644 --- a/crates/radicle/src/git/canonical.rs +++ b/crates/radicle/src/git/canonical.rs @@ -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, - 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( - repo: &S, - project: &Project, - delegates: &NonEmpty, - threshold: usize, - ) -> Result - 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( - repo: &S, - refname: &Qualified, - delegates: &NonEmpty, - threshold: usize, - ) -> Result +impl<'a, 'b> Canonical<'a, 'b> { + /// Construct the set of canonical tips given for the given `rule` and + /// the reference `refname`. + pub fn new(repo: &S, refname: Qualified<'a>, rule: &'b ValidRule) -> Result 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, - target: Oid, - repo: &Repository, -) -> Result { - 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 { + 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 { + 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 { - let tips = heads + let tips: BTreeMap = 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 { .. }) ); } } diff --git a/crates/radicle/src/git/canonical/rules.rs b/crates/radicle/src/git/canonical/rules.rs index 2b3c6161..96d70800 100644 --- a/crates/radicle/src/git/canonical/rules.rs +++ b/crates/radicle/src/git/canonical/rules.rs @@ -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::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>(&mut self, iter: T) { + self.rules.extend(iter) + } +} + impl From 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 + 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> { - 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>, 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), ) } } diff --git a/crates/radicle/src/identity.rs b/crates/radicle/src/identity.rs index c149c83d..7da9ed90 100644 --- a/crates/radicle/src/identity.rs +++ b/crates/radicle/src/identity.rs @@ -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}; diff --git a/crates/radicle/src/identity/crefs.rs b/crates/radicle/src/identity/crefs.rs new file mode 100644 index 00000000..112d5ccf --- /dev/null +++ b/crates/radicle/src/identity/crefs.rs @@ -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, 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, 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(&self, default: D) -> Result + where + D: Fn() -> Result, + E: From, + { + 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( + self, + resolve: &mut R, + ) -> Result + 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>(iter: T) -> Self { + Self::new(Rules::from_iter(iter)) + } +} + +impl Extend<(rules::Pattern, ValidRule)> for CanonicalRefs { + fn extend>(&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 for Payload { + type Error = CanonicalRefsPayloadError; + + fn try_from(crefs: CanonicalRefs) -> Result { + let value = serde_json::to_value(crefs).map_err(CanonicalRefsPayloadError::Json)?; + Ok(Self::from(value)) + } +} diff --git a/crates/radicle/src/identity/doc.rs b/crates/radicle/src/identity/doc.rs index f9da46d6..b3f4c1b3 100644 --- a/crates/radicle/src/identity/doc.rs +++ b/crates/radicle/src/identity/doc.rs @@ -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, 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, 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 { diff --git a/crates/radicle/src/storage.rs b/crates/radicle/src/storage.rs index b4113639..bff28f13 100644 --- a/crates/radicle/src/storage.rs +++ b/crates/radicle/src/storage.rs @@ -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 { diff --git a/crates/radicle/src/storage/git.rs b/crates/radicle/src/storage/git.rs index 1a49cd7a..410f3cb6 100644 --- a/crates/radicle/src/storage/git.rs +++ b/crates/radicle/src/storage/git.rs @@ -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 {