From 3f81e83d3624dda5ab162cc1ce1a107a7b9960bf Mon Sep 17 00:00:00 2001 From: Lorenz Leutgeb Date: Tue, 16 Sep 2025 22:02:09 +0200 Subject: [PATCH] radicle/crefs: Support Symbolic References Canonical references can not be used to model symbolic references. Relax this restriction by adding another member to the payload, named "symbolic". Its key-value/name-target pairs then translate directly to canonical symbolic references. Care is taken to not allow circular references, and that there always is a rule that could generate the target of a symbolic reference (or a chain of symbolic references). Still, a symbolic reference may dangle (for example when its target reference cannot be computed because of divergence), but at least it can be prevented that a symref *always* dangles, because there is no rule that would produce its target. Co-authored-by: Fintan Halpenny --- CHANGELOG.md | 2 + .../examples/git/git-push-canonical-branch.md | 90 +++ .../git/git-push-canonical-symbolic-ref.md | 130 +++ crates/radicle-cli/tests/commands/git.rs | 42 +- crates/radicle-node/src/worker/fetch.rs | 37 +- crates/radicle-remote-helper/src/push.rs | 22 +- crates/radicle/src/git/canonical.rs | 1 + crates/radicle/src/git/canonical/protect.rs | 55 +- crates/radicle/src/git/canonical/rules.rs | 16 + crates/radicle/src/git/canonical/symbolic.rs | 754 ++++++++++++++++++ crates/radicle/src/identity/crefs.rs | 216 ++++- crates/radicle/src/identity/doc.rs | 286 ++++++- crates/radicle/src/identity/doc/update.rs | 22 +- .../radicle/src/identity/doc/update/error.rs | 9 +- crates/radicle/src/identity/project.rs | 7 + crates/radicle/src/rad.rs | 2 +- crates/radicle/src/storage.rs | 31 +- crates/radicle/src/storage/git.rs | 65 +- crates/radicle/src/test.rs | 2 +- crates/radicle/src/test/storage.rs | 11 +- 20 files changed, 1670 insertions(+), 130 deletions(-) create mode 100644 crates/radicle-cli/examples/git/git-push-canonical-branch.md create mode 100644 crates/radicle-cli/examples/git/git-push-canonical-symbolic-ref.md create mode 100644 crates/radicle/src/git/canonical/symbolic.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fb9920d1..c3b0b56f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -326,6 +326,8 @@ With the introduction of `clap`, this helped with the introduction of a command environment variable `RAD_PASSPHRASE` (lower priority than the credential). The identifier of the credential is "xyz.radicle.node.passphrase". +- Symbolic references can now be handled by canonical references by coding them + in the payload `xyz.radicle.crefs` under the key `symbolic`. ## Fixed Bugs diff --git a/crates/radicle-cli/examples/git/git-push-canonical-branch.md b/crates/radicle-cli/examples/git/git-push-canonical-branch.md new file mode 100644 index 00000000..25c51f39 --- /dev/null +++ b/crates/radicle-cli/examples/git/git-push-canonical-branch.md @@ -0,0 +1,90 @@ +``` ~alice +$ rad id update --title "Add canonical branch for releases" --payload xyz.radicle.crefs rules '{ "refs/heads/releases/*": { "threshold": 1, "allow": [ "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk" ] }, "refs/tags/*": { "threshold": 1, "allow": "delegates" }, "refs/tags/qa/*": { "threshold": 1, "allow": "delegates" }}' +✓ Identity revision [..] created +╭────────────────────────────────────────────────────────────────────────╮ +│ Title Add canonical branch for releases │ +│ Revision 37a1aad231100cd206c49aed79e405ea2da9204b │ +│ Blob bbefd77cfeb456e500ad868c3b4effe1f7f818e2 │ +│ Author did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │ +│ State active │ +│ Quorum no │ +├────────────────────────────────────────────────────────────────────────┤ +│ ✓ did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi alice (you) │ +│ ? did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk bob │ +╰────────────────────────────────────────────────────────────────────────╯ + +@@ -1,26 +1,32 @@ + { + "payload": { + "xyz.radicle.crefs": { + "rules": { ++ "refs/heads/releases/*": { ++ "allow": [ ++ "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk" ++ ], ++ "threshold": 1 ++ }, + "refs/tags/*": { + "allow": "delegates", +- "threshold": 2 ++ "threshold": 1 + }, + "refs/tags/qa/*": { + "allow": "delegates", + "threshold": 1 + } + } + }, + "xyz.radicle.project": { + "defaultBranch": "master", + "description": "Radicle Heartwood Protocol & Stack", + "name": "heartwood" + } + }, + "delegates": [ + "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi", + "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk" + ], + "threshold": 1 + } +``` + +``` ~bob +$ cd heartwood +$ rad sync -f +Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s). +✓ Target met: 1 seed(s) +🌱 Fetched from z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi +$ rad id accept 37a1aad231100cd206c49aed79e405ea2da9204b -q +``` + +Bob immediately pushes a branch that matches the rule, thus populating and +modifying the canonical namespace. + +``` ~bob +$ git checkout -b releases/2 +$ git commit --allow-empty --message "Release notes for version 2" +[releases/2 afec366] Release notes for version 2 +``` + +``` ~bob (stderr) +$ git push -u rad releases/2 +✓ Canonical reference refs/heads/releases/2 updated to target commit afec366785ed3651cdc66975c0fec41866c9ce62 +✓ Synced with 1 seed(s) +To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk + * [new branch] releases/2 -> releases/2 +``` + +``` ~alice +$ rad sync -f +Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s). +✓ Target met: 1 seed(s) +🌱 Fetched from z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk +$ git ls-remote rad +f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 HEAD +f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/heads/master +afec366785ed3651cdc66975c0fec41866c9ce62 refs/heads/releases/2 +f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/tags/qa/v2.1 +ac51a0746a5e8311829bc481202909a1e3acc0c2 refs/tags/v1.0-hotfix +89f935f27a16f8ed97915ade4accab8fe48057aa refs/tags/v2.0 +``` diff --git a/crates/radicle-cli/examples/git/git-push-canonical-symbolic-ref.md b/crates/radicle-cli/examples/git/git-push-canonical-symbolic-ref.md new file mode 100644 index 00000000..e14fb0ff --- /dev/null +++ b/crates/radicle-cli/examples/git/git-push-canonical-symbolic-ref.md @@ -0,0 +1,130 @@ +Bob overhears that the new name for the default branch is "main", not "master" +as it used to be. To make tooling that expect "main" work, without the hassle +of having to push to such branch manually, he opts to create a symbolic +reference. + +``` ~bob +$ cd heartwood +$ rad id update --title "Add canonical symbolic ref" --payload xyz.radicle.crefs symbolic '{ "refs/heads/main": "refs/heads/master" }' +✓ Identity revision [..] created +╭────────────────────────────────────────────────────────────────────────╮ +│ Title Add canonical symbolic ref │ +│ Revision 62e2cb60c6df9ad9908b6697b5d126760a855484 │ +│ Blob b20de7b184673eb0d9227be17640c923d8ef3f3e │ +│ Author did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk │ +│ State active │ +│ Quorum no │ +├────────────────────────────────────────────────────────────────────────┤ +│ ✓ did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk bob (you) │ +│ ? did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi alice │ +╰────────────────────────────────────────────────────────────────────────╯ + +@@ -1,32 +1,35 @@ + { + "payload": { + "xyz.radicle.crefs": { + "rules": { + "refs/heads/releases/*": { + "allow": [ + "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk" + ], + "threshold": 1 + }, + "refs/tags/*": { + "allow": "delegates", + "threshold": 1 + }, + "refs/tags/qa/*": { + "allow": "delegates", + "threshold": 1 + } ++ }, ++ "symbolic": { ++ "refs/heads/main": "refs/heads/master" + } + }, + "xyz.radicle.project": { + "defaultBranch": "master", + "description": "Radicle Heartwood Protocol & Stack", + "name": "heartwood" + } + }, + "delegates": [ + "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi", + "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk" + ], + "threshold": 1 + } +``` + +Alice is happy with the new revision and accepts it. + +``` ~alice +$ rad sync -f +Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s). +✓ Target met: 1 seed(s) +🌱 Fetched from z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk +$ rad id accept 62e2cb60c6df9ad9908b6697b5d126760a855484 -q +``` + +As usual, alice works on "master". + +``` ~alice +$ git commit --allow-empty --message "Whew, new feature!" +[master 4dc510d] Whew, new feature! +``` + +And updating the canonical reference for "master" also works as usual. + +``` ~alice (stderr) +$ git push rad +✓ Canonical reference refs/heads/master updated to target commit 4dc510ddea5fd66499d1d2e996b8a97c8d57be54 +✓ Synced with 1 seed(s) +To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi + f2de534..4dc510d master -> master +``` + +Then, Alice is curious about the new symbolic reference. +She inspects the remote and sees that indeed a new branch named "main" now exists. + +``` ~alice +$ git ls-remote rad +4dc510ddea5fd66499d1d2e996b8a97c8d57be54 HEAD +4dc510ddea5fd66499d1d2e996b8a97c8d57be54 refs/heads/main +4dc510ddea5fd66499d1d2e996b8a97c8d57be54 refs/heads/master +afec366785ed3651cdc66975c0fec41866c9ce62 refs/heads/releases/2 +f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/tags/qa/v2.1 +ac51a0746a5e8311829bc481202909a1e3acc0c2 refs/tags/v1.0-hotfix +89f935f27a16f8ed97915ade4accab8fe48057aa refs/tags/v2.0 +``` + +Of course, she can also fetch it to her working copy as usual. + +``` ~alice (stderr) +$ git fetch rad +From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji + * [new branch] main -> rad/main + * [new branch] releases/2 -> rad/releases/2 + * [new tag] qa/v2.1 -> rad/tags/qa/v2.1 + * [new tag] qa/v2.1 -> qa/v2.1 +``` + +Bob fetches Alice's changes. + +``` ~bob +$ rad sync -f +Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s). +✓ Target met: 1 seed(s) +🌱 Fetched from z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi +``` + +And, sure enough, there is the new branch just as he wanted it. + +``` ~bob (stderr) +$ git fetch rad +From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji + * [new branch] main -> rad/main + f2de534..4dc510d master -> rad/master +``` + +Note that neither Alice nor Bob pushed directly to "main". diff --git a/crates/radicle-cli/tests/commands/git.rs b/crates/radicle-cli/tests/commands/git.rs index a8b3c58c..9ab6eb1e 100644 --- a/crates/radicle-cli/tests/commands/git.rs +++ b/crates/radicle-cli/tests/commands/git.rs @@ -301,8 +301,12 @@ fn git_push_canonical_lightweight_tags() { .unwrap(); } +/// This test exercises a large surface of the "Canonical References" feature: +/// - Annotated Tags +/// - Branches +/// - Symbolic References #[test] -fn git_push_canonical_annotated_tags() { +fn git_push_canonical() { let mut environment = Environment::new(); let alice = environment.node("alice"); let bob = environment.node("bob"); @@ -341,4 +345,40 @@ fn git_push_canonical_annotated_tags() { ) .run() .unwrap(); + + formula( + &environment.tempdir(), + "examples/git/git-push-canonical-branch.md", + ) + .unwrap() + .home( + "alice", + environment.work(&alice), + [("RAD_HOME", alice.home.path().display())], + ) + .home( + "bob", + environment.work(&bob), + [("RAD_HOME", bob.home.path().display())], + ) + .run() + .unwrap(); + + formula( + &environment.tempdir(), + "examples/git/git-push-canonical-symbolic-ref.md", + ) + .unwrap() + .home( + "alice", + environment.work(&alice), + [("RAD_HOME", alice.home.path().display())], + ) + .home( + "bob", + environment.work(&bob), + [("RAD_HOME", bob.home.path().display())], + ) + .run() + .unwrap(); } diff --git a/crates/radicle-node/src/worker/fetch.rs b/crates/radicle-node/src/worker/fetch.rs index 9e6ab905..e51649e1 100644 --- a/crates/radicle-node/src/worker/fetch.rs +++ b/crates/radicle-node/src/worker/fetch.rs @@ -14,8 +14,7 @@ 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 _, + ReadRepository, ReadStorage as _, RefUpdate, RemoteRepository, WriteRepository as _, }; use radicle::{Storage, cob, git, node}; use radicle_fetch::git::refs::Applied; @@ -128,15 +127,6 @@ impl Handle { // points to a repository that is temporary and gets moved by [`mv`]. let repo = storage.repository(rid)?; repo.set_identity_head()?; - match repo.set_head_to_default_branch() { - Ok(()) => { - log::trace!(target: "worker", "Set HEAD successfully"); - } - Err(RepositoryError::Quorum(e)) => { - log::warn!(target: "worker", "Fetch could not set HEAD for {rid}: {e}") - } - Err(e) => return Err(e.into()), - } let canonical = match set_canonical_refs(&repo, &applied) { Ok(updates) => updates.unwrap_or_default(), @@ -346,8 +336,21 @@ fn set_canonical_refs( repo: &Repository, applied: &Applied, ) -> Result, error::Canonical> { + const LOG_MESSAGE: &str = "set-canonical-reference from fetch (radicle)"; + let identity = repo.identity()?; - let rules = identity.doc().canonical_refs()?.rules().clone(); + let crefs = identity.doc().canonical_refs()?; + + for (name, target) in crefs.symbolic().iter() { + if let Err(e) = repo.set_symbolic_ref(name, target, LOG_MESSAGE) { + log::warn!( + target: "worker", + "Failed to set canonical symbolic reference '{name}' → '{target}': {e}" + ); + } + } + + let rules = crefs.rules().clone(); let mut updated_refs = UpdatedCanonicalRefs::default(); let refnames = applied @@ -389,12 +392,10 @@ fn set_canonical_refs( refname, object, .. }) => { let oid = object.id(); - if let Err(e) = repo.backend.reference( - refname.clone().as_str(), - oid.into(), - true, - "set-canonical-reference from fetch (radicle)", - ) { + if let Err(e) = + repo.backend + .reference(refname.clone().as_str(), oid.into(), true, LOG_MESSAGE) + { log::warn!( target: "worker", "Failed to set canonical reference {refname}->{oid}: {e}" diff --git a/crates/radicle-remote-helper/src/push.rs b/crates/radicle-remote-helper/src/push.rs index 52eb8263..1b264bd4 100644 --- a/crates/radicle-remote-helper/src/push.rs +++ b/crates/radicle-remote-helper/src/push.rs @@ -258,6 +258,8 @@ pub(super) fn run( git: &impl GitService, node: &mut impl NodeSession, ) -> Result, Error> { + const LOG_MESSAGE: &str = "set-canonical-reference from git-push (radicle)"; + // Don't allow push if either of these conditions is true: // // 1. Our key is not in ssh-agent, which means we won't be able to sign the refs. @@ -290,9 +292,6 @@ pub(super) 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::fmt::Qualified, git::canonical::Object)> = Vec::with_capacity(specs.len()); @@ -407,6 +406,8 @@ pub(super) fn run( if !ok.is_empty() { let _ = stored.sign_refs(&signer)?; + stored.set_canonical_symbolic_refs(LOG_MESSAGE)?; + for (refname, object) in &set_canonical_refs { let oid = object.id(); let kind = object.object_type(); @@ -419,20 +420,11 @@ pub(super) fn run( ) }; - // N.b. special case for handling the canonical ref, since it - // creates a symlink to HEAD - if *refname == canonical_ref { - stored.set_head_to_default_branch()?; - } - match stored.backend.refname_to_id(refname.as_str()) { Ok(new) if oid != new => { - stored.backend.reference( - refname.as_str(), - oid.into(), - true, - "set-canonical-reference from git-push (radicle)", - )?; + stored + .backend + .reference(refname.as_str(), oid.into(), true, LOG_MESSAGE)?; print_update(); } Err(e) if e.code() == git::raw::ErrorCode::NotFound => { diff --git a/crates/radicle/src/git/canonical.rs b/crates/radicle/src/git/canonical.rs index d94350ed..ce49a0e4 100644 --- a/crates/radicle/src/git/canonical.rs +++ b/crates/radicle/src/git/canonical.rs @@ -12,6 +12,7 @@ mod voting; pub mod effects; pub mod protect; pub mod rules; +pub mod symbolic; pub use rules::{MatchedRule, RawRule, Rules, ValidRule}; diff --git a/crates/radicle/src/git/canonical/protect.rs b/crates/radicle/src/git/canonical/protect.rs index 4326da5e..76ff12b5 100644 --- a/crates/radicle/src/git/canonical/protect.rs +++ b/crates/radicle/src/git/canonical/protect.rs @@ -19,7 +19,7 @@ pub enum Error { } /// A witnesses that the inner reference-like value is not protected. -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, Hash)] #[repr(transparent)] #[serde(transparent)] pub(super) struct Unprotected(T); @@ -42,6 +42,13 @@ impl Unprotected { pub fn into_inner(self) -> T { self.0 } + + /// Allows creation without any checking. Callers must ensure that + /// `reflike` is indeed unprotected! + #[inline] + const fn new_unchecked(reflike: T) -> Self { + Self(reflike) + } } impl AsRef for Unprotected { @@ -50,6 +57,20 @@ impl AsRef for Unprotected { } } +impl std::borrow::Borrow for Unprotected { + fn borrow(&self) -> &T { + &self.0 + } +} + +/// Enables looking up entries in a map keyed by `Unprotected` using +/// a `&RefStr`. +impl std::borrow::Borrow for Unprotected { + fn borrow(&self) -> &crate::git::fmt::RefStr { + self.0.as_ref() + } +} + impl<'de, T: RefLike + serde::Deserialize<'de>> serde::Deserialize<'de> for Unprotected { fn deserialize(deserializer: D) -> Result where @@ -68,7 +89,10 @@ impl std::fmt::Display for Unprotected { /// For types that are commonly used in conjunction with [`Unprotected`] /// have some `impl`s and companion infallible injections. mod impls { - use crate::git::fmt::{RefString, refspec::QualifiedPattern}; + use crate::git::{ + fmt::{Qualified, RefStr, RefString, refname, refspec::QualifiedPattern}, + refs::branch, + }; use super::*; @@ -76,6 +100,33 @@ mod impls { /// means to be [`RefLike`]. impl RefLike for RefString {} + impl Unprotected { + /// The reference name `HEAD`. + // We would like to have a `pub const HEAD`, but + // [`crate::git::RefStr::from_str`] is private. + #[inline] + pub fn head() -> Self { + // Calling [`Unprotected::new_unchecked`] here is legal, + // because we know statically that `HEAD` is not protected. + Unprotected::new_unchecked(refname!("HEAD")) + } + } + + /// [`Qualified`] is a restriction on [`RefString`]. + impl RefLike for Qualified<'_> {} + + impl Unprotected> { + /// Construct a qualified reference name for given branch, i.e., + /// return `/refs/heads/` + pub fn branch(name: &RefStr) -> Self { + Self::new(branch(name)).expect("branches are never protected") + } + + pub fn to_ref_string(&self) -> Unprotected { + Unprotected::new_unchecked(self.0.to_ref_string()) + } + } + /// A [`QualifiedPattern`] is [`RefLike`] in the sense that it matches a /// (possibly infinite) set of [`crate::git::Qualified`]. impl RefLike for QualifiedPattern<'_> {} diff --git a/crates/radicle/src/git/canonical/rules.rs b/crates/radicle/src/git/canonical/rules.rs index a6a99966..1df5e4e0 100644 --- a/crates/radicle/src/git/canonical/rules.rs +++ b/crates/radicle/src/git/canonical/rules.rs @@ -386,6 +386,22 @@ impl From for Allowed { } } +impl std::fmt::Display for Allowed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Allowed::Delegates => f.write_str("\"delegates\""), + Allowed::Set(dids) => { + let dids = dids + .iter() + .map(|did| did.to_string()) + .collect::>() + .join("\", \""); + f.write_fmt(format_args!("[\"{dids}\"]")) + } + } + } +} + /// A marker `enum` that is used in a [`ValidRule`]. /// /// It ensures that a rule that has been deserialized, resolving the `delegates` diff --git a/crates/radicle/src/git/canonical/symbolic.rs b/crates/radicle/src/git/canonical/symbolic.rs new file mode 100644 index 00000000..23cb355e --- /dev/null +++ b/crates/radicle/src/git/canonical/symbolic.rs @@ -0,0 +1,754 @@ +//! Symbolic references, which link neither to nor from protected references. +//! The prototypical example is `HEAD → refs/heads/main`. + +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::git::fmt::Qualified; +use crate::git::fmt::RefStr; +use crate::git::fmt::RefString; + +use super::protect::Unprotected; + +/// A type alias for a [`RefString`] that has yet to be validated into a +/// a symbolic reference name. +pub type RawName = RefString; + +/// A type alias for a [`RefString`] that has yet to be validated into a +/// symbolic reference target. +pub type RawTarget = RefString; + +/// The target of a symbolic reference. +/// +/// A target is either: +/// - [`Direct`](Target::Direct): a concrete qualified reference +/// (e.g. `refs/heads/main`). +/// - [`Symbolic`](Target::Symbolic): another symbolic reference name +/// (e.g. `MAIN`) that must itself resolve through the chain. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Target { + /// A concrete qualified reference — the end of a chain. + Direct(Direct), + /// Another symbolic reference name — an intermediate link. + Symbolic(Symbolic), +} + +impl AsRef for Target { + fn as_ref(&self) -> &RefStr { + match self { + Target::Direct(direct) => direct.0.as_ref(), + Target::Symbolic(symbolic) => symbolic.0.as_ref(), + } + } +} + +/// A concrete qualified reference — the end of a chain. +// `Unprotected` has `super` visibility, so `Direct` is used to allow `Target` +// to be `pub`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Direct(Unprotected>); + +impl Direct { + fn to_ref_string(&self) -> Unprotected { + self.0.to_ref_string() + } +} + +impl PartialEq for Direct { + fn eq(&self, other: &RefString) -> bool { + **self.0.as_ref() == **other + } +} + +impl AsRef> for Direct { + fn as_ref(&self) -> &Qualified<'static> { + self.0.as_ref() + } +} + +/// A concrete qualified reference — the end of a chain. +// `Unprotected` has `super` visibility, so `Symbolic` is used to allow `Target` +// to be `pub`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Symbolic(Unprotected); + +impl AsRef for Symbolic { + fn as_ref(&self) -> &RefString { + self.0.as_ref() + } +} + +impl Target { + /// Returns the underlying reference as a `&RefStr`. + pub fn as_refstr(&self) -> &RefStr { + match self { + Target::Direct(q) => q.as_ref().as_ref(), + Target::Symbolic(s) => s.as_ref().as_ref(), + } + } + + fn direct(d: Unprotected>) -> Self { + Self::Direct(Direct(d)) + } + + fn symbolic(s: Unprotected) -> Self { + Self::Symbolic(Symbolic(s)) + } + + /// Classify an [`Unprotected`] as either + /// [`Direct`](Target::Direct) or [`Symbolic`](Target::Symbolic) + /// based on whether it is [`Qualified`]. + /// + /// The [`Unprotected`] proof is preserved in the resulting variant. + fn classify(s: Unprotected) -> Self { + match Qualified::from_refstr(s.as_ref()) { + // Safety: the Qualified is derived from an Unprotected string, + // so it is also unprotected. + Some(q) => Target::direct( + Unprotected::new(q.to_owned()) + .expect("qualified derived from unprotected is unprotected"), + ), + None => Target::symbolic(s), + } + } +} + +impl Serialize for Target { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_refstr().as_str()) + } +} + +impl std::fmt::Display for Target { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_refstr().as_str()) + } +} + +/// Names of symbolic references are unprotected references. +/// Requiring [`Unprotected`] makes symbolic references that link *from* +/// protected references unrepresentable. +pub(super) type Name = Unprotected; + +impl std::cmp::Ord for Name { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.as_ref().cmp(other.as_ref()) + } +} + +impl std::cmp::PartialOrd for Name { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Maintains an acyclic set of symbolic references. +/// Note that dangling references are not detected. +/// +/// Internally, targets are stored as [`Target`], which distinguishes +/// direct (qualified) targets from symbolic (intermediate) ones. This +/// means resolution and cycle-checking can pattern-match on the variant +/// rather than re-parsing the string. +/// +/// # Deserialization Order +/// +/// Deserialization validates entries in iteration order via +/// [`TryFrom`]. This means deserialization only succeeds if +/// symbolic reference whose target is another symbolic +/// reference appears *after* that target in the JSON. +/// For example, +/// `{"MAIN": "refs/heads/master", "HEAD": "MAIN"}` is valid, but +/// `{"HEAD": "MAIN", "MAIN": "refs/heads/master"}` is not. +/// +/// To ensure that serialization and deserialization are inverses, +/// we must ensure that insertion order and iteration order are the same. +/// We do this by using [`IndexMap`] internally, which preserves this property, +/// and enabling the feature `preserve_order` of the `serde_json` crate. +/// +/// Normally, JSON objects are unordered (see [RFC 8259, Sec. 4]). +/// Any compatible (de-)serializer must preserve key order. +/// +/// [RFC 8259, Sec. 4]: +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(try_from = "IndexMap>")] +pub struct SymbolicRefs(IndexMap); + +/// Read-only access. +impl SymbolicRefs { + /// Returns an iterator over all contained symbolic references, as pairs + /// of their name and [`Target`]. + pub fn iter(&self) -> impl Iterator { + self.0.iter().map(|(name, target)| (name.as_ref(), target)) + } + + /// Returns an iterator over all contained symbolic references that + /// resolve to a direct (qualified) target. The yielded target is the + /// final [`Qualified`] reference after chasing through any intermediate + /// symbolic references. + pub fn iter_resolved(&self) -> impl Iterator)> { + self.0.keys().filter_map(|name| { + self.resolve(name.as_ref()) + .map(|target| (name.as_ref(), target)) + }) + } + + /// Resolve a name through the chain of symbolic references until a + /// [`Target::Direct`] target is reached. Returns `None` if the + /// name is not in the map or if the chain dangles (ends at a + /// [`Target::Symbolic`] whose name is not a key). + fn resolve(&self, name: &RefString) -> Option<&Qualified<'static>> { + let mut current = self.0.get(name)?; + loop { + match current { + Target::Direct(q) => return Some(q.as_ref()), + Target::Symbolic(s) => match self.0.get(s.as_ref()) { + Some(next) => current = next, + None => return None, + }, + } + } + } + + /// Returns `true` if the set of symbolic references is empty. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +/// Utilities for handling of `HEAD`. +impl SymbolicRefs { + /// Construct [`SymbolicRefs`] for the single symbolic reference `HEAD` + /// targeting `/refs/heads/`. + // This exists to encapsulate [`super::protect::Unprotected`]. + pub fn head(branch_name: &RefString) -> Self { + let mut result = Self::default(); + result + .try_insert_unprotected( + Unprotected::head().to_owned(), + Unprotected::branch(branch_name).to_ref_string(), + ) + .expect("not creating cycle"); + result + } + + /// Convenience method to get the resolved target of the `HEAD` reference. + /// Returns the final [`Qualified`] reference after chasing the chain. + /// See also [`SymbolicRefs::head`]. + pub fn resolve_head(&self) -> Option<&Qualified<'static>> { + self.resolve(Unprotected::head().as_ref()) + } +} + +#[derive(Debug, Error)] +pub enum InsertionError { + #[error("inserting symbolic reference '{name} → {target}' would create a cycle")] + Cyclic { name: RawName, target: RawName }, + + #[error( + "inserting symbolic reference '{name} → {target}' would result in a symbolic reference targeting an unqualified reference" + )] + TargetNotQualified { name: RawName, target: RawName }, +} + +/// Mutability. +impl SymbolicRefs { + /// Insert a symbolic reference from the given `name` to the given `target`. + /// + /// Internally, this classifies the `target` into the [`Target`] and + /// delegates the insertion. + pub(super) fn try_insert_unprotected( + &mut self, + name: Name, + target: Unprotected, + ) -> Result<(), InsertionError> { + self.insert(name, Target::classify(target)) + } + + /// Check whether `name` is reachable from `start` by chasing through + /// the map. Used to detect cycles before insertion. + /// + /// Unlike [`resolve`](SymbolicRefs::resolve), this chases through + /// *all* entries regardless of whether the target is [`Direct`](Target::Direct) + /// or [`Symbolic`](Target::Symbolic), since a qualified ref string can + /// also be a key in the map and thus part of a cycle. + fn is_reachable_from(&self, start: &RefString, name: &Name) -> bool { + let name = name.as_ref(); + if start == name { + return true; + } + let mut current: &RefStr = start; + loop { + match self.0.get(current) { + None => return false, + Some(target) => { + let next = target.as_refstr(); + if *next == **name { + return true; + } + current = next; + } + } + } + } + + /// Try to insert a symbolic reference. + /// Errors if `name` or `target` is protected (see [`super::protect`]) or would + /// cause infinite recursion (e.g. `A → B` and `B → A`). + /// + /// # Panics + /// + /// If `name` or `target` is not unprotected. + #[cfg(test)] + fn try_insert(&mut self, name: RawName, target: RawTarget) -> Result<(), InsertionError> { + self.try_insert_unprotected( + Unprotected::new(name).expect("name is unprotected"), + Unprotected::new(target).expect("target is unprotected"), + ) + } + + /// Consume `other` by iteratively inserting into self. + pub fn combine(&mut self, other: SymbolicRefs) -> Result<(), InsertionError> { + for (name, target) in other.0 { + self.insert(name, target)?; + } + Ok(()) + } + + /// Insert a symbolic reference from the given `name` to the given `target`. + /// + /// The targets in the map can change their classification from + /// [`Target::Direct`] to [`Target::Symbolic`], if the new insertion of the + /// `name` or `target` matches an existing key or existing entries. + /// + /// # Errors + /// + /// The `target` reference must either: + /// - be a direct [`Qualified`] reference, or + /// - resolve to a direct [`Qualified`] reference, if it is a keyed entry in [`SymbolicRefs`]. + /// + /// If neither of these is satisfied then an + /// [`InsertionError::TargetNotQualified`] error is returned. + /// + /// If the `name` and `target` end up in a cycle, e.g., `a → b → a`, then an + /// [`InsertionError::Cyclic`] error is returned. + fn insert(&mut self, name: Name, mut target: Target) -> Result<(), InsertionError> { + let target_str = match &target { + Target::Direct(q) => q.as_ref().to_ref_string(), + Target::Symbolic(s) => s.as_ref().clone(), + }; + + if self.is_reachable_from(&target_str, &name) { + return Err(InsertionError::Cyclic { + name: name.into_inner(), + target: target_str, + }); + } + + // A [`Target::Direct`] whose string is already a key is actually + // an intermediate link — downgrade to [`Target::Symbolic`]. + if let Target::Direct(q) = &target { + if self.0.contains_key(&q.as_ref().to_ref_string()) { + target = Target::symbolic(q.to_ref_string()); + } + } + + if let Target::Symbolic(s) = &target { + if self.resolve(s.as_ref()).is_none() { + return Err(InsertionError::TargetNotQualified { + name: name.into_inner(), + target: target_str, + }); + } + } + + self.reclassify_targets(&name); + self.0.insert(name, target); + Ok(()) + } + + /// When a new key is inserted, any existing [`Target::Direct`] whose + /// qualified string matches the new key is no longer terminal — it is + /// now an intermediate link and must be reclassified as [`Target::Symbolic`]. + fn reclassify_targets(&mut self, new_key: &Name) { + let key = new_key.as_ref(); + for existing in self.0.values_mut() { + if let Target::Direct(q) = existing { + if q == key { + *existing = Target::symbolic(q.to_ref_string()); + } + } + } + } +} + +impl TryFrom>> for SymbolicRefs { + type Error = InsertionError; + + fn try_from(map: IndexMap>) -> Result { + map.into_iter() + .try_fold(Self::default(), |mut result, (name, target)| { + result.try_insert_unprotected(name, target)?; + Ok(result) + }) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod test { + use crate::assert_matches; + use crate::git::fmt::refname; + + use super::*; + + #[test] + fn infinite_single() { + let mut symbolic = SymbolicRefs::default(); + + assert_matches!( + symbolic.try_insert(refname!("a"), refname!("a")), + Err(InsertionError::Cyclic { .. }) + ); + + assert!(symbolic.is_empty()); + } + + #[test] + fn infinite_multi() { + let mut symbolic = SymbolicRefs::default(); + + assert_matches!( + symbolic.try_insert(refname!("a"), refname!("refs/heads/b")), + Ok(()) + ); + + assert_matches!( + symbolic.try_insert(refname!("refs/heads/b"), refname!("refs/heads/c")), + Ok(()) + ); + + assert_matches!( + symbolic.try_insert(refname!("refs/heads/c"), refname!("a")), + Err(InsertionError::Cyclic { .. }) + ); + } + + #[test] + fn deserialize_valid() { + assert_matches!( + serde_json::from_value::(serde_json::json!({ + "refs/heads/a": "refs/heads/b", + })), + Ok(_) + ); + } + + #[test] + fn deserialize_order() { + assert_matches!( + serde_json::from_value::(serde_json::json!({ + "MAIN": "refs/heads/master", + "HEAD": "MAIN", + })), + Ok(_) + ); + + assert_matches!( + serde_json::from_value::(serde_json::json!({ + "HEAD": "MAIN", + "MAIN": "refs/heads/master", + })), + Err(_) + ); + } + + #[test] + fn deserialize_infinite() { + assert_matches!( + serde_json::from_value::(serde_json::json!({ + "refs/heads/a": "refs/heads/a", + })), + Err(_) + ); + + assert_matches!( + serde_json::from_value::(serde_json::json!({ + "refs/heads/a": "refs/heads/b", + "refs/heads/b": "refs/heads/c", + "refs/heads/c": "refs/heads/a", + })), + Err(_) + ); + + assert_matches!( + serde_json::from_value::(serde_json::json!({ + "HEAD": "b", + })), + Err(_) + ); + } + + /// Verifies that resolution works correctly for chains with 2 links + /// (even-length), e.g. `HEAD → MAIN → refs/heads/master`. + #[test] + fn resolve_two_hop_chain() { + let symrefs = serde_json::from_value::(serde_json::json!({ + "MAIN": "refs/heads/master", + "HEAD": "MAIN", + })) + .unwrap(); + + // HEAD → MAIN → refs/heads/master should resolve to refs/heads/master + assert_eq!( + symrefs.resolve_head().map(|r| r.as_str()), + Some("refs/heads/master"), + ); + } + + /// Motivates why we cannot simply delegate to [`BTreeMap::extend`] + /// for combining [`SymbolicRefs`]. + #[test] + fn infinite_extend() { + let mut a = SymbolicRefs::default(); + assert_matches!( + a.try_insert(refname!("refs/heads/a"), refname!("refs/heads/b")), + Ok(()) + ); + + let mut b = SymbolicRefs::default(); + assert_matches!( + b.try_insert(refname!("refs/heads/b"), refname!("refs/heads/a")), + Ok(()) + ); + + assert_matches!(a.combine(b), Err(InsertionError::Cyclic { .. })); + } + + /// Verifies that direct targets are stored as [`Target::Direct`]. + #[test] + fn target_classification() { + let symrefs = serde_json::from_value::(serde_json::json!({ + "HEAD": "refs/heads/main", + })) + .unwrap(); + + let (_, target) = symrefs.iter().next().unwrap(); + assert_matches!(target, Target::Direct(_)); + } + + /// Verifies that symbolic targets are stored as [`Target::Symbolic`]. + #[test] + fn target_classification_symbolic() { + let symrefs = serde_json::from_value::(serde_json::json!({ + "MAIN": "refs/heads/master", + "HEAD": "MAIN", + })) + .unwrap(); + + let head_entry = symrefs + .iter() + .find_map(|(name, target)| (name.as_str() == "HEAD").then_some(target)) + .unwrap(); + assert_matches!(head_entry, Target::Symbolic(_)); + + let main_entry = symrefs + .iter() + .find_map(|(name, target)| (name.as_str() == "MAIN").then_some(target)) + .unwrap(); + assert_matches!(main_entry, Target::Direct(_)); + } + + /// Verifies that an existing direct target can become a symbolic target + /// during a new insertion. + #[test] + fn target_reclassification() { + let mut symrefs = SymbolicRefs::default(); + symrefs + .try_insert(refname!("HEAD"), refname!("refs/heads/main")) + .unwrap(); + symrefs + .try_insert(refname!("refs/heads/main"), refname!("refs/heads/master")) + .unwrap(); + let main = symrefs + .iter() + .find_map(|(_, target)| { + (target.as_refstr().as_str() == "refs/heads/main").then_some(target) + }) + .unwrap(); + assert_matches!(main, Target::Symbolic(_)); + } + + /// Verifies that an existing direct target can become a symbolic target + /// during a new insertion. + #[test] + fn target_reclassification_commutative() { + let mut symrefs = SymbolicRefs::default(); + symrefs + .try_insert(refname!("refs/heads/main"), refname!("refs/heads/master")) + .unwrap(); + symrefs + .try_insert(refname!("HEAD"), refname!("refs/heads/main")) + .unwrap(); + let main = symrefs + .iter() + .find_map(|(_, target)| { + (target.as_refstr().as_str() == "refs/heads/main").then_some(target) + }) + .unwrap(); + assert_matches!(main, Target::Symbolic(_)); + } + + #[test] + fn reclassification_reverse_chain() { + let mut symrefs = SymbolicRefs::default(); + + // Build the chain in reverse: terminal first, origin last. + for (name, target) in [ + (refname!("refs/heads/c"), refname!("refs/heads/d")), + (refname!("refs/heads/b"), refname!("refs/heads/c")), + (refname!("refs/heads/a"), refname!("refs/heads/b")), + ] { + symrefs.try_insert(name, target).unwrap(); + } + + // Only refs/heads/d (the terminal) should be Direct. + // refs/heads/b and refs/heads/c are both keys AND targets — Symbolic. + for (_, target) in symrefs.iter() { + match target.as_refstr().as_str() { + "refs/heads/d" => assert_matches!(target, Target::Direct(_)), + other => { + assert_matches!(target, Target::Symbolic(_), "expected Symbolic for {other}") + } + } + } + + // Resolution should still work through the full chain. + assert_eq!( + symrefs + .resolve(&refname!("refs/heads/a")) + .map(|q| q.as_str()), + Some("refs/heads/d"), + ); + } + + #[test] + fn reclassification_diamond() { + let mut symrefs = SymbolicRefs::default(); + symrefs + .try_insert(refname!("HEAD"), refname!("refs/heads/main")) + .unwrap(); + symrefs + .try_insert(refname!("DEFAULT"), refname!("refs/heads/main")) + .unwrap(); + + // Both targets are Direct — refs/heads/main is not a key yet. + // Now make it a key: + symrefs + .try_insert(refname!("refs/heads/main"), refname!("refs/heads/master")) + .unwrap(); + + // Both HEAD and DEFAULT's targets should now be Symbolic. + let targets_for_main: Vec<_> = symrefs + .iter() + .filter(|(_, t)| t.as_refstr().as_str() == "refs/heads/main") + .collect(); + assert_eq!(targets_for_main.len(), 2); + for (name, target) in targets_for_main { + assert_matches!( + target, + Target::Symbolic(_), + "expected Symbolic for {name}'s target" + ); + } + } + + #[test] + fn reclassification_order_invariant() { + // Order A: HEAD first, then the chain link. + let a = { + let mut s = SymbolicRefs::default(); + s.try_insert(refname!("HEAD"), refname!("refs/heads/main")) + .unwrap(); + s.try_insert(refname!("refs/heads/main"), refname!("refs/heads/master")) + .unwrap(); + s + }; + + // Order B: chain link first, then HEAD. + let b = { + let mut s = SymbolicRefs::default(); + s.try_insert(refname!("refs/heads/main"), refname!("refs/heads/master")) + .unwrap(); + s.try_insert(refname!("HEAD"), refname!("refs/heads/main")) + .unwrap(); + s + }; + + // Both should resolve HEAD to the same place. + assert_eq!(a.resolve_head(), b.resolve_head()); + + // Both should have the same classification for the refs/heads/main target. + let classify_a = a + .iter() + .find(|(_, t)| t.as_refstr().as_str() == "refs/heads/main") + .unwrap() + .1; + let classify_b = b + .iter() + .find(|(_, t)| t.as_refstr().as_str() == "refs/heads/main") + .unwrap() + .1; + assert_matches!(classify_a, Target::Symbolic(_)); + assert_matches!(classify_b, Target::Symbolic(_)); + } + + #[test] + fn reclassification_combine() { + // A has HEAD → refs/heads/main (Direct) + let mut a = SymbolicRefs::head(&refname!("main")); + + // B has refs/heads/main → refs/heads/master (Direct) + let mut b = SymbolicRefs::default(); + b.try_insert(refname!("refs/heads/main"), refname!("refs/heads/master")) + .unwrap(); + + a.combine(b).unwrap(); + + // After combine, HEAD's target refs/heads/main should be Symbolic. + let main_target = a + .iter() + .find(|(_, t)| t.as_refstr().as_str() == "refs/heads/main") + .unwrap() + .1; + assert_matches!(main_target, Target::Symbolic(_)); + assert_eq!( + a.resolve_head().map(|q| q.as_str()), + Some("refs/heads/master") + ); + } + + #[test] + fn reclassification_combine_reverse() { + // B has refs/heads/main → refs/heads/master (Direct) + let mut b = SymbolicRefs::default(); + b.try_insert(refname!("refs/heads/main"), refname!("refs/heads/master")) + .unwrap(); + + // A has HEAD → refs/heads/main (Direct) + let a = SymbolicRefs::head(&refname!("main")); + + b.combine(a).unwrap(); + + // HEAD's target refs/heads/main IS a key — should be Symbolic. + let main_target = b + .iter() + .find_map(|(_, t)| (t.as_refstr().as_str() == "refs/heads/main").then_some(t)) + .unwrap(); + assert_matches!(main_target, Target::Symbolic(_)); + assert_eq!( + b.resolve_head().map(|q| q.as_str()), + Some("refs/heads/master") + ); + } +} diff --git a/crates/radicle/src/identity/crefs.rs b/crates/radicle/src/identity/crefs.rs index 5da76c93..cd7cbb5e 100644 --- a/crates/radicle/src/identity/crefs.rs +++ b/crates/radicle/src/identity/crefs.rs @@ -1,22 +1,48 @@ use serde::{Deserialize, Serialize}; +use thiserror::Error; -use crate::git::canonical::rules::{RawRules, Rules, ValidationError}; +use crate::git::canonical::rules::{self, RawRules, Rules}; +use crate::git::canonical::symbolic::{self, SymbolicRefs}; +use crate::git::fmt::Qualified; use super::doc::{Delegates, Payload}; +#[derive(Debug, Error)] +pub enum ValidationError { + #[error(transparent)] + Rules(#[from] rules::ValidationError), + + #[error("the target of the symbolic reference '{name} → {target}' is not matched by any rule")] + Dangling { + name: symbolic::RawName, + target: Qualified<'static>, + }, + + #[error( + "the symbolic reference name '{name}' is also matched by rule(s) with pattern(s) {patterns:?}" + )] + Clash { + patterns: Vec, + name: Qualified<'static>, + }, +} + /// Configuration for canonical references and their rules. /// -/// `RawCanonicalRefs` are verified into [`CanonicalRefs`]. +/// [`RawCanonicalRefs`] are verified into [`CanonicalRefs`]. #[derive(Default, Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RawCanonicalRefs { rules: RawRules, + + #[serde(default)] // Default to empty for backwards compatibility. + symbolic: SymbolicRefs, } impl RawCanonicalRefs { /// Construct a new [`RawCanonicalRefs`] from a set of [`RawRules`]. - pub fn new(rules: RawRules) -> Self { - Self { rules } + pub fn new(rules: RawRules, symbolic: SymbolicRefs) -> Self { + Self { rules, symbolic } } /// Return the [`RawRules`]. @@ -24,6 +50,21 @@ impl RawCanonicalRefs { &self.rules } + /// Return the [`RawRules`] for mutation. + pub fn raw_rules_mut(&mut self) -> &mut RawRules { + &mut self.rules + } + + /// Return the [`SymbolicRefs`]. + pub fn symbolic(&self) -> &SymbolicRefs { + &self.symbolic + } + + /// Return the [`SymbolicRefs`] for mutation. + pub fn symbolic_mut(&mut self) -> &mut SymbolicRefs { + &mut self.symbolic + } + /// Validate the [`RawCanonicalRefs`] into a set of [`CanonicalRefs`]. pub fn try_into_canonical_refs( self, @@ -33,7 +74,7 @@ impl RawCanonicalRefs { R: Fn() -> Delegates, { let rules = Rules::from_raw(self.rules, resolve)?; - Ok(CanonicalRefs::new(rules)) + CanonicalRefs::new(rules, self.symbolic) } } @@ -41,22 +82,62 @@ impl RawCanonicalRefs { /// /// [`CanonicalRefs`] can be converted into a [`Payload`] using its [`From`] /// implementation. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct CanonicalRefs { rules: Rules, + + #[serde(default, skip_serializing_if = "SymbolicRefs::is_empty")] + symbolic: SymbolicRefs, } impl CanonicalRefs { - /// Construct a new [`CanonicalRefs`] from a set of [`Rules`]. - pub fn new(rules: Rules) -> Self { - CanonicalRefs { rules } + /// Construct a new [`CanonicalRefs`] from a set of [`Rules`] and + /// [`SymbolicRefs`], validating that these may be evaluated to a well + /// formed set of references when interpreted together. + pub fn new(rules: Rules, symbolic: SymbolicRefs) -> Result { + for (name, target) in symbolic.iter_resolved() { + if rules.matches(target).next().is_none() { + return Err(ValidationError::Dangling { + name: name.to_owned(), + target: target.to_owned(), + }); + } + + let Some(name) = Qualified::from_refstr(name) else { + continue; + }; + + let mut patterns = rules + .matches(&name) + .map(|(pattern, _)| pattern.to_string()) + .peekable(); + if patterns.peek().is_some() { + return Err(ValidationError::Clash { + patterns: patterns.collect(), + name: name.to_owned(), + }); + } + } + + Ok(CanonicalRefs { rules, symbolic }) } /// Return the [`Rules`]. pub fn rules(&self) -> &Rules { &self.rules } + + /// Return the [`SymbolicRefs`]. + pub fn symbolic(&self) -> &SymbolicRefs { + &self.symbolic + } +} + +impl Extend<(rules::RawPattern, rules::RawRule)> for RawCanonicalRefs { + fn extend>(&mut self, iter: T) { + self.rules.extend(iter) + } } #[derive(Debug, thiserror::Error)] @@ -74,3 +155,120 @@ impl TryFrom for Payload { Ok(Self::from(value)) } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use serde_json::json; + + use crate::assert_matches; + + use super::{ValidationError::*, *}; + + fn from(value: serde_json::Value) -> Result { + let delegates: Delegates = crate::test::arbitrary::r#gen::(1).into(); + serde_json::from_value::(value) + .unwrap() + .try_into_canonical_refs(&mut || delegates.clone()) + } + + /// Backwards compatibility to before addition of symbolic references. + #[test] + fn omit_symbolic() { + assert_matches!( + from(json!({ + "rules": {}, + })), + Ok(_) + ); + } + + #[test] + fn invalid_dangling() { + assert_matches!( + from(json!({ + "symbolic": { + "HEAD": "refs/heads/master" + }, + "rules": {}, + })), + Err(Dangling { .. }) + ); + } + + #[test] + fn invalid_clash() { + assert_matches!( + from(json!({ + "symbolic": { + "refs/heads/foo": "refs/heads/bar", + }, + "rules": { + "refs/heads/foo": { + "allow": "delegates", + "threshold": 1, + }, + "refs/heads/bar": { + "allow": "delegates", + "threshold": 1, + }, + }, + })), + Err(Clash { .. }) + ); + } + + #[test] + fn invalid_clash_asterisk_name() { + assert_matches!( + from(json!({ + "symbolic": { + "refs/heads/foo": "refs/heads/bar", + }, + "rules": { + "refs/heads/*": { + "allow": "delegates", + "threshold": 1, + }, + }, + })), + Err(Clash { .. }) + ); + } + + #[test] + fn valid_asterisk_target() { + assert_matches!( + from(json!({ + "symbolic": { + "HEAD": "refs/heads/master", + }, + "rules": { + "refs/heads/*": { + "allow": "delegates", + "threshold": 1, + }, + }, + })), + Ok(_) + ); + } + + #[test] + fn valid() { + assert_matches!( + from(json!({ + "symbolic": { + "refs/heads/foo": "refs/heads/ruled/bar", + }, + "rules": { + "refs/heads/ruled/*": { + "allow": "delegates", + "threshold": 1, + }, + }, + })), + Ok(_) + ); + } +} diff --git a/crates/radicle/src/identity/doc.rs b/crates/radicle/src/identity/doc.rs index 08c14ba4..85c99be3 100644 --- a/crates/radicle/src/identity/doc.rs +++ b/crates/radicle/src/identity/doc.rs @@ -20,7 +20,11 @@ use crate::crypto; use crate::crypto::Signature; use crate::git; use crate::git::canonical::rules; +use crate::git::canonical::symbolic; +use crate::git::fmt::Qualified; +use crate::git::fmt::RefString; use crate::git::raw::ErrorExt as _; +use crate::identity::crefs; use crate::identity::{Did, project::Project}; use crate::node::device::Device; use crate::storage; @@ -75,9 +79,20 @@ impl DocError { } #[derive(Debug, Error)] -pub enum DefaultBranchRuleError { - #[error("could not load `xyz.radicle.project` to get default branch name: {0}")] - Payload(#[from] PayloadError), +pub enum DefaultBranchError { + #[error( + "could not find default branch in any of the payloads `xyz.radicle.project` ({project}) or `xyz.radicle.crefs` ({crefs})" + )] + Payload { + project: PayloadError, + crefs: PayloadError, + }, + + #[error("no symbolic reference with name `HEAD` is defined")] + MissingHead, + + #[error(transparent)] + CanonicalRefsError(#[from] CanonicalRefsError), } /// The version number of the identity document. @@ -757,42 +772,133 @@ impl Doc { } /// Gets the qualified reference name of the default branch, - /// according to the project payload in this document. - pub fn default_branch(&self) -> Result, PayloadError> { - Ok(git::refs::branch(self.project()?.default_branch())) - } - - pub fn default_branch_rule(&self) -> Result { - let pattern = git::fmt::refspec::QualifiedPattern::from(git::refs::branch( - self.project()?.default_branch(), - )); - let rule = rules::Rule::new( - rules::ResolvedDelegates::Delegates(self.delegates.clone()), - self.threshold, - ); - Ok(rules::Rules::from_raw( - rules::RawRules::from_iter([(pattern, rule.into())]), - &mut || self.delegates.clone(), - ) - .expect("default rules are valid")) + /// according to payloads `xyz.radicle.project` and `xyz.radicle.crefs` + /// in this document. + pub fn default_branch(&self) -> Result, DefaultBranchError> { + let crefs = self.canonical_refs()?; + let qualified = crefs + .symbolic() + .resolve_head() + .ok_or(DefaultBranchError::MissingHead)?; + Ok(qualified.to_owned()) } /// Construct the canonical references for this document. - /// The implementation of [`RawCanonicalRefs`] is used to - /// obtain the payload identified by [`PayloadId::canonical_refs`], if it - /// exists. - /// The resulting [`CanonicalRefs`] are constructed by extension with - /// [`Self::default_branch_rule`]. + /// + /// Uses the `xyz.radicle.crefs` payload (if present) and the + /// `xyz.radicle.project` payload to determine the `HEAD` symbolic + /// reference and its associated rule. + /// + /// There are three cases, depending on whether `HEAD` is already + /// defined in the crefs payload and whether a project payload exists: + /// + /// 1. **Explicit HEAD + project**: `HEAD` must agree with + /// [`Project::default_branch_qualified`], and the matching rule must + /// use `delegates` with the document's threshold. + /// 2. **Explicit HEAD, no project**: The matching rule must use + /// `delegates` with the document's threshold. + /// 3. **No HEAD**: A rule and `HEAD` symbolic reference are synthesized + /// from the project payload (which must exist). + /// + /// In all cases the result must pass [`RawCanonicalRefs::try_into_canonical_refs`] + /// validation. If a rule for `HEAD`'s target is missing, it will be + /// caught as a dangling reference there. /// /// [`RawCanonicalRefs`]: super::crefs::RawCanonicalRefs pub fn canonical_refs(&self) -> Result { - let raw_crefs = self.raw_canonical_refs()?.unwrap_or_default(); + let mut raw_crefs = self.raw_canonical_refs()?.unwrap_or_default(); + let resolve = &mut || self.delegates.clone(); - let mut raw_rules = raw_crefs.raw_rules().clone(); - raw_rules.extend(rules::RawRules::from(self.default_branch_rule()?)); + // Determine where `HEAD` comes from. The `resolve_head()` result + // borrows `raw_crefs`, so clone to allow mutation in the synthesis + // path. + let head: Option> = raw_crefs.symbolic().resolve_head().cloned(); - let raw_crefs = RawCanonicalRefs::new(raw_rules); - Ok(raw_crefs.try_into_canonical_refs(&mut || self.delegates.clone())?) + match (head, self.project()) { + (Some(ref default_branch), Ok(project)) => { + let project_branch = project.default_branch_qualified(); + if project_branch != *default_branch { + return Err(DefaultBranchRuleError::HeadMismatch { + cref: default_branch.to_ref_string(), + project: project_branch.to_ref_string(), + })?; + } + self.validate_head_rule(&raw_crefs, default_branch)?; + } + (Some(ref default_branch), Err(_)) => { + self.validate_head_rule(&raw_crefs, default_branch)?; + } + (None, Ok(project)) => { + self.synthesize_head(&mut raw_crefs, &project)?; + } + (None, Err(err)) => { + return Err(CanonicalRefsError::SynthesisPayloadMissing(err)); + } + } + + Ok(raw_crefs.try_into_canonical_refs(resolve)?) + } + + /// Validate that the rule matching `HEAD`'s target branch uses + /// `delegates` and the document's threshold. + /// + /// If no rule matches the target, `HEAD` will dangle — this is caught + /// later by [`RawCanonicalRefs::try_into_canonical_refs`] validation. + fn validate_head_rule( + &self, + raw_crefs: &RawCanonicalRefs, + default_branch: &Qualified, + ) -> Result<(), CanonicalRefsError> { + let Some((pattern, rule)) = raw_crefs.raw_rules().matches(default_branch).next() else { + return Ok(()); + }; + + let allowed = rule.allowed(); + if *allowed != rules::Allowed::Delegates { + return Err(DefaultBranchRuleError::Allowed { + pattern: pattern.to_string(), + actual: allowed.to_string(), + })?; + } + let actual = *rule.threshold(); + let expected = self.threshold(); + if actual != expected { + return Err(DefaultBranchRuleError::Threshold { + pattern: pattern.to_string(), + actual, + expected, + })?; + } + Ok(()) + } + + /// Synthesize a `HEAD` symbolic reference and a default branch rule + /// from the project payload. + fn synthesize_head( + &self, + raw_crefs: &mut RawCanonicalRefs, + project: &Project, + ) -> Result<(), CanonicalRefsError> { + let default_branch = project.default_branch_qualified(); + + if raw_crefs + .raw_rules() + .matches(&default_branch) + .next() + .is_none() + { + raw_crefs.raw_rules_mut().insert( + git::fmt::refspec::QualifiedPattern::from(default_branch.to_owned()), + rules::Rule::new(rules::Allowed::Delegates, self.threshold()), + ); + } + + raw_crefs + .symbolic_mut() + .combine(symbolic::SymbolicRefs::head(project.default_branch())) + .map_err(|source| CanonicalRefsError::SynthesisCycle { source })?; + + Ok(()) } /// Return the associated [`Visibility`] of this document. @@ -952,22 +1058,52 @@ impl Doc { } } -#[derive(Debug, Error)] -pub enum CanonicalRefsError { - #[error(transparent)] - Raw(#[from] RawCanonicalRefsError), - #[error(transparent)] - CanonicalRefs(#[from] rules::ValidationError), - #[error(transparent)] - DefaultBranch(#[from] DefaultBranchRuleError), -} - #[derive(Debug, Error)] pub enum RawCanonicalRefsError { #[error(transparent)] Json(#[from] serde_json::Error), } +#[derive(Debug, Error)] +pub enum CanonicalRefsError { + #[error(transparent)] + Raw(#[from] RawCanonicalRefsError), + + #[error(transparent)] + CanonicalRefs(#[from] crefs::ValidationError), + + #[error("could not load `xyz.radicle.project` to get default branch name: {0}")] + SynthesisPayloadMissing(PayloadError), + + #[error(transparent)] + DefaultBranchRuleError(#[from] DefaultBranchRuleError), + + #[error("synthesizing canonical references from project payload failed: {source}")] + SynthesisCycle { source: symbolic::InsertionError }, +} + +#[derive(Debug, Error)] +pub enum DefaultBranchRuleError { + #[error( + "rule for pattern '{pattern}' which matches the target of symbolic reference 'HEAD' (possibly synthesized from payload 'xyz.radicle.project') must use 'allow' value of \"delegates\" but uses {actual}" + )] + Allowed { pattern: String, actual: String }, + + #[error( + "rule for pattern '{pattern}' which matches the target of symbolic reference 'HEAD' (possibly synthesized from payload 'xyz.radicle.project') must use a threshold of {expected} as required by the identity document but uses {actual}" + )] + Threshold { + pattern: String, + actual: usize, + expected: usize, + }, + + #[error( + "target symbolic reference 'HEAD' ('{cref}') does not match `defaultBranch` from payload `xyz.radicle.project` ('{project}')" + )] + HeadMismatch { cref: RefString, project: RefString }, +} + pub trait GetRawCanonicalRefs: GetPayload { /// Retrieve the [`RawCanonicalRefs`] by deserializing from the payload /// (if present). @@ -1232,4 +1368,72 @@ mod test { serde_json::json!({ "type": "private", "allow": ["did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT"] }) ); } + + #[test] + fn default_branch_without_project() { + let value = serde_json::json!( + { + "payload": { + "xyz.radicle.crefs": { + "symbolic": { + "HEAD": "refs/heads/main", + }, + "rules": { + "refs/heads/main": { + "allow": "delegates", + "threshold": 1, + } + } + } + }, + "delegates": [ + "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM" + ], + "threshold": 1 + } + ); + + let doc = serde_json::from_value::(value).unwrap(); + assert_eq!(doc.default_branch().unwrap().as_str(), "refs/heads/main"); + } + + #[test] + fn default_branch_clash() { + let value = serde_json::json!( + { + "payload": { + "xyz.radicle.project": { + "name": "example", + "description": "An example project", + "defaultBranch": "main", + }, + "xyz.radicle.crefs": { + "symbolic": { + "HEAD": "refs/heads/master", + }, + "rules": { + "refs/heads/master": { + "allow": "delegates", + "threshold": 1, + } + } + } + }, + "delegates": [ + "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM" + ], + "threshold": 1 + } + ); + + let doc = serde_json::from_value::(value).unwrap(); + assert_matches!( + doc.default_branch(), + Err(DefaultBranchError::CanonicalRefsError( + CanonicalRefsError::DefaultBranchRuleError( + DefaultBranchRuleError::HeadMismatch { .. } + ) + )) + ); + } } diff --git a/crates/radicle/src/identity/doc/update.rs b/crates/radicle/src/identity/doc/update.rs index fbec6457..1298a014 100644 --- a/crates/radicle/src/identity/doc/update.rs +++ b/crates/radicle/src/identity/doc/update.rs @@ -208,9 +208,13 @@ pub fn verify(raw: RawDoc) -> Result { }); } }; - // Ensure that if we have canonical reference rules and a project, that no - // rule exists for the default branch. This rule must be synthesized when - // constructing the canonical reference rules. + + // If we have both payloads `xyz.radicle.{project,crefs}` ensure that, + // in the `crefs` payload there is no … + // 1. … rule that matches the default branch from the `project` payload. + // (This rule must be synthesized!) + // 2. … symbolic reference with the name `HEAD`. + // (This reference must be synthesized!) use super::GetRawCanonicalRefs as _; match raw .raw_canonical_refs() @@ -225,11 +229,19 @@ pub fn verify(raw: RawDoc) -> Result { .map(|(pattern, _)| pattern.to_string()) .collect::>(); if !matches.is_empty() { - return Err(error::DocVerification::DisallowDefault { matches, default }); + return Err(error::DocVerification::DisallowDefaultBranchRule { matches, default }); + } + + if let Some(symbolic) = crefs.symbolic().resolve_head() { + return Err(error::DocVerification::DisallowDefaultBranchSymbolic { + symbolic: symbolic.to_ref_string(), + default, + }); } } _ => { /* we validate below */ } } + // Verify that the canonical references payload is valid if let Err(e) = proposal.canonical_refs() { return Err(error::DocVerification::PayloadError { @@ -332,7 +344,7 @@ mod test { assert!( matches!( super::verify(raw), - Err(error::DocVerification::DisallowDefault { .. }) + Err(error::DocVerification::DisallowDefaultBranchRule { .. }) ), "Verification should be rejected for including default branch rule" ) diff --git a/crates/radicle/src/identity/doc/update/error.rs b/crates/radicle/src/identity/doc/update/error.rs index bf19aca6..31ffde52 100644 --- a/crates/radicle/src/identity/doc/update/error.rs +++ b/crates/radicle/src/identity/doc/update/error.rs @@ -31,10 +31,17 @@ pub enum DocVerification { #[error( "incompatible payloads: The rule(s) xyz.radicle.crefs.rules.{matches:?} matches the value of xyz.radicle.project.defaultBranch ('{default}'). Possible resolutions: Change the name of the default branch or remove the rule(s)." )] - DisallowDefault { + DisallowDefaultBranchRule { matches: Vec, default: git::fmt::Qualified<'static>, }, + #[error( + "incompatible payloads: The symbolic reference xyz.radicle.crefs.symbolic.HEAD → '{symbolic}' conflicts with xyz.radicle.project.defaultBranch ('{default}'). Possible resolutions: Remove either of the two." + )] + DisallowDefaultBranchSymbolic { + symbolic: RefString, + default: git::fmt::Qualified<'static>, + }, } #[derive(Clone, Debug)] diff --git a/crates/radicle/src/identity/project.rs b/crates/radicle/src/identity/project.rs index 9f250475..6509e7de 100644 --- a/crates/radicle/src/identity/project.rs +++ b/crates/radicle/src/identity/project.rs @@ -8,6 +8,7 @@ use thiserror::Error; use crate::crypto; use crate::git::BranchName; +use crate::git::{fmt::Qualified, refs::branch}; use crate::identity::doc; use crate::identity::doc::Payload; @@ -254,6 +255,12 @@ impl Project { pub fn default_branch(&self) -> &BranchName { &self.default_branch } + + /// Return the qualified name of the default branch. + #[inline] + pub fn default_branch_qualified(&self) -> Qualified<'_> { + branch(&self.default_branch) + } } impl From for Payload { diff --git a/crates/radicle/src/rad.rs b/crates/radicle/src/rad.rs index 6e91a762..61d00a52 100644 --- a/crates/radicle/src/rad.rs +++ b/crates/radicle/src/rad.rs @@ -142,7 +142,7 @@ where )?; stored.set_remote_identity_root_to(pk, identity)?; stored.set_identity_head_to(identity)?; - stored.set_head_to_default_branch()?; + stored.set_canonical_symbolic_refs("set-canonical from init (radicle)")?; stored.set_default_branch_to_canonical_head()?; let signed = stored.sign_refs(signer)?; diff --git a/crates/radicle/src/storage.rs b/crates/radicle/src/storage.rs index f407cd7d..99c99a69 100644 --- a/crates/radicle/src/storage.rs +++ b/crates/radicle/src/storage.rs @@ -150,7 +150,7 @@ pub enum RepositoryError { #[error("missing canonical reference rule for default branch")] MissingBranchRule, #[error("could not get the default branch rule: {0}")] - DefaultBranchRule(#[from] doc::DefaultBranchRuleError), + DefaultBranchRule(#[from] doc::DefaultBranchError), #[error("failed to get canonical reference rules: {0}")] CanonicalRefs(#[from] doc::CanonicalRefsError), #[error(transparent)] @@ -668,11 +668,30 @@ where /// Allows read-write access to a repository. pub trait WriteRepository: ReadRepository + SignRepository { - /// Sets the symbolic reference `HEAD` to target the default branch. - /// This only depends on the value for the default branch in the identity - /// document, and does not require the canonical reference behind the - /// default branch to be computed, or even exist. - fn set_head_to_default_branch(&self) -> Result<(), RepositoryError>; + /// Sets the canonical symbolic references. + /// + /// This only depends on canonical references (thus the `xyz.radicle.crefs` + /// payload, and possibly the `xyz.radicle.project` payload in the identity + /// document). The targeted canonical references are not computed and might + /// not even exist. + fn set_canonical_symbolic_refs(&self, message: &str) -> Result<(), RepositoryError> { + for (name, target) in self.identity_doc()?.canonical_refs()?.symbolic().iter() { + self.set_symbolic_ref(name, target, message)?; + } + Ok(()) + } + + /// Sets a symbolic reference, if it does not exist or its target is different + /// from the given one. + fn set_symbolic_ref( + &self, + name: &Name, + target: &Target, + message: &str, + ) -> Result<(), RepositoryError> + where + Name: AsRef, + Target: AsRef; /// Computes the head of the default branch based on the delegate set, /// and sets it. diff --git a/crates/radicle/src/storage/git.rs b/crates/radicle/src/storage/git.rs index e6ba9754..bdc5f2ac 100644 --- a/crates/radicle/src/storage/git.rs +++ b/crates/radicle/src/storage/git.rs @@ -28,7 +28,7 @@ use crate::{git, git::Oid, node}; use crate::git::RefError; use crate::git::UserInfo; use crate::git::fmt::{ - Qualified, RefString, refname, refspec, refspec::PatternStr, refspec::PatternString, + Qualified, RefStr, RefString, refspec, refspec::PatternStr, refspec::PatternString, }; pub use crate::storage::{Error, RepositoryError}; @@ -852,20 +852,17 @@ impl ReadRepository for Repository { fn canonical_head(&self) -> Result<(Qualified<'_>, Oid), RepositoryError> { let doc = self.identity_doc()?; - let refname = doc.default_branch()?.to_owned(); - - let crefs = doc.canonical_refs()?; - - Ok(crefs + Ok(doc + .canonical_refs()? .rules() - .canonical(refname, self) + .canonical(doc.default_branch()?, self) .ok_or(RepositoryError::MissingBranchRule)? .find_objects()? .quorum()?) .map( |Quorum { refname, object, .. - }| (refname, object.id()), + }| (refname.to_owned(), object.id()), ) } @@ -939,31 +936,41 @@ impl ReadRepository for Repository { } impl WriteRepository for Repository { - fn set_head_to_default_branch(&self) -> Result<(), RepositoryError> { - let head_ref = refname!("HEAD"); - let branch_ref = self.default_branch()?; - - match self.raw().find_reference(head_ref.as_str()) { - Ok(mut head_ref) => { - if head_ref - .symbolic_target() - .is_some_and(|t| t != branch_ref.as_str()) - { - head_ref.symbolic_set_target(branch_ref.as_str(), "set-head (radicle)")?; + fn set_symbolic_ref( + &self, + name: &Name, + target: &Target, + message: &str, + ) -> Result<(), RepositoryError> + where + Name: AsRef, + Target: AsRef, + { + let name = name.as_ref(); + let target = target.as_ref(); + match self.raw().find_reference(name.as_str()) { + Ok(mut existing) => match existing.symbolic_target() { + Some(current) if current == target.as_str() => { + // Already points to the correct target, nothing to do. } - Ok(()) - } + Some(_) => { + // Symbolic ref pointing to a different target, update it. + existing.symbolic_set_target(target.as_str(), message)?; + } + None => { + // A direct (non-symbolic) ref exists where we expect a + // symbolic one. Overwrite it with force. + self.raw() + .reference_symbolic(name.as_str(), target.as_str(), true, message)?; + } + }, Err(err) if err.is_not_found() => { - self.raw().reference_symbolic( - head_ref.as_str(), - branch_ref.as_str(), - true, - "set-head (radicle)", - )?; - Ok(()) + self.raw() + .reference_symbolic(name.as_str(), target.as_str(), true, message)?; } - Err(err) => Err(err.into()), + Err(err) => return Err(err.into()), } + Ok(()) } fn set_default_branch_to_canonical_head(&self) -> Result { diff --git a/crates/radicle/src/test.rs b/crates/radicle/src/test.rs index 9ca677ce..89b36289 100644 --- a/crates/radicle/src/test.rs +++ b/crates/radicle/src/test.rs @@ -59,7 +59,7 @@ pub fn fetch( drop(opts); repo.set_identity_head()?; - repo.set_head_to_default_branch()?; + repo.set_canonical_symbolic_refs("set-canonical test (radicle)")?; repo.set_default_branch_to_canonical_head()?; let validations = repo.validate()?; diff --git a/crates/radicle/src/test/storage.rs b/crates/radicle/src/test/storage.rs index 17de8105..fb5ee497 100644 --- a/crates/radicle/src/test/storage.rs +++ b/crates/radicle/src/test/storage.rs @@ -344,7 +344,16 @@ impl WriteRepository for MockRepository { todo!() } - fn set_head_to_default_branch(&self) -> Result<(), RepositoryError> { + fn set_symbolic_ref( + &self, + _name: &Name, + _target: &Target, + _message: &str, + ) -> Result<(), RepositoryError> + where + Name: AsRef, + Target: AsRef, + { todo!() }