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 <fintan.halpenny@gmail.com>
This commit is contained in:
Lorenz Leutgeb 2025-09-16 22:02:09 +02:00 committed by Lorenz Leutgeb
parent 080790d84e
commit 3f81e83d36
20 changed files with 1670 additions and 130 deletions

View File

@ -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 environment variable `RAD_PASSPHRASE` (lower priority than the
credential). The identifier of the credential is credential). The identifier of the credential is
"xyz.radicle.node.passphrase". "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 ## Fixed Bugs

View File

@ -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
```

View File

@ -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".

View File

@ -301,8 +301,12 @@ fn git_push_canonical_lightweight_tags() {
.unwrap(); .unwrap();
} }
/// This test exercises a large surface of the "Canonical References" feature:
/// - Annotated Tags
/// - Branches
/// - Symbolic References
#[test] #[test]
fn git_push_canonical_annotated_tags() { fn git_push_canonical() {
let mut environment = Environment::new(); let mut environment = Environment::new();
let alice = environment.node("alice"); let alice = environment.node("alice");
let bob = environment.node("bob"); let bob = environment.node("bob");
@ -341,4 +345,40 @@ fn git_push_canonical_annotated_tags() {
) )
.run() .run()
.unwrap(); .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();
} }

View File

@ -14,8 +14,7 @@ use radicle::prelude::RepoId;
use radicle::storage::git::Repository; use radicle::storage::git::Repository;
use radicle::storage::refs::RefsAt; use radicle::storage::refs::RefsAt;
use radicle::storage::{ use radicle::storage::{
ReadRepository, ReadStorage as _, RefUpdate, RemoteRepository, RepositoryError, ReadRepository, ReadStorage as _, RefUpdate, RemoteRepository, WriteRepository as _,
WriteRepository as _,
}; };
use radicle::{Storage, cob, git, node}; use radicle::{Storage, cob, git, node};
use radicle_fetch::git::refs::Applied; use radicle_fetch::git::refs::Applied;
@ -128,15 +127,6 @@ impl Handle {
// points to a repository that is temporary and gets moved by [`mv`]. // points to a repository that is temporary and gets moved by [`mv`].
let repo = storage.repository(rid)?; let repo = storage.repository(rid)?;
repo.set_identity_head()?; 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) { let canonical = match set_canonical_refs(&repo, &applied) {
Ok(updates) => updates.unwrap_or_default(), Ok(updates) => updates.unwrap_or_default(),
@ -346,8 +336,21 @@ fn set_canonical_refs(
repo: &Repository, repo: &Repository,
applied: &Applied, applied: &Applied,
) -> Result<Option<UpdatedCanonicalRefs>, error::Canonical> { ) -> Result<Option<UpdatedCanonicalRefs>, error::Canonical> {
const LOG_MESSAGE: &str = "set-canonical-reference from fetch (radicle)";
let identity = repo.identity()?; 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 mut updated_refs = UpdatedCanonicalRefs::default();
let refnames = applied let refnames = applied
@ -389,12 +392,10 @@ fn set_canonical_refs(
refname, object, .. refname, object, ..
}) => { }) => {
let oid = object.id(); let oid = object.id();
if let Err(e) = repo.backend.reference( if let Err(e) =
refname.clone().as_str(), repo.backend
oid.into(), .reference(refname.clone().as_str(), oid.into(), true, LOG_MESSAGE)
true, {
"set-canonical-reference from fetch (radicle)",
) {
log::warn!( log::warn!(
target: "worker", target: "worker",
"Failed to set canonical reference {refname}->{oid}: {e}" "Failed to set canonical reference {refname}->{oid}: {e}"

View File

@ -258,6 +258,8 @@ pub(super) fn run(
git: &impl GitService, git: &impl GitService,
node: &mut impl NodeSession, node: &mut impl NodeSession,
) -> Result<Vec<String>, Error> { ) -> Result<Vec<String>, Error> {
const LOG_MESSAGE: &str = "set-canonical-reference from git-push (radicle)";
// Don't allow push if either of these conditions is true: // 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. // 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 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)> = let mut set_canonical_refs: Vec<(git::fmt::Qualified, git::canonical::Object)> =
Vec::with_capacity(specs.len()); Vec::with_capacity(specs.len());
@ -407,6 +406,8 @@ pub(super) fn run(
if !ok.is_empty() { if !ok.is_empty() {
let _ = stored.sign_refs(&signer)?; let _ = stored.sign_refs(&signer)?;
stored.set_canonical_symbolic_refs(LOG_MESSAGE)?;
for (refname, object) in &set_canonical_refs { for (refname, object) in &set_canonical_refs {
let oid = object.id(); let oid = object.id();
let kind = object.object_type(); 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()) { match stored.backend.refname_to_id(refname.as_str()) {
Ok(new) if oid != new => { Ok(new) if oid != new => {
stored.backend.reference( stored
refname.as_str(), .backend
oid.into(), .reference(refname.as_str(), oid.into(), true, LOG_MESSAGE)?;
true,
"set-canonical-reference from git-push (radicle)",
)?;
print_update(); print_update();
} }
Err(e) if e.code() == git::raw::ErrorCode::NotFound => { Err(e) if e.code() == git::raw::ErrorCode::NotFound => {

View File

@ -12,6 +12,7 @@ mod voting;
pub mod effects; pub mod effects;
pub mod protect; pub mod protect;
pub mod rules; pub mod rules;
pub mod symbolic;
pub use rules::{MatchedRule, RawRule, Rules, ValidRule}; pub use rules::{MatchedRule, RawRule, Rules, ValidRule};

View File

@ -19,7 +19,7 @@ pub enum Error {
} }
/// A witnesses that the inner reference-like value is not protected. /// 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)] #[repr(transparent)]
#[serde(transparent)] #[serde(transparent)]
pub(super) struct Unprotected<T: RefLike>(T); pub(super) struct Unprotected<T: RefLike>(T);
@ -42,6 +42,13 @@ impl<T: RefLike> Unprotected<T> {
pub fn into_inner(self) -> T { pub fn into_inner(self) -> T {
self.0 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> { impl<T: RefLike> AsRef<T> for Unprotected<T> {
@ -50,6 +57,20 @@ impl<T: RefLike> AsRef<T> for Unprotected<T> {
} }
} }
impl<T: RefLike> std::borrow::Borrow<T> for Unprotected<T> {
fn borrow(&self) -> &T {
&self.0
}
}
/// Enables looking up entries in a map keyed by `Unprotected<RefString>` using
/// a `&RefStr`.
impl std::borrow::Borrow<crate::git::fmt::RefStr> for Unprotected<crate::git::fmt::RefString> {
fn borrow(&self) -> &crate::git::fmt::RefStr {
self.0.as_ref()
}
}
impl<'de, T: RefLike + serde::Deserialize<'de>> serde::Deserialize<'de> for Unprotected<T> { impl<'de, T: RefLike + serde::Deserialize<'de>> serde::Deserialize<'de> for Unprotected<T> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where where
@ -68,7 +89,10 @@ impl<T: RefLike + std::fmt::Display> std::fmt::Display for Unprotected<T> {
/// For types that are commonly used in conjunction with [`Unprotected`] /// For types that are commonly used in conjunction with [`Unprotected`]
/// have some `impl`s and companion infallible injections. /// have some `impl`s and companion infallible injections.
mod impls { mod impls {
use crate::git::fmt::{RefString, refspec::QualifiedPattern}; use crate::git::{
fmt::{Qualified, RefStr, RefString, refname, refspec::QualifiedPattern},
refs::branch,
};
use super::*; use super::*;
@ -76,6 +100,33 @@ mod impls {
/// means to be [`RefLike`]. /// means to be [`RefLike`].
impl RefLike for RefString {} 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 /// A [`QualifiedPattern`] is [`RefLike`] in the sense that it matches a
/// (possibly infinite) set of [`crate::git::Qualified`]. /// (possibly infinite) set of [`crate::git::Qualified`].
impl RefLike for QualifiedPattern<'_> {} impl RefLike for QualifiedPattern<'_> {}

View File

@ -386,6 +386,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("\"delegates\""),
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`]. /// A marker `enum` that is used in a [`ValidRule`].
/// ///
/// It ensures that a rule that has been deserialized, resolving the `delegates` /// It ensures that a rule that has been deserialized, resolving the `delegates`

