Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
radicle/crefs: Support symrefs
✗ CI failure Lorenz Leutgeb committed 7 months ago
commit 51037a9efbf70584bc06a2faaba5c9a70eae4e5d
parent 8c65efec8c08053364c22207dd0b69e3a90bcdcc
1 failed 1 pending (2 total) View logs
12 files changed +321 -82
modified CHANGELOG.md
@@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `rad init --setup-signing` now works on bare repositories.
- `git-remote-rad` now correctly reports the default branch to Git by listing
  the symbolic reference `HEAD`.
+
- Symbolic references can now be handled by canonical references by coding them
+
  in the payload `xyz.radicle.crefs` under the key `symbolic`.

## Fixed Bugs

modified crates/radicle-node/src/worker/fetch.rs
@@ -15,8 +15,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;
@@ -119,15 +118,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(),
@@ -366,14 +356,47 @@ fn set_canonical_refs(
    repo: &Repository,
    applied: &Applied,
) -> Result<Option<UpdatedCanonicalRefs>, error::Canonical> {
+
    const LOG_MESSAGE: &str = "set-canonical-reference from fetch (radicle)";
+

    let identity = repo.identity()?;
-
    let rules = identity
-
        .canonical_refs_or_default(|| {
-
            let rule = identity.doc().default_branch_rule()?;
-
            Ok::<_, CanonicalRefsError>(CanonicalRefs::from_iter([rule]))
-
        })?
-
        .rules()
-
        .clone();
+

+
    let crefs = identity.canonical_refs_or_default(|| {
+
        let doc = identity.doc();
+
        let mut crefs = CanonicalRefs::default();
+
        crefs.extend([doc.default_branch_rule()?]);
+
        crefs.extend([doc.default_branch_symbolic()?]);
+

+
        Ok::<_, CanonicalRefsError>(crefs)
+
    })?;
+

+
    log::info!(
+
        target: "worker",
+
        "Attempting to set canonical symbolic references!"
+
    );
+
    for (name, target) in crefs.symbolic().into_iter() {
+
        log::info!(
+
            target: "worker",
+
            "Attempting to set canonical symbolic reference {name}->{target}"
+
        );
+
        if let Err(e) = repo.backend.reference_symbolic(
+
            name.as_ref().as_str(),
+
            target.as_ref().as_str(),
+
            true,
+
            LOG_MESSAGE,
+
        ) {
+
            log::warn!(
+
                target: "worker",
+
                "Failed to set canonical symbolic reference {name}->{target}: {e}"
+
            );
+
        } else {
+
            log::info!(
+
                target: "worker",
+
                "Set canonical symbolic reference {name}->{target}"
+
            );
+
        }
+
    }
+

+
    let rules = crefs.rules().clone();

    let mut updated_refs = UpdatedCanonicalRefs::default();
    let refnames = applied
@@ -415,12 +438,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}"
modified crates/radicle-remote-helper/src/push.rs
@@ -252,6 +252,8 @@ pub fn run(
    stdin: &io::Stdin,
    opts: Options,
) -> Result<(), Error> {
+
    const LOG_MESSAGE: &str = "set-canonical-reference from git-push (radicle)";
+

    // Don't allow push if either of these conditions is true:
    //
    // 1. Our key is not in ssh-agent, which means we won't be able to sign the refs.
@@ -285,7 +287,15 @@ pub fn run(
    }
    let delegates = stored.delegates()?;
    let identity = stored.identity()?;
-
    let canonical_ref = identity.default_branch()?;
+

+
    let crefs = identity.canonical_refs_or_default(|| {
+
        let mut crefs = CanonicalRefs::default();
+
        crefs.extend([identity.default_branch_rule()?]);
+
        crefs.extend([identity.default_branch_symbolic()?]);
+

+
        Ok::<_, CanonicalRefsError>(crefs)
+
    })?;
+

    let mut set_canonical_refs: Vec<(git::Qualified, git::canonical::Object)> =
        Vec::with_capacity(specs.len());

@@ -344,8 +354,10 @@ pub fn run(
                    PushAction::PushRef { dst } => {
                        let identity = stored.identity()?;
                        let crefs = identity.canonical_refs_or_default(|| {
-
                            let rule = identity.doc().default_branch_rule()?;
-
                            Ok::<_, CanonicalRefsError>(CanonicalRefs::from_iter([rule]))
+
                            let mut crefs = CanonicalRefs::default();
+
                            crefs.extend([identity.doc().default_branch_rule()?]);
+
                            crefs.extend([identity.doc().default_branch_symbolic()?]);
+
                            Ok::<_, CanonicalRefsError>(crefs)
                        })?;
                        let rules = crefs.rules();
                        let me = Did::from(nid);
@@ -400,6 +412,15 @@ pub fn run(
    if !ok.is_empty() {
        let _ = stored.sign_refs(&signer)?;

+
        for (name, target) in crefs.symbolic() {
+
            stored.backend.reference_symbolic(
+
                name.as_ref().as_str(),
+
                target.as_ref().as_str(),
+
                true,
+
                LOG_MESSAGE,
+
            )?;
+
        }
+

        for (refname, object) in &set_canonical_refs {
            let oid = object.id();
            let kind = object.object_type();
@@ -412,20 +433,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 => {
modified 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};

modified crates/radicle/src/git/canonical/protect.rs
@@ -63,6 +63,21 @@ mod common {
        refname, refs::branch, refspec::QualifiedPattern, Qualified, RefStr, RefString,
    };

+
    impl Unprotected<RefString> {
+
        /// Construct the reference name `HEAD`.
+
        pub fn head() -> Self {
+
            Self::new(refname!("HEAD")).expect("HEAD is never protected")
+
        }
+
    }
+

+
    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")
+
        }
+
    }
+

    impl Unprotected<QualifiedPattern<'_>> {
        /// Construct a qualified reference name pattern that matches a branch
        /// exactly, i.e., return `/refs/heads/<name>`.
added crates/radicle/src/git/canonical/symbolic.rs
@@ -0,0 +1,122 @@
+
//! 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 crate::git::fmt::{Qualified, RefString};
+

+
use super::protect::Unprotected;
+

+
/// Targets for symbolic rereferences are unprotected qualified references.
+
/// Requiring [`Unprotected`] makes symbolic references that link *to*
+
/// protected references unrepresentable.
+
pub type Target = Unprotected<Qualified<'static>>;
+

+
/// Names for symbolic references are unprotected references.
+
/// Requiring [`Unprotected`] makes symbolic references that link *from*
+
/// from protected references unrepresentable.
+
pub type Name = Unprotected<RefString>;
+

+
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+
pub struct SymbolicRefs(BTreeMap<Name, Target>);
+

+
impl std::cmp::PartialOrd for Name {
+
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+
        Some(self.as_ref().cmp(other.as_ref()))
+
    }
+
}
+

+
impl std::cmp::Ord for Name {
+
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+
        self.as_ref().cmp(other.as_ref())
+
    }
+
}
+

+
impl SymbolicRefs {
+
    pub fn get(&self, key: &Name) -> Option<&Target> {
+
        self.0.get(key)
+
    }
+

+
    // Convenience method to get `HEAD`.
+
    pub fn get_head(&self) -> Option<&Target> {
+
        self.0.get(&Unprotected::head())
+
    }
+

+
    pub fn is_empty(&self) -> bool {
+
        self.0.is_empty()
+
    }
+
}
+

+
impl Extend<(Name, Target)> for SymbolicRefs {
+
    fn extend<T: IntoIterator<Item = (Name, Target)>>(&mut self, iter: T) {
+
        self.0.extend(iter)
+
    }
+
}
+

+
impl<'a> IntoIterator for &'a SymbolicRefs {
+
    type Item = (&'a Name, &'a Target);
+
    type IntoIter = std::collections::btree_map::Iter<'a, Name, Target>;
+

+
    fn into_iter(self) -> Self::IntoIter {
+
        self.0.iter()
+
    }
+
}
+

+
impl FromIterator<(Name, Target)> for SymbolicRefs {
+
    fn from_iter<T: IntoIterator<Item = (Name, Target)>>(iter: T) -> Self {
+
        Self(iter.into_iter().collect())
+
    }
+
}
+

+
#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
+
mod tests {
+
    use crate::git;
+

+
    use super::*;
+

+
    #[test]
+
    fn test_deserialization() {
+
        let examples = r#"
+
{
+
  "HEAD": "refs/heads/main",
+
  "PATCHES": "refs/heads/development"
+
}
+
 "#;
+
        let expected = [
+
            (
+
                Unprotected::new(git::refname!("HEAD")).unwrap(),
+
                Unprotected::branch(&git::refname!("main")),
+
            ),
+
            (
+
                Unprotected::new(git::refname!("PATCHES")).unwrap(),
+
                Unprotected::branch(&git::refname!("development")),
+
            ),
+
        ]
+
        .into_iter()
+
        .collect::<SymbolicRefs>();
+

+
        let rules = serde_json::from_str::<SymbolicRefs>(examples).unwrap();
+
        assert_eq!(expected, rules)
+
    }
+

+
    /// We test that a symbolic reference *from* a protected ref does not deserialize.
+
    /// Note that the target here is actually sane.
+
    #[test]
+
    #[should_panic(expected = "because it starts with 'refs/rad'")]
+
    fn test_deserialization_from_protected() {
+
        let examples = r#"{ "refs/rad/id": "refs/heads/sane" }"#;
+
        let _ = serde_json::from_str::<BTreeMap<Name, Target>>(examples).unwrap();
+
    }
+

+
    /// We test that a symbolic reference *to* a protected ref does not deserialize.
+
    /// Note that the name here is actually sane.
+
    #[test]
+
    #[should_panic(expected = "because it starts with 'refs/rad'")]
+
    fn test_deserialization_to_protected() {
+
        let examples = r#"{ "SANE": "refs/rad/id" }"#;
+
        let _ = serde_json::from_str::<BTreeMap<Name, Target>>(examples).unwrap();
+
    }
+
}
modified crates/radicle/src/identity/crefs.rs
@@ -5,6 +5,8 @@ use crate::git::canonical::{
    ValidRule,
};

+
use crate::git::canonical::symbolic::{self, SymbolicRefs};
+

use super::doc::{Delegates, Payload};

/// Implemented by any data type or store that can return [`CanonicalRefs`] and
@@ -47,12 +49,18 @@ pub trait GetCanonicalRefs {
#[serde(rename_all = "camelCase")]
pub struct RawCanonicalRefs {
    rules: RawRules,
+

+
    #[serde(default)]
+
    symbolic: SymbolicRefs,
}

impl RawCanonicalRefs {
    /// Construct a new [`RawCanonicalRefs`] from a set of [`RawRules`].
    pub fn new(rules: RawRules) -> Self {
-
        Self { rules }
+
        Self {
+
            rules,
+
            symbolic: SymbolicRefs::default(),
+
        }
    }

    /// Return the [`RawRules`].
@@ -60,6 +68,11 @@ impl RawCanonicalRefs {
        &self.rules
    }

+
    /// Return the [`SymbolicRefs`].
+
    pub fn symbolic(&self) -> &SymbolicRefs {
+
        &self.symbolic
+
    }
+

    /// Validate the [`RawCanonicalRefs`] into a set of [`CanonicalRefs`].
    pub fn try_into_canonical_refs<R>(
        self,
@@ -69,7 +82,7 @@ impl RawCanonicalRefs {
        R: Fn() -> Delegates,
    {
        let rules = Rules::from_raw(self.rules, resolve)?;
-
        Ok(CanonicalRefs::new(rules))
+
        Ok(CanonicalRefs::new(rules, self.symbolic))
    }
}

@@ -77,27 +90,28 @@ 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(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 }
+
    pub fn new(rules: Rules, symbolic: SymbolicRefs) -> Self {
+
        CanonicalRefs { rules, symbolic }
    }

    /// Return the [`Rules`].
    pub fn rules(&self) -> &Rules {
        &self.rules
    }
-
}

-
impl FromIterator<(rules::Pattern, ValidRule)> for CanonicalRefs {
-
    fn from_iter<T: IntoIterator<Item = (rules::Pattern, ValidRule)>>(iter: T) -> Self {
-
        Self::new(Rules::from_iter(iter))
+
    /// Return the [`SymbolicRefs`].
+
    pub fn symbolic(&self) -> &SymbolicRefs {
+
        &self.symbolic
    }
}

@@ -107,6 +121,12 @@ impl Extend<(rules::Pattern, ValidRule)> for CanonicalRefs {
    }
}

+
impl Extend<(symbolic::Name, symbolic::Target)> for CanonicalRefs {
+
    fn extend<T: IntoIterator<Item = (symbolic::Name, symbolic::Target)>>(&mut self, iter: T) {
+
        self.symbolic.extend(iter)
+
    }
+
}
+

#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CanonicalRefsPayloadError {
modified 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::protect::Unprotected;
use crate::git::canonical::rules;
+
use crate::git::canonical::symbolic;
+
use crate::identity::crefs::GetCanonicalRefs;
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),
}
@@ -741,14 +744,26 @@ impl Doc {
    }

    /// Gets the qualified reference name of the default branch,
-
    /// according to the project payload in this document.
-
    pub fn default_branch(&self) -> Result<git::Qualified, PayloadError> {
-
        Ok(git::refs::branch(self.project()?.default_branch()))
+
    /// according to the cref or project payload in this document.
+
    pub fn default_branch(&self) -> Result<git::Qualified, DefaultBranchError> {
+
        // Do not directly use `default_branch_symbolic`,
+
        // because this eagerly accesses the project payload.
+

+
        // Attempt to resolve the default branch from crefs payload via symbolic reference `HEAD`.
+
        if let Ok(Some(crefs)) = self.canonical_refs() {
+
            if let Some(target) = crefs.symbolic().get_head() {
+
                return Ok(target.as_ref().to_owned());
+
            }
+
        }
+

+
        // Attempt to resolve the default branch from project payload.
+
        self.default_branch_symbolic()
+
            .map(|(_head, target)| target.as_ref().to_owned())
    }

    pub fn default_branch_rule(
        &self,
-
    ) -> Result<(rules::Pattern, rules::ValidRule), DefaultBranchRuleError> {
+
    ) -> Result<(rules::Pattern, rules::ValidRule), DefaultBranchError> {
        let pattern = rules::Pattern::branch_exact(self.project()?.default_branch());
        let rule = rules::Rule::new(
            rules::ResolvedDelegates::Delegates(self.delegates.clone()),
@@ -757,6 +772,15 @@ impl Doc {
        Ok((pattern, rule))
    }

+
    pub fn default_branch_symbolic(
+
        &self,
+
    ) -> Result<(symbolic::Name, symbolic::Target), DefaultBranchError> {
+
        Ok((
+
            Unprotected::head(),
+
            Unprotected::branch(self.project()?.default_branch()),
+
        ))
+
    }
+

    /// Return the associated [`Visibility`] of this document.
    pub fn visibility(&self) -> &Visibility {
        &self.visibility
@@ -921,28 +945,22 @@ pub enum CanonicalRefsError {
    #[error(transparent)]
    CanonicalRefs(#[from] rules::ValidationError),
    #[error(transparent)]
-
    DefaultBranch(#[from] DefaultBranchRuleError),
+
    DefaultBranch(#[from] DefaultBranchError),
}

impl crefs::GetCanonicalRefs for Doc {
    type Error = CanonicalRefsError;

    fn canonical_refs(&self) -> Result<Option<CanonicalRefs>, Self::Error> {
-
        self.raw_canonical_refs().and_then(|raw| {
-
            raw.map(|raw| {
-
                raw.try_into_canonical_refs(&mut || self.delegates.clone())
-
                    .map_err(CanonicalRefsError::from)
-
                    .and_then(|mut crefs| {
-
                        self.default_branch_rule()
-
                            .map_err(CanonicalRefsError::from)
-
                            .map(|rule| {
-
                                crefs.extend([rule]);
-
                                crefs
-
                            })
-
                    })
-
            })
-
            .transpose()
-
        })
+
        let Some(crefs) = self.raw_canonical_refs()? else {
+
            return Ok(None);
+
        };
+

+
        let mut crefs = crefs.try_into_canonical_refs(&mut || self.delegates.clone())?;
+
        crefs.extend([self.default_branch_rule()?]);
+
        crefs.extend([self.default_branch_symbolic()?]);
+

+
        Ok(Some(crefs))
    }

    fn raw_canonical_refs(&self) -> Result<Option<RawCanonicalRefs>, Self::Error> {
@@ -1119,6 +1137,11 @@ mod test {
                visibility: Visibility::Public,
            }
        );
+

+
        assert_eq!(
+
            verified.default_branch().unwrap().as_str(),
+
            "refs/heads/master"
+
        )
    }

    #[test]
modified crates/radicle/src/identity/doc/update.rs
@@ -215,7 +215,14 @@ 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().get_head() {
+
                return Err(error::DocVerification::DisallowDefaultBranchSymbolic {
+
                    symbolic: symbolic.as_ref().clone(),
+
                    default,
+
                });
            }
        }
        _ => { /* we validate below */ }
@@ -324,7 +331,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"
        )
modified 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: git::Qualified<'static>,
+
        default: git::Qualified<'static>,
+
    },
}

#[derive(Clone, Debug)]
modified 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,9 +525,10 @@ 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 crefs payload in the identity document.
    fn default_branch(&self) -> Result<Qualified, RepositoryError> {
-
        Ok(self.identity_doc()?.default_branch()?.to_owned())
+
        let (qualified, _oid) = self.canonical_head()?;
+
        Ok(qualified.to_owned())
    }

    /// Compute the canonical head of this repository.
modified crates/radicle/src/storage/git.rs
@@ -786,13 +786,21 @@ 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 = match doc.canonical_refs()? {
-
            Some(crefs) => crefs,
-
            // Fallback to constructing the default branch via the project
-
            // payload
-
            None => CanonicalRefs::from_iter([doc.default_branch_rule()?]),
+

+
        let crefs = doc.canonical_refs_or_default(|| {
+
            let mut crefs = CanonicalRefs::default();
+
            crefs.extend([doc.default_branch_rule()?]);
+
            crefs.extend([doc.default_branch_symbolic()?]);
+

+
            Ok::<_, RepositoryError>(crefs)
+
        })?;
+

+
        let Some(refname) = crefs.symbolic().get_head() else {
+
            return Err(RepositoryError::MissingBranchSymbolic);
        };
+

+
        let refname = refname.as_ref().to_owned();
+

        Ok(crefs
            .rules()
            .canonical(refname, self)
@@ -880,7 +888,7 @@ 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()?;
+
        let (branch_ref, _) = self.canonical_head()?;

        match self.raw().find_reference(head_ref.as_str()) {
            Ok(mut head_ref) => {