rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwood0b0f73d2b39088bbfeb30c496e870b27c915a81b
{
"request": "trigger",
"version": 1,
"event_type": "patch",
"repository": {
"id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
"name": "heartwood",
"description": "Radicle Heartwood Protocol & Stack",
"private": false,
"default_branch": "master",
"delegates": [
"did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT",
"did:key:z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW",
"did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"did:key:z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz",
"did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz"
]
},
"action": "Updated",
"patch": {
"id": "63b4e1d9046a2af75401f74234d7fc18b447109d",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"title": "Canonical Symbolic References",
"state": {
"status": "open",
"conflicts": []
},
"before": "ed8b086045ee5d7bd1327f579de7861a1cf49e3b",
"after": "0b0f73d2b39088bbfeb30c496e870b27c915a81b",
"commits": [
"0b0f73d2b39088bbfeb30c496e870b27c915a81b",
"60631338bf257e74865133a9925594ccdfa0abf5",
"80f313c1aa87f215e2c51117c33186c2149601e4",
"45e50add1693d40624a349d97da56184961087cf",
"3a4174ade803b7713785a7985948e149131738da",
"354a2f8d5b3261ef20fa009ef2d97e06d2549b33",
"d9740dea9448a25695bd93d1e27f733d1a59d93c",
"b1c0dc16aea2e30c701a8ebc9c8e7d0111148460"
],
"target": "ed8b086045ee5d7bd1327f579de7861a1cf49e3b",
"labels": [],
"assignees": [],
"revisions": [
{
"id": "63b4e1d9046a2af75401f74234d7fc18b447109d",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "Extend the payload `xyz.radicle.crefs` to additionally support creating symbolic references via the member \"symbolic\".",
"base": "86472fdccbf95d08d0184776ee1ca75d01caf2c8",
"oid": "0d8ccb51101df44aa643c14e421f0a8f5a1aca53",
"timestamp": 1758036727
},
{
"id": "a8f4547661b3e0c0ae87a7b2bf10bac784529d42",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "First working revision.",
"base": "86472fdccbf95d08d0184776ee1ca75d01caf2c8",
"oid": "4910c5f03beefe20d7a1dea9e0c986de867987b5",
"timestamp": 1758063904
},
{
"id": "4728a5335d735afa5c6a0427bd88a35f93c7b8fa",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "REVIEW\n\nSuper nice set of changes, and seems to fit so nicely into the canonical\nreference system!\n\nAppreciate the type safety too :)\n\nLeft some bits of feedback, but all-in-all I'm very happy with these changes.",
"base": "86472fdccbf95d08d0184776ee1ca75d01caf2c8",
"oid": "cc99c8cc509107757916307fdc5f29210ef70114",
"timestamp": 1758111930
},
{
"id": "61f41e3e3bf3469acd72b9b3f237bb29356f328f",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "Work in Fintan's review.",
"base": "86472fdccbf95d08d0184776ee1ca75d01caf2c8",
"oid": "72584651f1818dfc2e9759d4f4afa60ad0ada357",
"timestamp": 1758117424
},
{
"id": "4f8b72024cc6e070f6e8324292b8205536f6f924",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "Rebase",
"base": "7b00bf2e3ac5e83eab262182bf51a5ede656145c",
"oid": "3324ebbb01151e6f0db951155d342a1782c27f95",
"timestamp": 1758120326
},
{
"id": "e4e142e1b405905ca7219e1d2534619408104095",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "Handle interactions between rules and symrefs, and cyclic symrefs.",
"base": "e70850cb36c5f08d2d8714017d5404dde34deefa",
"oid": "902d99f2131f8420f2a8a9c70f645f69e13cfbf1",
"timestamp": 1758459092
},
{
"id": "6a1155bceb7d3ca55b39311848111f8fdc1f1552",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "REVIEW\n\nSee commits for review suggestions.\n\nLooking really good and clean still!",
"base": "e70850cb36c5f08d2d8714017d5404dde34deefa",
"oid": "ce4864fc46c4463b6aeabb79b0712bcaf9987bdf",
"timestamp": 1758559598
},
{
"id": "52de3142d005a0d934cf6d84f3dab33f2b94dcc0",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "Squash in Fintan's review, rename tests as mentioned on Zulip, improve docs on `Doc::canonical_refs`.",
"base": "e70850cb36c5f08d2d8714017d5404dde34deefa",
"oid": "0891ad955e8a68b689b56a1b5e1a9e1077147586",
"timestamp": 1758614851
},
{
"id": "d76a072ff1259a4a1e584e93d5e4eb47a1ff3d27",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "Rebase",
"base": "ed8b086045ee5d7bd1327f579de7861a1cf49e3b",
"oid": "0b0f73d2b39088bbfeb30c496e870b27c915a81b",
"timestamp": 1758725112
}
]
}
}
{
"response": "triggered",
"run_id": {
"id": "38c85d6e-c6ed-4cb7-aec0-9674ff9978cf"
},
"info_url": "https://cci.rad.levitte.org//38c85d6e-c6ed-4cb7-aec0-9674ff9978cf.html"
}
Started at: 2025-09-24 17:21:53.104275+02:00
Commands:
$ rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 .
✓ Creating checkout in ./...
✓ Remote cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT added
✓ Remote-tracking branch cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/master created for z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT
✓ Remote cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW added
✓ Remote-tracking branch cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW/master created for z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW
✓ Remote fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM added
✓ Remote-tracking branch fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/master created for z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM
✓ Remote erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz added
✓ Remote-tracking branch erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz/master created for z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz
✓ Remote lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz added
✓ Remote-tracking branch lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz/master created for z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz
✓ Repository successfully cloned under /opt/radcis/ci.rad.levitte.org/cci/state/38c85d6e-c6ed-4cb7-aec0-9674ff9978cf/w/
╭────────────────────────────────────╮
│ heartwood │
│ Radicle Heartwood Protocol & Stack │
│ 122 issues · 17 patches │
╰────────────────────────────────────╯
Run `cd ./.` to go to the repository directory.
Exit code: 0
$ rad patch checkout 63b4e1d9046a2af75401f74234d7fc18b447109d
✓ Switched to branch patch/63b4e1d at revision d76a072
✓ Branch patch/63b4e1d setup to track rad/patches/63b4e1d9046a2af75401f74234d7fc18b447109d
Exit code: 0
$ git config advice.detachedHead false
Exit code: 0
$ git checkout 0b0f73d2b39088bbfeb30c496e870b27c915a81b
HEAD is now at 0b0f73d2 radicle/crefs: Support Symbolic References
Exit code: 0
$ git show 0b0f73d2b39088bbfeb30c496e870b27c915a81b
commit 0b0f73d2b39088bbfeb30c496e870b27c915a81b
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Tue Sep 16 22:02:09 2025 +0200
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.
Re-use `Unprotected` just like rules do.
Care is taken to not allow circular references, and that there always
is a rule that could generate the target of a (chain of) symbolic
reference. This does not guarantee that no symref can ever dangle, but
at least it can be prevented that a symref always dangles.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 377d98cb..26bf42c4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `git-remote-rad` now correctly reports the default branch to Git by listing
the symbolic reference `HEAD`.
- `rad status` learned a new option `--only nid` for printing the Node ID.
+- 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-node/src/worker/fetch.rs b/crates/radicle-node/src/worker/fetch.rs
index e007bd05..fcfd3da4 100644
--- a/crates/radicle-node/src/worker/fetch.rs
+++ b/crates/radicle-node/src/worker/fetch.rs
@@ -12,8 +12,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::{cob, git, node, Storage};
use radicle_fetch::git::refs::Applied;
@@ -116,15 +115,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: {e}")
- }
- Err(e) => return Err(e.into()),
- }
let canonical = match set_canonical_refs(&repo, &applied) {
Ok(updates) => updates.unwrap_or_default(),
@@ -363,8 +353,21 @@ fn set_canonical_refs(
repo: &Repository,
applied: &Applied,
) -> Result<Option<UpdatedCanonicalRefs>, error::Canonical> {
+ const LOG_MESSAGE: &str = "set-canonical 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
@@ -406,12 +409,10 @@ fn set_canonical_refs(
refname, object, ..
}) => {
let oid = object.id();
- if let Err(e) = repo.backend.reference(
- refname.clone().as_str(),
- *oid,
- true,
- "set-canonical-reference from fetch (radicle)",
- ) {
+ if let Err(e) =
+ repo.backend
+ .reference(refname.clone().as_str(), *oid, 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 928d2900..75bad2e6 100644
--- a/crates/radicle-remote-helper/src/push.rs
+++ b/crates/radicle-remote-helper/src/push.rs
@@ -250,6 +250,8 @@ pub fn run(
stdin: &io::Stdin,
opts: Options,
) -> Result<(), Error> {
+ const LOG_MESSAGE: &str = "set-canonical from 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.
@@ -282,8 +284,7 @@ pub fn run(
}
}
let delegates = stored.delegates()?;
- let identity = stored.identity()?;
- let canonical_ref = identity.default_branch()?;
+
let mut set_canonical_refs: Vec<(git::Qualified, git::canonical::Object)> =
Vec::with_capacity(specs.len());
@@ -395,6 +396,8 @@ pub 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();
@@ -407,20 +410,11 @@ pub 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 new != *oid => {
- stored.backend.reference(
- refname.as_str(),
- *oid,
- true,
- "set-canonical-reference from git-push (radicle)",
- )?;
+ stored
+ .backend
+ .reference(refname.as_str(), *oid, 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 fc802553..3645e09b 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 7ff29eb3..848ecd9d 100644
--- a/crates/radicle/src/git/canonical/protect.rs
+++ b/crates/radicle/src/git/canonical/protect.rs
@@ -42,6 +42,13 @@ impl<T: RefLike> Unprotected<T> {
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<T: RefLike> AsRef<T> for Unprotected<T> {
@@ -68,7 +75,9 @@ impl<T: RefLike + std::fmt::Display> std::fmt::Display for Unprotected<T> {
/// For types that are commonly used in conjunction with [`Unprotected`]
/// have some `impl`s and companion infallible injections.
mod impls {
- use crate::git::{refspec::QualifiedPattern, RefString};
+ use crate::git::{
+ refname, refs::branch, refspec::QualifiedPattern, Qualified, RefStr, RefString,
+ };
use super::*;
@@ -76,6 +85,33 @@ mod impls {
/// means to be [`RefLike`].
impl RefLike for RefString {}
+ impl Unprotected<RefString> {
+ /// 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<Qualified<'_>> {
+ /// Construct a qualified reference name for given branch, i.e.,
+ /// return `/refs/heads/<name>`
+ pub fn branch(name: &RefStr) -> Self {
+ Self::new(branch(name)).expect("branches are never protected")
+ }
+
+ pub fn to_ref_string(&self) -> Unprotected<RefString> {
+ 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 a28d1c3e..79ef5877 100644
--- a/crates/radicle/src/git/canonical/rules.rs
+++ b/crates/radicle/src/git/canonical/rules.rs
@@ -389,6 +389,22 @@ impl From<Did> 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("\"allowed\""),
+ Allowed::Set(dids) => {
+ let dids = dids
+ .iter()
+ .map(|did| did.to_string())
+ .collect::<Vec<_>>()
+ .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..ab799267
--- /dev/null
+++ b/crates/radicle/src/git/canonical/symbolic.rs
@@ -0,0 +1,324 @@
+//! Symbolic references, which link neither to nor from protected references.
+//! The prototypical example is `HEAD → refs/heads/main`.
+
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+use crate::git::fmt::RefString;
+
+use super::protect::{self, Unprotected};
+
+use reachability::reachable;
+
+pub type RawName = RefString;
+
+/// Names of symbolic references are unprotected references.
+/// Requiring [`Unprotected`] makes symbolic references that link *from*
+/// protected references unrepresentable.
+pub(super) type Name = Unprotected<RefString>;
+
+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<std::cmp::Ordering> {
+ Some(self.cmp(other))
+ }
+}
+
+pub type RawTarget = RefString;
+
+/// Targets for symbolic references are unprotected references.
+/// Requiring [`Unprotected`] makes symbolic references that link *to*
+/// protected references unrepresentable.
+pub(super) type Target = Unprotected<RefString>;
+
+/// Maintains a cycle-free set of symbolic references.
+/// Note that dangling references are not detected.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(try_from = "BTreeMap<Name, Target>")]
+pub struct SymbolicRefs(BTreeMap<Name, Target>);
+
+// Read-only access.
+impl SymbolicRefs {
+ /// Returns an iterator over all contained symbolic references, as pairs of
+ /// their name [`RawName`] and [`RawTarget`].
+ pub fn iter(&self) -> impl Iterator<Item = (&RawName, &RawTarget)> {
+ self.0
+ .iter()
+ .map(|(name, target)| (name.as_ref(), target.as_ref()))
+ }
+
+ /// Returns an iterator over all contained symbolic references, as pairs of
+ /// their name [`RawName`] and resolved [`RawTarget`].
+ pub fn iter_resolved(&self) -> impl Iterator<Item = (&RawName, &RawTarget)> {
+ self.iter_resolved_unprotected()
+ .map(|(name, target)| (name.as_ref(), target.as_ref()))
+ }
+
+ pub(super) fn iter_resolved_unprotected(&self) -> impl Iterator<Item = (&Name, &Target)> {
+ self.0
+ .keys()
+ .filter_map(|name| self.resolve_unprotected(name).map(|target| (name, target)))
+ }
+
+ fn resolve_unprotected<'a, 'b>(&'a self, mut name: &'b Name) -> Option<&'a Target>
+ where
+ 'a: 'b,
+ {
+ while let Some(target) = self.0.get(name) {
+ match self.0.get(target) {
+ Some(next) => {
+ name = next;
+ }
+ None => return Some(target),
+ }
+ }
+
+ 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/<branch_name>`.
+ // This exists to encapsulate [`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 target of the `HEAD` reference.
+ /// See also [`SymbolicRefs::head`].
+ pub fn resolve_head(&self) -> Option<&RawTarget> {
+ self.resolve_unprotected(&Unprotected::head())
+ .map(|target| target.as_ref())
+ }
+}
+
+#[derive(Debug, Error)]
+pub enum InsertionError {
+ #[error("inserting symbolic reference '{name} → {target}' would create a cycle")]
+ Cyclic { name: RawName, target: RawTarget },
+
+ #[error(transparent)]
+ Protected(#[from] protect::Error),
+}
+
+// Mutability.
+impl SymbolicRefs {
+ /// Insert a symbolic reference.
+ /// Even though this method will never return [`InsertionError::Protected`]
+ /// we opt to return that (slightly more general) error, as it allows
+ /// construction of [`InsertionError::Cyclic`] by consuming `name` and
+ /// `target`, avoiding an early copy in [`Self::try_insert`].
+ pub(super) fn try_insert_unprotected(
+ &mut self,
+ name: Name,
+ target: Target,
+ ) -> Result<(), InsertionError> {
+ if reachable(&self.0, &target, &name) {
+ return Err(InsertionError::Cyclic {
+ name: name.into_inner(),
+ target: target.into_inner(),
+ });
+ }
+
+ self.0.insert(name, target);
+ Ok(())
+ }
+
+ /// Try to insert a symbolic reference.
+ /// Errors if `name` or `target` is protected (see [`protect`]) or would
+ /// cause infinite recursion (e.g. `A → B` and `B → A`).
+ pub fn try_insert(&mut self, name: RawName, target: RawTarget) -> Result<(), InsertionError> {
+ self.try_insert_unprotected(Unprotected::new(name)?, Unprotected::new(target)?)
+ }
+
+ /// Consume `other` by iteratively inserting into self.
+ pub fn combine(&mut self, other: SymbolicRefs) -> Result<(), InsertionError> {
+ for (name, target) in other.0 {
+ self.try_insert_unprotected(name, target)?;
+ }
+ Ok(())
+ }
+}
+
+#[derive(Debug, Error)]
+#[error("symbolic reference '{name}' is cyclic")]
+pub struct Cyclic {
+ name: RawName,
+}
+
+impl TryFrom<BTreeMap<Name, Target>> for SymbolicRefs {
+ type Error = Cyclic;
+
+ fn try_from(map: BTreeMap<Name, Target>) -> Result<Self, Self::Error> {
+ for (name, target) in map.iter() {
+ if reachable(&map, target, name) {
+ return Err(Cyclic {
+ name: name.to_owned().into_inner(),
+ });
+ }
+ }
+
+ Ok(Self(map))
+ }
+}
+
+mod reachability {
+ pub(super) trait Get<'a, 'b, K, V> {
+ fn get(&'a self, key: &'b K) -> Option<&'a V>;
+ }
+
+ impl<'a, 'b, K: Ord, V> Get<'a, 'b, K, V> for std::collections::BTreeMap<K, V> {
+ fn get(&'a self, key: &'b K) -> Option<&'a V> {
+ std::collections::BTreeMap::get(self, key)
+ }
+ }
+
+ impl<'a, 'b, K: Eq + std::hash::Hash, V> Get<'a, 'b, K, V> for std::collections::HashMap<K, V> {
+ fn get(&'a self, key: &'b K) -> Option<&'a V> {
+ std::collections::HashMap::get(self, key)
+ }
+ }
+
+ impl<'a, 'b, K: Eq + std::hash::Hash, V> Get<'a, 'b, K, V> for indexmap::IndexMap<K, V> {
+ fn get(&'a self, key: &'b K) -> Option<&'a V> {
+ indexmap::IndexMap::get(self, key)
+ }
+ }
+
+ /// A reachability check linking
+ /// from `K` to `V` using [`Get`], and
+ /// from `V` to `K` using [`Into`].
+ /// Note that the bound is trivial if `K = V`.
+ ///
+ /// This can be used to check whether inserting `key → val`
+ /// would create a cycle.
+ ///
+ /// # Returns
+ ///
+ /// Whether `key == val` (under [`Into::into`]) or
+ /// `key` is reachable from `val` (under [`Into::into`] and [`Get::get`]).
+ pub(super) fn reachable<'a, 'b, K: PartialEq, V: 'a>(
+ map: &'a impl Get<'a, 'b, K, V>,
+ val: &'b V,
+ key: &'b K,
+ ) -> bool
+ where
+ 'a: 'b,
+ &'b V: Into<&'b K>,
+ {
+ // Self-Reference
+ let src = val.into();
+ if *src == *key {
+ return true;
+ }
+
+ // Chase
+ let mut src = src;
+ while let Some(tmp) = map.get(src).map(|value| value.into()) {
+ if *tmp == *key {
+ return true;
+ }
+ src = tmp;
+ }
+
+ false
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::unwrap_used)]
+mod test {
+ use crate::assert_matches;
+ use crate::git::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!("b")), Ok(()));
+
+ assert_matches!(symbolic.try_insert(refname!("b"), refname!("c")), Ok(()));
+
+ assert_matches!(
+ symbolic.try_insert(refname!("c"), refname!("a")),
+ Err(InsertionError::Cyclic { .. })
+ );
+ }
+
+ #[test]
+ fn deserialize_valid() {
+ assert_matches!(
+ serde_json::from_value::<SymbolicRefs>(serde_json::json!({
+ "refs/heads/a": "refs/heads/b",
+ })),
+ Ok(_)
+ );
+ }
+
+ #[test]
+ fn deserialize_infinite() {
+ assert_matches!(
+ serde_json::from_value::<SymbolicRefs>(serde_json::json!({
+ "refs/heads/a": "refs/heads/a",
+ })),
+ Err(_)
+ );
+
+ assert_matches!(
+ serde_json::from_value::<SymbolicRefs>(serde_json::json!({
+ "refs/heads/a": "refs/heads/b",
+ "refs/heads/b": "refs/heads/c",
+ "refs/heads/c": "refs/heads/a",
+ })),
+ Err(_)
+ );
+ }
+
+ /// 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!("a"), refname!("b")), Ok(()));
+
+ let mut b = SymbolicRefs::default();
+ assert_matches!(b.try_insert(refname!("b"), refname!("a")), Ok(()));
+
+ assert_matches!(a.combine(b), Err(InsertionError::Cyclic { .. }));
+ }
+}
diff --git a/crates/radicle/src/identity/crefs.rs b/crates/radicle/src/identity/crefs.rs
index 5da76c93..afee3582 100644
--- a/crates/radicle/src/identity/crefs.rs
+++ b/crates/radicle/src/identity/crefs.rs
@@ -1,9 +1,29 @@
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::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: symbolic::RawTarget,
+ },
+ #[error("the symbolic reference name '{name}' is also matched by rule(s) with pattern(s) {patterns:?}")]
+ Clash {
+ patterns: Vec<String>,
+ name: Qualified<'static>,
+ },
+}
+
/// Configuration for canonical references and their rules.
///
/// `RawCanonicalRefs` are verified into [`CanonicalRefs`].
@@ -11,12 +31,15 @@ use super::doc::{Delegates, Payload};
#[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 +47,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<R>(
self,
@@ -33,7 +71,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 +79,65 @@ 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<Self, ValidationError> {
+ for (name, target) in symbolic.iter_resolved() {
+ if Qualified::from_refstr(target)
+ .and_then(|qualified| rules.matches(&qualified).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 patterns = rules
+ .matches(&name)
+ .map(|(pattern, _)| pattern.to_string())
+ .collect::<Vec<_>>();
+ if !patterns.is_empty() {
+ return Err(ValidationError::Clash {
+ patterns,
+ 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<T: IntoIterator<Item = (rules::RawPattern, rules::RawRule)>>(&mut self, iter: T) {
+ self.rules.extend(iter)
+ }
}
#[derive(Debug, thiserror::Error)]
@@ -74,3 +155,120 @@ impl TryFrom<CanonicalRefs> 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<CanonicalRefs, super::ValidationError> {
+ let delegates: Delegates = crate::test::arbitrary::gen::<crate::prelude::Did>(1).into();
+ serde_json::from_value::<RawCanonicalRefs>(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 a8a53412..aeb0f40f 100644
--- a/crates/radicle/src/identity/doc.rs
+++ b/crates/radicle/src/identity/doc.rs
@@ -21,7 +21,10 @@ use crate::cob::identity;
use crate::crypto;
use crate::crypto::Signature;
use crate::git;
-use crate::git::canonical::rules::{self, RawRules};
+use crate::git::canonical::rules;
+use crate::git::canonical::symbolic;
+use crate::git::Qualified;
+use crate::identity::crefs;
use crate::identity::{project::Project, Did};
use crate::node::device::Device;
use crate::storage;
@@ -80,7 +83,7 @@ impl DocError {
}
#[derive(Debug, Error)]
-pub enum DefaultBranchRuleError {
+pub enum DefaultBranchError {
#[error("could not load `xyz.radicle.project` to get default branch name: {0}")]
Payload(#[from] PayloadError),
}
@@ -761,41 +764,113 @@ impl Doc {
Ok(proj)
}
- /// Gets the qualified reference name of the default branch,
- /// according to the project payload in this document.
- pub fn default_branch(&self) -> Result<git::Qualified, PayloadError> {
- Ok(git::refs::branch(self.project()?.default_branch()))
- }
-
- pub fn default_branch_rule(&self) -> Result<rules::Rules, DefaultBranchRuleError> {
- let pattern = git::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"))
- }
-
/// Construct the canonical references for this document.
- /// The implementation of [`crefs::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`].
+ ///
+ /// Starts by obtaining the payload identified by
+ /// [`PayloadId::canonical_refs`].
+ ///
+ /// If the payload exists, and it contains both a symbolic reference with
+ /// the name `HEAD` and a rule matching the corresponding target branch,
+ /// then this rule is verified to be backwards compatible, i.e. that the
+ /// value for `allowed` is [`rules::Allowed::Delegates`] and the threshold
+ /// matches [`Self::threshold`].
+ ///
+ /// If the payload is missing, canonical references are synthesized from
+ /// the payload identified by [`PayloadId::project`]:
+ /// - A rule exactly matching [`Project::default_branch`]
+ /// that is compatible with self.
+ /// - A symbolic reference with name `HEAD`
+ /// (see [`symbolic::SymbolicRefs::head`]) that targets the same branch.
+ ///
+ /// The resulting [`CanonicalRefs`] must pass validation, and there are
+ /// cases where the payload is valid as such, but invalid in combination
+ /// with the synthesized rule and symbolic reference. For example, if
+ /// there is a symbolic reference already, with the name of the default
+ /// branch, this will clash with the synthesized rule.
pub fn canonical_refs(&self) -> Result<CanonicalRefs, CanonicalRefsError> {
let raw_crefs = self.raw_canonical_refs()?.unwrap_or_default();
- let mut raw_rules = raw_crefs.raw_rules().clone();
- raw_rules.extend(RawRules::from(self.default_branch_rule()?));
+ let resolve = &mut || self.delegates.clone();
+
+ // If there is a symbolic reference with name `HEAD` in the crefs
+ // payload, we do not need to access the project payload to obtain
+ // the name of the default branch from there.
+ // However, we must still ensure that the crefs payload, in particular
+ // the rule matching the target brach of the symbolic reference with
+ // name `HEAD`, is backwards compatible with the rest of the identity
+ // document.
+ // These conditions may only be relaxed by introducing a new version of
+ // the identity document.
+ if let Some(default_branch) = raw_crefs.symbolic().resolve_head() {
+ if let Some(default_branch) = Qualified::from_refstr(default_branch) {
+ if let Some((pattern, rule)) = raw_crefs.raw_rules().matches(&default_branch).next()
+ {
+ if *rule.allowed() != rules::Allowed::Delegates {
+ return Err(CanonicalRefsError::DefaultBranchRuleError(
+ DefaultBranchRuleError::Allowed {
+ pattern: pattern.to_string(),
+ actual: rule.allowed().to_string(),
+ },
+ ));
+ }
+ if *rule.threshold() != self.threshold() {
+ return Err(CanonicalRefsError::DefaultBranchRuleError(
+ DefaultBranchRuleError::Threshold {
+ pattern: pattern.to_string(),
+ actual: *rule.threshold(),
+ expected: self.threshold(),
+ },
+ ));
+ }
+ } else {
+ // There is a symbolic reference for `HEAD`, but not matching
+ // canonical reference rule. `HEAD` is dangling!
+ // `raw_crefs` is malformed and will not pass validation below.
+ }
+ }
+ return Ok(raw_crefs.try_into_canonical_refs(resolve)?);
+ }
+
+ // Since there is no symbolic reference with name `HEAD`, we fall back
+ // to the project payload for obtaining the default branch.
+ let project = self.project().map_err(CanonicalRefsError::DefaultBranch)?;
+
+ // Only now, once we know that we will be synthesizing, and have a
+ // project to do so, make `raw_crefs` mutable.
+ let mut raw_crefs = raw_crefs;
+
+ let default_branch = project.default_branch_ref();
+
+ if raw_crefs
+ .raw_rules()
+ .matches(default_branch.as_ref())
+ .next()
+ .is_none()
+ {
+ let rule = rules::Rule::new(rules::Allowed::Delegates, self.threshold());
+
+ raw_crefs.raw_rules_mut().insert(
+ git::refspec::QualifiedPattern::from(default_branch.to_owned()),
+ rule,
+ );
+ }
- let raw_crefs = RawCanonicalRefs::new(raw_rules);
- Ok(raw_crefs.try_into_canonical_refs(&mut || self.delegates.clone())?)
+ raw_crefs
+ .symbolic_mut()
+ .combine(symbolic::SymbolicRefs::head(project.default_branch()))?;
+
+ Ok(raw_crefs.try_into_canonical_refs(resolve)?)
+ }
+
+ /// Gets the qualified reference name of the default branch,
+ /// according to the cref or project payload in this document.
+ pub fn default_branch(&self) -> Result<git::Qualified, CanonicalRefsError> {
+ self.canonical_refs()?
+ .symbolic()
+ .resolve_head()
+ .cloned()
+ .and_then(Qualified::from_refstr)
+ .ok_or(CanonicalRefsError::MissingHead)
}
/// Return the associated [`Visibility`] of this document.
@@ -955,20 +1030,42 @@ impl Doc {
}
}
+#[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] rules::ValidationError),
+ CanonicalRefs(#[from] crefs::ValidationError),
+ #[error("could not load `xyz.radicle.project` to get default branch name: {0}")]
+ DefaultBranch(PayloadError),
+
+ #[error("no symbolic reference with name `HEAD` is defined")]
+ MissingHead,
+
#[error(transparent)]
- DefaultBranch(#[from] DefaultBranchRuleError),
+ DefaultBranchRuleError(#[from] DefaultBranchRuleError),
+
+ #[error("synthesizing canonical references from project payload failed: {0}")]
+ Synthesis(#[from] symbolic::InsertionError),
}
#[derive(Debug, Error)]
-pub enum RawCanonicalRefsError {
- #[error(transparent)]
- Json(#[from] serde_json::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,
+ },
}
pub trait GetRawCanonicalRefs: GetPayload {
@@ -1232,4 +1329,32 @@ mod test {
serde_json::json!({ "type": "private", "allow": ["did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT"] })
);
}
+
+ #[test]
+ fn test_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::<Doc>(value).unwrap();
+ assert_eq!(doc.default_branch().unwrap().as_str(), "refs/heads/main");
+ }
}
diff --git a/crates/radicle/src/identity/doc/update.rs b/crates/radicle/src/identity/doc/update.rs
index 6e216004..806f92e3 100644
--- a/crates/radicle/src/identity/doc/update.rs
+++ b/crates/radicle/src/identity/doc/update.rs
@@ -199,9 +199,13 @@ pub fn verify(raw: RawDoc) -> Result<Doc, error::DocVerification> {
})
}
};
- // 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()
@@ -215,11 +219,19 @@ pub fn verify(raw: RawDoc) -> Result<Doc, error::DocVerification> {
.map(|(pattern, _)| pattern.to_string())
.collect::<Vec<_>>();
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_owned(),
+ default,
+ });
}
}
_ => { /* we validate below */ }
}
+
// Verify that the canonical references payload is valid
if let Err(e) = proposal.canonical_refs() {
return Err(error::DocVerification::PayloadError {
@@ -321,7 +333,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 a9e664f4..565dfda8 100644
--- a/crates/radicle/src/identity/doc/update/error.rs
+++ b/crates/radicle/src/identity/doc/update/error.rs
@@ -29,10 +29,15 @@ pub enum DocVerification {
#[error(transparent)]
Doc(#[from] DocError),
#[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<String>,
default: git::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::Qualified<'static>,
+ },
}
#[derive(Clone, Debug)]
diff --git a/crates/radicle/src/identity/project.rs b/crates/radicle/src/identity/project.rs
index 242288ef..7049a686 100644
--- a/crates/radicle/src/identity/project.rs
+++ b/crates/radicle/src/identity/project.rs
@@ -7,6 +7,7 @@ use serde::{
use thiserror::Error;
use crate::crypto;
+use crate::git::{refs::branch, Qualified};
use crate::identity::doc;
use crate::identity::doc::Payload;
use crate::storage::BranchName;
@@ -254,6 +255,11 @@ impl Project {
pub fn default_branch(&self) -> &BranchName {
&self.default_branch
}
+
+ #[inline]
+ pub fn default_branch_ref(&self) -> Qualified {
+ branch(&self.default_branch)
+ }
}
impl From<Project> for Payload {
diff --git a/crates/radicle/src/rad.rs b/crates/radicle/src/rad.rs
index 71d71efb..edbaa23f 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 7a85088a..e9d30fde 100644
--- a/crates/radicle/src/storage.rs
+++ b/crates/radicle/src/storage.rs
@@ -122,8 +122,10 @@ pub enum RepositoryError {
Refs(#[from] refs::Error),
#[error("missing canonical reference rule for default branch")]
MissingBranchRule,
+ #[error("missing canonical symbolic reference for default branch (`HEAD`)")]
+ MissingBranchSymbolic,
#[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)]
@@ -523,7 +525,7 @@ pub trait ReadRepository: Sized + ValidateRepository {
fn head(&self) -> Result<(Qualified, Oid), RepositoryError>;
/// Gets the qualified reference name of the default branch of self,
- /// according to the project payload in the identity document.
+ /// according to the identity document.
fn default_branch(&self) -> Result<Qualified, RepositoryError> {
Ok(self.identity_doc()?.default_branch()?.to_owned())
}
@@ -670,11 +672,26 @@ 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 the cref payload in the identity document, and does
+ /// not require the targeted canonical references to be computed, or 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: &RefStr,
+ target: &RefStr,
+ message: &str,
+ ) -> Result<(), RepositoryError>;
/// 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 4a8eee6c..04ec3494 100644
--- a/crates/radicle/src/storage/git.rs
+++ b/crates/radicle/src/storage/git.rs
@@ -26,7 +26,7 @@ use crate::storage::{
use crate::{git, node};
pub use crate::git::{
- ext, raw, refname, refspec, Oid, PatternStr, Qualified, RefError, RefString, UserInfo,
+ ext, raw, refname, refspec, Oid, PatternStr, Qualified, RefError, RefStr, RefString, UserInfo,
};
pub use crate::storage::{Error, RepositoryError};
@@ -785,13 +785,16 @@ 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()?;
+ let Some(refname) = crefs.symbolic().resolve_head().and_then(Qualified::from_refstr) else {
+ return Err(RepositoryError::MissingBranchSymbolic);
+ };
+
Ok(crefs
.rules()
- .canonical(refname, self)
+ .canonical(refname.to_owned(), self)
.ok_or(RepositoryError::MissingBranchRule)?
.find_objects()?
.quorum()?)
@@ -874,31 +877,28 @@ 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()) {
+ fn set_symbolic_ref(
+ &self,
+ name: &RefStr,
+ target: &RefStr,
+ message: &str,
+ ) -> Result<(), RepositoryError> {
+ match self.raw().find_reference(name.as_str()) {
Ok(mut head_ref) => {
if head_ref
.symbolic_target()
- .is_some_and(|t| t != branch_ref.as_str())
+ .is_some_and(|t| t != target.as_str())
{
- head_ref.symbolic_set_target(branch_ref.as_str(), "set-head (radicle)")?;
+ head_ref.symbolic_set_target(target.as_str(), message)?;
}
- Ok(())
}
Err(err) if git::ext::is_not_found_err(&err) => {
- 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<SetHead, RepositoryError> {
diff --git a/crates/radicle/src/test.rs b/crates/radicle/src/test.rs
index 7a219531..e2c5e30f 100644
--- a/crates/radicle/src/test.rs
+++ b/crates/radicle/src/test.rs
@@ -58,7 +58,7 @@ pub fn fetch<W: WriteRepository>(
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 b8e44c71..65bddd2f 100644
--- a/crates/radicle/src/test/storage.rs
+++ b/crates/radicle/src/test/storage.rs
@@ -326,7 +326,12 @@ impl WriteRepository for MockRepository {
todo!()
}
- fn set_head_to_default_branch(&self) -> Result<(), RepositoryError> {
+ fn set_symbolic_ref(
+ &self,
+ _name: &fmt::RefStr,
+ _target: &fmt::RefStr,
+ _message: &str,
+ ) -> Result<(), RepositoryError> {
todo!()
}
Exit code: 0
shell: 'export RUSTDOCFLAGS=''-D warnings'' cargo --version rustc --version cargo fmt --check cargo clippy --all-targets --workspace -- --deny warnings cargo build --all-targets --workspace cargo doc --workspace --no-deps cargo test --workspace --no-fail-fast '
Commands:
$ podman run --name 38c85d6e-c6ed-4cb7-aec0-9674ff9978cf -v /opt/radcis/ci.rad.levitte.org/cci/state/38c85d6e-c6ed-4cb7-aec0-9674ff9978cf/s:/38c85d6e-c6ed-4cb7-aec0-9674ff9978cf/s:ro -v /opt/radcis/ci.rad.levitte.org/cci/state/38c85d6e-c6ed-4cb7-aec0-9674ff9978cf/w:/38c85d6e-c6ed-4cb7-aec0-9674ff9978cf/w -w /38c85d6e-c6ed-4cb7-aec0-9674ff9978cf/w -v /opt/radcis/ci.rad.levitte.org/.radicle:/${id}/.radicle:ro -e RAD_HOME=/${id}/.radicle rust:bookworm bash /38c85d6e-c6ed-4cb7-aec0-9674ff9978cf/s/script.sh
+ export 'RUSTDOCFLAGS=-D warnings'
+ RUSTDOCFLAGS='-D warnings'
+ cargo --version
info: syncing channel updates for '1.88-x86_64-unknown-linux-gnu'
info: latest update on 2025-06-26, rust version 1.88.0 (6b00bc388 2025-06-23)
info: downloading component 'cargo'
info: downloading component 'clippy'
info: downloading component 'rust-docs'
info: downloading component 'rust-src'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: downloading component 'rustfmt'
info: installing component 'cargo'
info: installing component 'clippy'
info: installing component 'rust-docs'
info: installing component 'rust-src'
info: installing component 'rust-std'
info: installing component 'rustc'
info: installing component 'rustfmt'
cargo 1.88.0 (873a06493 2025-05-10)
+ rustc --version
rustc 1.88.0 (6b00bc388 2025-06-23)
+ cargo fmt --check
Diff in /38c85d6e-c6ed-4cb7-aec0-9674ff9978cf/w/crates/radicle/src/identity/doc.rs:775:
/// value for `allowed` is [`rules::Allowed::Delegates`] and the threshold
/// matches [`Self::threshold`].
///
- /// If the payload is missing, canonical references are synthesized from
+ /// If the payload is missing, canonical references are synthesized from
/// the payload identified by [`PayloadId::project`]:
/// - A rule exactly matching [`Project::default_branch`]
/// that is compatible with self.
Diff in /38c85d6e-c6ed-4cb7-aec0-9674ff9978cf/w/crates/radicle/src/storage/git.rs:788:
let crefs = doc.canonical_refs()?;
- let Some(refname) = crefs.symbolic().resolve_head().and_then(Qualified::from_refstr) else {
+ let Some(refname) = crefs
+ .symbolic()
+ .resolve_head()
+ .and_then(Qualified::from_refstr)
+ else {
return Err(RepositoryError::MissingBranchSymbolic);
};
Exit code: 1
{
"response": "finished",
"result": "failure"
}