View File

@ -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<RefStr> 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<Qualified<'static>>);
impl Direct {
fn to_ref_string(&self) -> Unprotected<RefString> {
self.0.to_ref_string()
}
}
impl PartialEq<RefString> for Direct {
fn eq(&self, other: &RefString) -> bool {
**self.0.as_ref() == **other
}
}
impl AsRef<Qualified<'static>> 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<RefString>);
impl AsRef<RefString> 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<Qualified<'static>>) -> Self {
Self::Direct(Direct(d))
}
fn symbolic(s: Unprotected<RefString>) -> Self {
Self::Symbolic(Symbolic(s))
}
/// Classify an [`Unprotected<RefString>`] 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<RefString>) -> 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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
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<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))
}
}
/// 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<IndexMap>`]. 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]: <https://datatracker.ietf.org/doc/html/rfc8259#section-4>
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(try_from = "IndexMap<Name, Unprotected<RefString>>")]
pub struct SymbolicRefs(IndexMap<Name, Target>);
/// 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<Item = (&RawName, &Target)> {
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<Item = (&RawName, &Qualified<'static>)> {
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/<branch_name>`.
// 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<RefString>,
) -> 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<IndexMap<Name, Unprotected<RefString>>> for SymbolicRefs {
type Error = InsertionError;
fn try_from(map: IndexMap<Name, Unprotected<RefString>>) -> Result<Self, Self::Error> {
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::<SymbolicRefs>(serde_json::json!({
"refs/heads/a": "refs/heads/b",
})),
Ok(_)
);
}
#[test]
fn deserialize_order() {
assert_matches!(
serde_json::from_value::<SymbolicRefs>(serde_json::json!({
"MAIN": "refs/heads/master",
"HEAD": "MAIN",
})),
Ok(_)
);
assert_matches!(
serde_json::from_value::<SymbolicRefs>(serde_json::json!({
"HEAD": "MAIN",
"MAIN": "refs/heads/master",
})),
Err(_)
);
}
#[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(_)
);
assert_matches!(
serde_json::from_value::<SymbolicRefs>(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::<SymbolicRefs>(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::<SymbolicRefs>(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::<SymbolicRefs>(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")
);
}
}

View File

@ -1,22 +1,48 @@
use serde::{Deserialize, Serialize}; 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}; 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<String>,
name: Qualified<'static>,
},
}
/// Configuration for canonical references and their rules. /// Configuration for canonical references and their rules.
/// ///
/// `RawCanonicalRefs` are verified into [`CanonicalRefs`]. /// [`RawCanonicalRefs`] are verified into [`CanonicalRefs`].
#[derive(Default, Debug, Clone, PartialEq, Eq, Deserialize)] #[derive(Default, Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RawCanonicalRefs { pub struct RawCanonicalRefs {
rules: RawRules, rules: RawRules,
#[serde(default)] // Default to empty for backwards compatibility.
symbolic: SymbolicRefs,
} }
impl RawCanonicalRefs { impl RawCanonicalRefs {
/// Construct a new [`RawCanonicalRefs`] from a set of [`RawRules`]. /// Construct a new [`RawCanonicalRefs`] from a set of [`RawRules`].
pub fn new(rules: RawRules) -> Self { pub fn new(rules: RawRules, symbolic: SymbolicRefs) -> Self {
Self { rules } Self { rules, symbolic }
} }
/// Return the [`RawRules`]. /// Return the [`RawRules`].
@ -24,6 +50,21 @@ impl RawCanonicalRefs {
&self.rules &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`]. /// Validate the [`RawCanonicalRefs`] into a set of [`CanonicalRefs`].
pub fn try_into_canonical_refs<R>( pub fn try_into_canonical_refs<R>(
self, self,
@ -33,7 +74,7 @@ impl RawCanonicalRefs {
R: Fn() -> Delegates, R: Fn() -> Delegates,
{ {
let rules = Rules::from_raw(self.rules, resolve)?; 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`] /// [`CanonicalRefs`] can be converted into a [`Payload`] using its [`From`]
/// implementation. /// implementation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CanonicalRefs { pub struct CanonicalRefs {
rules: Rules, rules: Rules,
#[serde(default, skip_serializing_if = "SymbolicRefs::is_empty")]
symbolic: SymbolicRefs,
} }
impl CanonicalRefs { impl CanonicalRefs {
/// Construct a new [`CanonicalRefs`] from a set of [`Rules`]. /// Construct a new [`CanonicalRefs`] from a set of [`Rules`] and
pub fn new(rules: Rules) -> Self { /// [`SymbolicRefs`], validating that these may be evaluated to a well
CanonicalRefs { rules } /// 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 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`]. /// Return the [`Rules`].
pub fn rules(&self) -> &Rules { pub fn rules(&self) -> &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)] #[derive(Debug, thiserror::Error)]
@ -74,3 +155,120 @@ impl TryFrom<CanonicalRefs> for Payload {
Ok(Self::from(value)) 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::r#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(_)
);
}
}

View File

@ -20,7 +20,11 @@ use crate::crypto;
use crate::crypto::Signature; use crate::crypto::Signature;
use crate::git; use crate::git;
use crate::git::canonical::rules; 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::git::raw::ErrorExt as _;
use crate::identity::crefs;
use crate::identity::{Did, project::Project}; use crate::identity::{Did, project::Project};
use crate::node::device::Device; use crate::node::device::Device;
use crate::storage; use crate::storage;
@ -75,9 +79,20 @@ impl DocError {
} }
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum DefaultBranchRuleError { pub enum DefaultBranchError {
#[error("could not load `xyz.radicle.project` to get default branch name: {0}")] #[error(
Payload(#[from] PayloadError), "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. /// The version number of the identity document.
@ -757,42 +772,133 @@ impl Doc {
} }
/// Gets the qualified reference name of the default branch, /// Gets the qualified reference name of the default branch,
/// according to the project payload in this document. /// according to payloads `xyz.radicle.project` and `xyz.radicle.crefs`
pub fn default_branch(&self) -> Result<git::fmt::Qualified<'_>, PayloadError> { /// in this document.
Ok(git::refs::branch(self.project()?.default_branch())) pub fn default_branch(&self) -> Result<git::fmt::Qualified<'_>, DefaultBranchError> {
} let crefs = self.canonical_refs()?;
let qualified = crefs
pub fn default_branch_rule(&self) -> Result<rules::Rules, DefaultBranchRuleError> { .symbolic()
let pattern = git::fmt::refspec::QualifiedPattern::from(git::refs::branch( .resolve_head()
self.project()?.default_branch(), .ok_or(DefaultBranchError::MissingHead)?;
)); Ok(qualified.to_owned())
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. /// Construct the canonical references for this document.
/// The implementation of [`RawCanonicalRefs`] is used to ///
/// obtain the payload identified by [`PayloadId::canonical_refs`], if it /// Uses the `xyz.radicle.crefs` payload (if present) and the
/// exists. /// `xyz.radicle.project` payload to determine the `HEAD` symbolic
/// The resulting [`CanonicalRefs`] are constructed by extension with /// reference and its associated rule.
/// [`Self::default_branch_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 /// [`RawCanonicalRefs`]: super::crefs::RawCanonicalRefs
pub fn canonical_refs(&self) -> Result<CanonicalRefs, CanonicalRefsError> { pub fn canonical_refs(&self) -> Result<CanonicalRefs, CanonicalRefsError> {
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(); // Determine where `HEAD` comes from. The `resolve_head()` result
raw_rules.extend(rules::RawRules::from(self.default_branch_rule()?)); // borrows `raw_crefs`, so clone to allow mutation in the synthesis
// path.
let head: Option<Qualified<'static>> = raw_crefs.symbolic().resolve_head().cloned();
let raw_crefs = RawCanonicalRefs::new(raw_rules); match (head, self.project()) {
Ok(raw_crefs.try_into_canonical_refs(&mut || self.delegates.clone())?) (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. /// 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)] #[derive(Debug, Error)]
pub enum RawCanonicalRefsError { pub enum RawCanonicalRefsError {
#[error(transparent)] #[error(transparent)]
Json(#[from] serde_json::Error), 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 { pub trait GetRawCanonicalRefs: GetPayload {
/// Retrieve the [`RawCanonicalRefs`] by deserializing from the payload /// Retrieve the [`RawCanonicalRefs`] by deserializing from the payload
/// (if present). /// (if present).
@ -1232,4 +1368,72 @@ mod test {
serde_json::json!({ "type": "private", "allow": ["did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT"] }) 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::<Doc>(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::<Doc>(value).unwrap();
assert_matches!(
doc.default_branch(),
Err(DefaultBranchError::CanonicalRefsError(
CanonicalRefsError::DefaultBranchRuleError(
DefaultBranchRuleError::HeadMismatch { .. }
)
))
);
}
} }

View File

@ -208,9 +208,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 // If we have both payloads `xyz.radicle.{project,crefs}` ensure that,
// constructing the canonical reference rules. // 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 _; use super::GetRawCanonicalRefs as _;
match raw match raw
.raw_canonical_refs() .raw_canonical_refs()
@ -225,11 +229,19 @@ pub fn verify(raw: RawDoc) -> Result<Doc, error::DocVerification> {
.map(|(pattern, _)| pattern.to_string()) .map(|(pattern, _)| pattern.to_string())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if !matches.is_empty() { 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 */ } _ => { /* we validate below */ }
} }
// Verify that the canonical references payload is valid // Verify that the canonical references payload is valid
if let Err(e) = proposal.canonical_refs() { if let Err(e) = proposal.canonical_refs() {
return Err(error::DocVerification::PayloadError { return Err(error::DocVerification::PayloadError {
@ -332,7 +344,7 @@ mod test {
assert!( assert!(
matches!( matches!(
super::verify(raw), super::verify(raw),
Err(error::DocVerification::DisallowDefault { .. }) Err(error::DocVerification::DisallowDefaultBranchRule { .. })
), ),
"Verification should be rejected for including default branch rule" "Verification should be rejected for including default branch rule"
) )

View File

@ -31,10 +31,17 @@ pub enum DocVerification {
#[error( #[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)." "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>, matches: Vec<String>,
default: git::fmt::Qualified<'static>, 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)] #[derive(Clone, Debug)]

View File

@ -8,6 +8,7 @@ use thiserror::Error;
use crate::crypto; use crate::crypto;
use crate::git::BranchName; use crate::git::BranchName;
use crate::git::{fmt::Qualified, refs::branch};
use crate::identity::doc; use crate::identity::doc;
use crate::identity::doc::Payload; use crate::identity::doc::Payload;
@ -254,6 +255,12 @@ impl Project {
pub fn default_branch(&self) -> &BranchName { pub fn default_branch(&self) -> &BranchName {
&self.default_branch &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<Project> for Payload { impl From<Project> for Payload {

View File

@ -142,7 +142,7 @@ where
)?; )?;
stored.set_remote_identity_root_to(pk, identity)?; stored.set_remote_identity_root_to(pk, identity)?;
stored.set_identity_head_to(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()?; stored.set_default_branch_to_canonical_head()?;
let signed = stored.sign_refs(signer)?; let signed = stored.sign_refs(signer)?;

View File

@ -150,7 +150,7 @@ pub enum RepositoryError {
#[error("missing canonical reference rule for default branch")] #[error("missing canonical reference rule for default branch")]
MissingBranchRule, MissingBranchRule,
#[error("could not get the default branch rule: {0}")] #[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}")] #[error("failed to get canonical reference rules: {0}")]
CanonicalRefs(#[from] doc::CanonicalRefsError), CanonicalRefs(#[from] doc::CanonicalRefsError),
#[error(transparent)] #[error(transparent)]
@ -668,11 +668,30 @@ where
/// Allows read-write access to a repository. /// Allows read-write access to a repository.
pub trait WriteRepository: ReadRepository + SignRepository { pub trait WriteRepository: ReadRepository + SignRepository {
/// Sets the symbolic reference `HEAD` to target the default branch. /// Sets the canonical symbolic references.
/// This only depends on the value for the default branch in the identity ///
/// document, and does not require the canonical reference behind the /// This only depends on canonical references (thus the `xyz.radicle.crefs`
/// default branch to be computed, or even exist. /// payload, and possibly the `xyz.radicle.project` payload in the identity
fn set_head_to_default_branch(&self) -> Result<(), RepositoryError>; /// 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<Name, Target>(
&self,
name: &Name,
target: &Target,
message: &str,
) -> Result<(), RepositoryError>
where
Name: AsRef<RefStr>,
Target: AsRef<RefStr>;
/// Computes the head of the default branch based on the delegate set, /// Computes the head of the default branch based on the delegate set,
/// and sets it. /// and sets it.

View File

@ -28,7 +28,7 @@ use crate::{git, git::Oid, node};
use crate::git::RefError; use crate::git::RefError;
use crate::git::UserInfo; use crate::git::UserInfo;
use crate::git::fmt::{ 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}; pub use crate::storage::{Error, RepositoryError};
@ -852,20 +852,17 @@ impl ReadRepository for Repository {
fn canonical_head(&self) -> Result<(Qualified<'_>, Oid), RepositoryError> { fn canonical_head(&self) -> Result<(Qualified<'_>, Oid), RepositoryError> {
let doc = self.identity_doc()?; let doc = self.identity_doc()?;
let refname = doc.default_branch()?.to_owned(); Ok(doc
.canonical_refs()?
let crefs = doc.canonical_refs()?;
Ok(crefs
.rules() .rules()
.canonical(refname, self) .canonical(doc.default_branch()?, self)
.ok_or(RepositoryError::MissingBranchRule)? .ok_or(RepositoryError::MissingBranchRule)?
.find_objects()? .find_objects()?
.quorum()?) .quorum()?)
.map( .map(
|Quorum { |Quorum {
refname, object, .. refname, object, ..
}| (refname, object.id()), }| (refname.to_owned(), object.id()),
) )
} }
@ -939,32 +936,42 @@ impl ReadRepository for Repository {
} }
impl WriteRepository for Repository { impl WriteRepository for Repository {
fn set_head_to_default_branch(&self) -> Result<(), RepositoryError> { fn set_symbolic_ref<Name, Target>(
let head_ref = refname!("HEAD"); &self,
let branch_ref = self.default_branch()?; name: &Name,
target: &Target,
match self.raw().find_reference(head_ref.as_str()) { message: &str,
Ok(mut head_ref) => { ) -> Result<(), RepositoryError>
if head_ref where
.symbolic_target() Name: AsRef<RefStr>,
.is_some_and(|t| t != branch_ref.as_str()) Target: AsRef<RefStr>,
{ {
head_ref.symbolic_set_target(branch_ref.as_str(), "set-head (radicle)")?; 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() => { Err(err) if err.is_not_found() => {
self.raw().reference_symbolic( self.raw()
head_ref.as_str(), .reference_symbolic(name.as_str(), target.as_str(), true, message)?;
branch_ref.as_str(), }
true, Err(err) => return Err(err.into()),
"set-head (radicle)", }
)?;
Ok(()) Ok(())
} }
Err(err) => Err(err.into()),
}
}
fn set_default_branch_to_canonical_head(&self) -> Result<SetHead, RepositoryError> { fn set_default_branch_to_canonical_head(&self) -> Result<SetHead, RepositoryError> {
let (branch_ref, new) = self.canonical_head()?; let (branch_ref, new) = self.canonical_head()?;

View File

@ -59,7 +59,7 @@ pub fn fetch<W: WriteRepository>(
drop(opts); drop(opts);
repo.set_identity_head()?; 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()?; repo.set_default_branch_to_canonical_head()?;
let validations = repo.validate()?; let validations = repo.validate()?;

View File

@ -344,7 +344,16 @@ impl WriteRepository for MockRepository {
todo!() todo!()
} }
fn set_head_to_default_branch(&self) -> Result<(), RepositoryError> { fn set_symbolic_ref<Name, Target>(
&self,
_name: &Name,
_target: &Target,
_message: &str,
) -> Result<(), RepositoryError>
where
Name: AsRef<fmt::RefStr>,
Target: AsRef<fmt::RefStr>,
{
todo!() todo!()
} }