Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
httpd: Add canonical and peer refs to repo API
Rūdolfs Ošiņš committed 8 days ago
commit 42bc27c1f8a5c449382d7157271203c03e76d09c
parent a184186
5 files changed +691 -50
modified radicle-httpd/src/api.rs
@@ -6,15 +6,15 @@ use axum::routing::get;
use axum::Router;
use serde_json::{json, Value};

+
use radicle::identity::crefs::GetCanonicalRefs;
use radicle::identity::doc::PayloadId;
use radicle::identity::{DocAt, RepoId};
use radicle::issue::cache::Issues as _;
use radicle::node::routing::Store;
-
use radicle::node::NodeId;
use radicle::patch::cache::Patches as _;
use radicle::storage::git::Repository;
use radicle::storage::{ReadRepository, ReadStorage};
-
use radicle::{web, Profile};
+
use radicle::{git, web, Profile};
use tokio::sync::RwLock;

mod error;
@@ -82,11 +82,7 @@ impl Context {
    }

    #[allow(clippy::result_large_err)]
-
    pub fn repo_info<R: ReadRepository + radicle::cob::Store<Namespace = NodeId>>(
-
        &self,
-
        repo: &R,
-
        doc: DocAt,
-
    ) -> Result<repo::Info, error::Error> {
+
    pub fn repo_info(&self, repo: &Repository, doc: DocAt) -> Result<repo::Info, error::Error> {
        let DocAt { doc, .. } = doc;
        let rid = repo.id();

@@ -127,6 +123,10 @@ impl Context {
            })
            .collect();

+
        let refs = canonical_refs(repo, &doc)
+
            .inspect_err(|e| tracing::warn!("failed to read canonical refs for {rid}: {e}"))
+
            .unwrap_or_default();
+

        Ok(repo::Info {
            payloads,
            delegates,
@@ -134,6 +134,7 @@ impl Context {
            visibility: doc.visibility().clone(),
            rid,
            seeding,
+
            refs,
        })
    }

@@ -163,6 +164,116 @@ impl Context {
    }
}

+
pub trait ReadCanonicalRefs {
+
    fn find_by_pattern(
+
        &self,
+
        pattern: &git::fmt::refspec::QualifiedPattern,
+
    ) -> Result<Vec<(git::fmt::RefString, git::Oid)>, error::Error>;
+
}
+

+
pub trait PeelToCommit {
+
    fn peel_to_commit(&self, oid: git::Oid) -> Result<git::Oid, git::raw::Error>;
+
}
+

+
pub trait ResolveTag {
+
    fn resolve_tag(&self, oid: git::Oid) -> Result<repo::Tag, git::raw::Error>;
+
}
+

+
impl ReadCanonicalRefs for Repository {
+
    fn find_by_pattern(
+
        &self,
+
        pattern: &git::fmt::refspec::QualifiedPattern,
+
    ) -> Result<Vec<(git::fmt::RefString, git::Oid)>, error::Error> {
+
        let mut refs = Vec::new();
+
        for r in self.backend.references_glob(pattern.as_str())? {
+
            let r = r?;
+
            let Some(refname) = r.name().and_then(|n| git::fmt::RefString::try_from(n).ok()) else {
+
                continue;
+
            };
+
            let Some(oid) = r.target().map(git::Oid::from) else {
+
                continue;
+
            };
+
            refs.push((refname, oid));
+
        }
+
        Ok(refs)
+
    }
+
}
+

+
impl PeelToCommit for Repository {
+
    fn peel_to_commit(&self, oid: git::Oid) -> Result<git::Oid, git::raw::Error> {
+
        let obj = self.backend.find_object(oid.into(), None)?;
+
        let commit = obj.peel_to_commit()?;
+
        Ok(commit.id().into())
+
    }
+
}
+

+
impl ResolveTag for Repository {
+
    fn resolve_tag(&self, oid: git::Oid) -> Result<repo::Tag, git::raw::Error> {
+
        // Annotated tag: carries tagger/message.
+
        if let Ok(tag) = self.backend.find_tag(oid.into()) {
+
            return Ok(repo::Tag {
+
                commit: tag.target_id().into(),
+
                tagger: tag.tagger().map(|t| repo::Tagger {
+
                    name: t.name().unwrap_or_default().to_owned(),
+
                    email: t.email().unwrap_or_default().to_owned(),
+
                    timestamp: t.when().seconds(),
+
                }),
+
                message: tag.message().map(str::to_owned),
+
            });
+
        }
+
        // Lightweight tag: ref points directly at a commit.
+
        let commit = self.backend.find_commit(oid.into())?;
+
        Ok(repo::Tag {
+
            commit: commit.id().into(),
+
            tagger: None,
+
            message: None,
+
        })
+
    }
+
}
+

+
#[allow(clippy::result_large_err)]
+
fn canonical_refs<R: ReadCanonicalRefs + PeelToCommit + ResolveTag>(
+
    repo: &R,
+
    doc: &radicle::identity::Doc,
+
) -> Result<repo::CanonicalReferences, error::Error> {
+
    let Some(crefs) = doc.canonical_refs()? else {
+
        return Ok(repo::CanonicalReferences::default());
+
    };
+
    canonical_refs_for_patterns(repo, crefs.rules().iter().map(|(p, _)| p.as_ref()))
+
}
+

+
#[allow(clippy::result_large_err)]
+
fn canonical_refs_for_patterns<'a, R, I>(
+
    repo: &R,
+
    patterns: I,
+
) -> Result<repo::CanonicalReferences, error::Error>
+
where
+
    R: ReadCanonicalRefs + PeelToCommit + ResolveTag,
+
    I: IntoIterator<Item = &'a git::fmt::refspec::QualifiedPattern<'static>>,
+
{
+
    let mut canonical = repo::CanonicalReferences::default();
+
    for pattern in patterns {
+
        for (refname, oid) in repo.find_by_pattern(pattern)? {
+
            if refname.as_str().starts_with("refs/tags/") {
+
                match repo.resolve_tag(oid) {
+
                    Ok(tag) => {
+
                        canonical.tags.insert(refname, tag);
+
                    }
+
                    Err(e) => tracing::warn!("skipping canonical tag {refname}: {e}"),
+
                }
+
            } else {
+
                match repo.peel_to_commit(oid) {
+
                    Ok(commit) => {
+
                        canonical.refs.insert(refname, commit);
+
                    }
+
                    Err(e) => tracing::warn!("skipping canonical ref {refname}: {e}"),
+
                }
+
            }
+
        }
+
    }
+
    Ok(canonical)
+
}
+

pub fn router(ctx: Context) -> Router {
    Router::new()
        .route("/", get(root_handler))
@@ -286,9 +397,36 @@ mod repo {
    use serde::Serialize;
    use serde_json::Value;

+
    use radicle::git::fmt::RefString;
+
    use radicle::git::Oid;
    use radicle::identity::doc::PayloadId;
    use radicle::identity::{RepoId, Visibility};

+
    #[derive(Default, Serialize)]
+
    #[serde(rename_all = "camelCase")]
+
    pub struct CanonicalReferences {
+
        pub tags: BTreeMap<RefString, Tag>,
+
        pub refs: BTreeMap<RefString, Oid>,
+
    }
+

+
    #[derive(Serialize)]
+
    #[serde(rename_all = "camelCase")]
+
    pub struct Tag {
+
        pub commit: Oid,
+
        #[serde(skip_serializing_if = "Option::is_none")]
+
        pub tagger: Option<Tagger>,
+
        #[serde(skip_serializing_if = "Option::is_none")]
+
        pub message: Option<String>,
+
    }
+

+
    #[derive(Serialize)]
+
    #[serde(rename_all = "camelCase")]
+
    pub struct Tagger {
+
        pub name: String,
+
        pub email: String,
+
        pub timestamp: i64,
+
    }
+

    /// Repos info.
    #[derive(Serialize)]
    #[serde(rename_all = "camelCase")]
@@ -299,6 +437,7 @@ mod repo {
        pub visibility: Visibility,
        pub rid: RepoId,
        pub seeding: usize,
+
        pub refs: CanonicalReferences,
    }
}

@@ -439,4 +578,242 @@ mod tests {
        }
        assert_eq!(ctx.web_config.read().await.pinned.repositories.len(), 0);
    }
+

+
    mod refs {
+
        use std::str::FromStr;
+

+
        use radicle::git::fmt::RefStr;
+
        use radicle::identity::RepoId;
+
        use radicle::storage::{ReadRepository, ReadStorage};
+

+
        use crate::test;
+

+
        fn r(s: &str) -> &RefStr {
+
            RefStr::try_from_str(s).unwrap()
+
        }
+

+
        #[test]
+
        fn test_canonical_refs_empty_without_config() {
+
            let tmp = tempfile::tempdir().unwrap();
+
            let ctx = test::seed(tmp.path());
+
            let rid = RepoId::from_str(test::RID).unwrap();
+

+
            let repo = ctx.profile.storage.repository(rid).unwrap();
+
            let doc = repo.identity_doc().unwrap();
+

+
            let refs = super::super::canonical_refs(&repo, &doc.doc).unwrap();
+
            assert!(refs.tags.is_empty());
+
            assert!(refs.refs.is_empty());
+
        }
+

+
        #[test]
+
        fn test_repo_info_includes_refs() {
+
            let tmp = tempfile::tempdir().unwrap();
+
            let ctx = test::seed(tmp.path());
+
            let rid = RepoId::from_str(test::RID).unwrap();
+

+
            let (repo, doc) = ctx.repo(rid).unwrap();
+
            let info = ctx.repo_info(&repo, doc).unwrap();
+

+
            assert!(info.refs.tags.is_empty());
+
            assert!(info.refs.refs.is_empty());
+
        }
+

+
        #[test]
+
        fn test_multi_peer_canonical_refs() {
+
            let tmp = tempfile::tempdir().unwrap();
+
            let ctx = test::seed_multi_peer(tmp.path());
+
            let rid = RepoId::from_str(test::RID).unwrap();
+

+
            let repo = ctx.profile.storage.repository(rid).unwrap();
+
            let doc = repo.identity_doc().unwrap();
+

+
            let refs = super::super::canonical_refs(&repo, &doc.doc).unwrap();
+

+
            assert!(refs.refs.contains_key(r("refs/heads/master")));
+
            assert!(refs.tags.contains_key(r("refs/tags/v1.0")));
+
            assert!(refs.refs.contains_key(r("refs/heads/feature/branch")));
+
            assert!(!refs.tags.contains_key(r("refs/tags/v2.0-rc")));
+
            // Fixture uses a lightweight tag (ref → commit, no tag object).
+
            let tag = refs.tags.get(r("refs/tags/v1.0")).unwrap();
+
            assert!(tag.tagger.is_none());
+
            assert!(tag.message.is_none());
+
        }
+

+
        mod mock {
+
            use std::collections::HashMap;
+
            use std::str::FromStr;
+

+
            use radicle::git;
+
            use radicle::git::fmt::refspec::{PatternString, QualifiedPattern};
+
            use radicle::git::fmt::RefString;
+

+
            use crate::api::error;
+
            use crate::api::{
+
                canonical_refs_for_patterns, repo, PeelToCommit, ReadCanonicalRefs, ResolveTag,
+
            };
+

+
            struct StubRepo {
+
                refs: HashMap<String, Vec<(RefString, git::Oid)>>,
+
                peel_errors: HashMap<git::Oid, String>,
+
                annotated_tags: HashMap<git::Oid, (String, String, i64, String)>,
+
            }
+

+
            impl ReadCanonicalRefs for StubRepo {
+
                fn find_by_pattern(
+
                    &self,
+
                    pattern: &QualifiedPattern,
+
                ) -> Result<Vec<(RefString, git::Oid)>, error::Error> {
+
                    Ok(self.refs.get(pattern.as_str()).cloned().unwrap_or_default())
+
                }
+
            }
+

+
            impl PeelToCommit for StubRepo {
+
                fn peel_to_commit(&self, oid: git::Oid) -> Result<git::Oid, git::raw::Error> {
+
                    if let Some(msg) = self.peel_errors.get(&oid) {
+
                        Err(git::raw::Error::from_str(msg))
+
                    } else {
+
                        Ok(oid)
+
                    }
+
                }
+
            }
+

+
            impl ResolveTag for StubRepo {
+
                fn resolve_tag(&self, oid: git::Oid) -> Result<repo::Tag, git::raw::Error> {
+
                    if let Some(msg) = self.peel_errors.get(&oid) {
+
                        return Err(git::raw::Error::from_str(msg));
+
                    }
+
                    let (tagger, message) = self
+
                        .annotated_tags
+
                        .get(&oid)
+
                        .map(|(name, email, ts, msg)| {
+
                            (
+
                                Some(repo::Tagger {
+
                                    name: name.clone(),
+
                                    email: email.clone(),
+
                                    timestamp: *ts,
+
                                }),
+
                                Some(msg.clone()),
+
                            )
+
                        })
+
                        .unwrap_or((None, None));
+
                    Ok(repo::Tag {
+
                        commit: oid,
+
                        tagger,
+
                        message,
+
                    })
+
                }
+
            }
+

+
            fn pat(s: &str) -> QualifiedPattern<'static> {
+
                QualifiedPattern::from_patternstr(&PatternString::try_from(s).unwrap())
+
                    .unwrap()
+
                    .to_owned()
+
            }
+

+
            fn oid(hex: &str) -> git::Oid {
+
                git::Oid::from_str(hex).unwrap()
+
            }
+

+
            fn refname(s: &str) -> RefString {
+
                RefString::try_from(s).unwrap()
+
            }
+

+
            #[test]
+
            fn classifies_tags_and_non_tags() {
+
                let head = oid("1111111111111111111111111111111111111111");
+
                let tag = oid("2222222222222222222222222222222222222222");
+
                let heads = pat("refs/heads/*");
+
                let tags = pat("refs/tags/*");
+
                let stub = StubRepo {
+
                    refs: HashMap::from([
+
                        (
+
                            heads.as_str().to_owned(),
+
                            vec![(refname("refs/heads/main"), head)],
+
                        ),
+
                        (
+
                            tags.as_str().to_owned(),
+
                            vec![(refname("refs/tags/v1"), tag)],
+
                        ),
+
                    ]),
+
                    peel_errors: HashMap::new(),
+
                    annotated_tags: HashMap::new(),
+
                };
+

+
                let result = canonical_refs_for_patterns(&stub, [&heads, &tags]).unwrap();
+
                assert_eq!(result.refs.get(&refname("refs/heads/main")), Some(&head));
+
                assert_eq!(
+
                    result.tags.get(&refname("refs/tags/v1")).map(|t| t.commit),
+
                    Some(tag),
+
                );
+
                assert!(!result.tags.contains_key(&refname("refs/heads/main")));
+
                assert!(!result.refs.contains_key(&refname("refs/tags/v1")));
+
            }
+

+
            #[test]
+
            fn annotated_tag_metadata_is_included() {
+
                let tag = oid("3333333333333333333333333333333333333333");
+
                let pattern = pat("refs/tags/*");
+
                let stub = StubRepo {
+
                    refs: HashMap::from([(
+
                        pattern.as_str().to_owned(),
+
                        vec![(refname("refs/tags/v1"), tag)],
+
                    )]),
+
                    peel_errors: HashMap::new(),
+
                    annotated_tags: HashMap::from([(
+
                        tag,
+
                        (
+
                            "Alice".to_owned(),
+
                            "alice@example.com".to_owned(),
+
                            1700000000i64,
+
                            "Release v1".to_owned(),
+
                        ),
+
                    )]),
+
                };
+

+
                let result = canonical_refs_for_patterns(&stub, [&pattern]).unwrap();
+
                let resolved = result.tags.get(&refname("refs/tags/v1")).unwrap();
+
                assert_eq!(resolved.message.as_deref(), Some("Release v1"));
+
                let tagger = resolved.tagger.as_ref().unwrap();
+
                assert_eq!(tagger.name, "Alice");
+
                assert_eq!(tagger.email, "alice@example.com");
+
                assert_eq!(tagger.timestamp, 1700000000);
+
            }
+

+
            #[test]
+
            fn skips_refs_whose_peel_fails() {
+
                let good = oid("1111111111111111111111111111111111111111");
+
                let bad = oid("2222222222222222222222222222222222222222");
+
                let heads = pat("refs/heads/*");
+
                let stub = StubRepo {
+
                    refs: HashMap::from([(
+
                        heads.as_str().to_owned(),
+
                        vec![
+
                            (refname("refs/heads/ok"), good),
+
                            (refname("refs/heads/broken"), bad),
+
                        ],
+
                    )]),
+
                    peel_errors: HashMap::from([(bad, "missing".to_owned())]),
+
                    annotated_tags: HashMap::new(),
+
                };
+

+
                let result = canonical_refs_for_patterns(&stub, [&heads]).unwrap();
+
                assert_eq!(result.refs.get(&refname("refs/heads/ok")), Some(&good));
+
                assert!(!result.refs.contains_key(&refname("refs/heads/broken")));
+
            }
+

+
            #[test]
+
            fn empty_patterns_yield_empty_result() {
+
                let stub = StubRepo {
+
                    refs: HashMap::new(),
+
                    peel_errors: HashMap::new(),
+
                    annotated_tags: HashMap::new(),
+
                };
+
                let result: Vec<&QualifiedPattern<'static>> = vec![];
+
                let result = canonical_refs_for_patterns(&stub, result).unwrap();
+
                assert!(result.refs.is_empty());
+
                assert!(result.tags.is_empty());
+
            }
+
        }
+
    }
}
modified radicle-httpd/src/api/error.rs
@@ -84,6 +84,10 @@ pub enum Error {
    #[error(transparent)]
    IdentityDoc(#[from] radicle::identity::doc::DocError),

+
    /// Canonical refs error.
+
    #[error(transparent)]
+
    CanonicalRefs(#[from] radicle::identity::doc::CanonicalRefsError),
+

    /// Tracking store error.
    #[error(transparent)]
    TrackingStore(#[from] radicle::node::policy::store::Error),
modified radicle-httpd/src/api/v1/delegates.rs
@@ -141,6 +141,7 @@ mod routes {
                },
                "rid": RID,
                "seeding": 1,
+
                "refs": { "tags": {}, "refs": {} }
              },
              {
                "payloads": {
@@ -177,6 +178,7 @@ mod routes {
                },
                "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
                "seeding": 1,
+
                "refs": { "tags": {}, "refs": {} }
              }
            ])
        );
@@ -235,6 +237,7 @@ mod routes {
                },
                "rid": RID,
                "seeding": 1,
+
                "refs": { "tags": {}, "refs": {} }
              },
              {
                "payloads": {
@@ -271,6 +274,7 @@ mod routes {
                },
                "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
                "seeding": 1,
+
                "refs": { "tags": {}, "refs": {} }
              }
            ])
        );
modified radicle-httpd/src/api/v1/repos.rs
@@ -9,13 +9,14 @@ use axum::routing::get;
use axum::{Json, Router};
use hyper::StatusCode;
use radicle_surf::blob::BlobRef;
+
use radicle_surf::ref_format::{Qualified, RefString};
use radicle_surf::{diff, Glob, Oid, Repository};
use serde::{Deserialize, Serialize};
use serde_json::json;

use radicle::cob::{issue::cache::Issues as _, patch::cache::Patches as _};
use radicle::identity::RepoId;
-
use radicle::node::{AliasStore, NodeId};
+
use radicle::node::{Alias, AliasStore, NodeId};
use radicle::storage::{ReadRepository, ReadStorage, RemoteRepository};

use crate::api;
@@ -23,6 +24,7 @@ use crate::api::error::Error;
use crate::api::query::{CobsQuery, PaginationQuery, RepoQuery};
use crate::api::search::{SearchQueryString, SearchResult};
use crate::api::Context;
+
use crate::api::PeelToCommit;
use crate::axum_extra::{cached_response, immutable_response, Path, Query};

const MAX_BODY_LIMIT: usize = 4_194_304;
@@ -448,35 +450,11 @@ async fn remotes_handler(State(ctx): State<Context>, Path(rid): Path<RepoId>) ->
    let (repo, doc) = ctx.repo(rid)?;
    let delegates = doc.delegates();
    let aliases = &ctx.profile.aliases();
+

    let remotes = repo
        .remotes()?
        .filter_map(|r| r.map(|r| r.1).ok())
-
        .map(|remote| {
-
            let refs = remote
-
                .refs
-
                .iter()
-
                .filter_map(|(r, oid)| {
-
                    r.as_str().strip_prefix("refs/heads/").map(|head| {
-
                        let surf_oid = Oid::from(radicle::git::raw::Oid::from(oid));
-
                        (head.to_string(), surf_oid)
-
                    })
-
                })
-
                .collect::<BTreeMap<String, Oid>>();
-

-
            match aliases.alias(&remote.id) {
-
                Some(alias) => json!({
-
                    "id": remote.id,
-
                    "alias": alias,
-
                    "heads": refs,
-
                    "delegate": delegates.contains(&remote.id.into()),
-
                }),
-
                None => json!({
-
                    "id": remote.id,
-
                    "heads": refs,
-
                    "delegate": delegates.contains(&remote.id.into()),
-
                }),
-
            }
-
        })
+
        .map(|remote| remote_info(&repo, &remote, delegates, aliases))
        .collect::<Vec<_>>();

    Ok::<_, Error>(Json(remotes))
@@ -490,24 +468,130 @@ async fn remote_handler(
) -> impl IntoResponse {
    let (repo, doc) = ctx.repo(rid)?;
    let delegates = doc.delegates();
+
    let aliases = &ctx.profile.aliases();
    let remote = repo.remote(&node_id)?;
-
    let refs = remote
-
        .refs
-
        .iter()
-
        .filter_map(|(r, oid)| {
-
            r.as_str().strip_prefix("refs/heads/").map(|head| {
-
                let surf_oid = Oid::from(radicle::git::raw::Oid::from(oid));
-
                (head.to_string(), surf_oid)
-
            })
+

+
    Ok::<_, Error>(Json(remote_info(&repo, &remote, delegates, aliases)))
+
}
+

+
/// Information tracked per remote peer in Radicle storage.
+
#[derive(Serialize)]
+
#[serde(rename_all = "camelCase")]
+
struct RemoteInfo {
+
    /// The [`NodeId`] associated with the remote.
+
    id: NodeId,
+
    /// The [`Alias`] of the remote, if it can be found.
+
    #[serde(skip_serializing_if = "Option::is_none")]
+
    alias: Option<Alias>,
+
    /// Any references under the remote's namespace that begin with
+
    /// `refs/heads`, returning the suffix after `refs/heads`.
+
    heads: BTreeMap<RefString, radicle::git::Oid>,
+
    /// All references under the remote's namespace.
+
    refs: BTreeMap<Qualified<'static>, radicle::git::Oid>,
+
    /// Whether the remote is a delegate of the repository.
+
    delegate: bool,
+
}
+

+
impl RemoteInfo {
+
    pub fn new(id: NodeId) -> Self {
+
        Self {
+
            id,
+
            alias: None,
+
            heads: BTreeMap::new(),
+
            refs: BTreeMap::new(),
+
            delegate: false,
+
        }
+
    }
+

+
    pub fn with_alias(mut self, alias: Option<Alias>) -> Self {
+
        self.alias = alias;
+
        self
+
    }
+

+
    pub fn with_heads(mut self, heads: BTreeMap<RefString, radicle::git::Oid>) -> Self {
+
        self.heads = heads;
+
        self
+
    }
+

+
    pub fn with_refs(mut self, refs: BTreeMap<Qualified<'static>, radicle::git::Oid>) -> Self {
+
        self.refs = refs;
+
        self
+
    }
+

+
    pub fn set_delegate(mut self, delegate: bool) -> Self {
+
        self.delegate = delegate;
+
        self
+
    }
+
}
+

+
/// Partition [`Refs`] into their `refs/heads` and all sets of references.
+
///
+
/// References are skipped if they:
+
/// - Are not [`Qualified`],
+
/// - Cannot be peeled to a commit,
+
/// - Are not under `refs/heads` or `refs/tags`.
+
///
+
/// [`Refs`]: radicle::storage::refs::Refs
+
fn partition_refs<R>(
+
    refs: &radicle::storage::refs::Refs,
+
    repository: &R,
+
) -> (
+
    BTreeMap<RefString, radicle::git::Oid>,
+
    BTreeMap<Qualified<'static>, radicle::git::Oid>,
+
)
+
where
+
    R: PeelToCommit,
+
{
+
    refs.iter()
+
        .filter_map(|(refname, oid)| {
+
            let oid = match repository.peel_to_commit(*oid) {
+
                Ok(oid) => Some(oid),
+
                Err(e) => {
+
                    tracing::warn!("skipping {refname}: {e}");
+
                    None
+
                }
+
            };
+
            match refname.qualified() {
+
                Some(refname) => Some(refname).zip(oid),
+
                None => {
+
                    tracing::debug!("skipping '{refname}' since it is not qualified");
+
                    None
+
                }
+
            }
        })
-
        .collect::<BTreeMap<String, Oid>>();
-
    let remote = json!({
-
        "id": remote.id,
-
        "heads": refs,
-
        "delegate": delegates.contains(&remote.id.into()),
-
    });
+
        .fold(
+
            (BTreeMap::new(), BTreeMap::new()),
+
            |(mut heads, mut refs), (qualified, oid)| {
+
                let (_refs, category, first, rest) = qualified.non_empty_components();
+
                match category.as_str() {
+
                    "heads" => {
+
                        let name = std::iter::once(first).chain(rest).collect::<RefString>();
+
                        heads.insert(name, oid);
+
                        refs.insert(qualified.to_owned(), oid);
+
                    }
+
                    "tags" => {
+
                        refs.insert(qualified.to_owned(), oid);
+
                    }
+
                    _ => {}
+
                }
+
                (heads, refs)
+
            },
+
        )
+
}

-
    Ok::<_, Error>(Json(remote))
+
#[tracing::instrument(skip_all, fields(remote.id = %remote.id))]
+
fn remote_info(
+
    repo: &radicle::storage::git::Repository,
+
    remote: &radicle::storage::Remote,
+
    delegates: &radicle::identity::doc::Delegates,
+
    aliases: &radicle::profile::Aliases,
+
) -> RemoteInfo {
+
    let (heads, refs) = partition_refs(&remote.refs, repo);
+
    RemoteInfo::new(remote.id)
+
        .with_heads(heads)
+
        .with_refs(refs)
+
        .with_alias(aliases.alias(&remote.id))
+
        .set_delegate(delegates.contains(&remote.id.into()))
}

/// Get repo source file.
@@ -749,6 +833,7 @@ mod routes {
                },
                "rid": RID,
                "seeding": 1,
+
                "refs": { "tags": {}, "refs": {} }
              },
              {
                "payloads": {
@@ -785,6 +870,7 @@ mod routes {
                },
                "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
                "seeding": 1,
+
                "refs": { "tags": {}, "refs": {} }
              },
            ])
        );
@@ -834,6 +920,7 @@ mod routes {
                },
                "rid": RID,
                "seeding": 1,
+
                "refs": { "tags": {}, "refs": {} }
              },
              {
                "payloads": {
@@ -870,6 +957,7 @@ mod routes {
                },
                "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
                "seeding": 1,
+
                "refs": { "tags": {}, "refs": {} }
              },
            ])
        );
@@ -919,6 +1007,7 @@ mod routes {
               },
               "rid": RID,
               "seeding": 1,
+
               "refs": { "tags": {}, "refs": {} }
            })
        );
    }
@@ -1389,6 +1478,9 @@ mod routes {
                "heads": {
                  "master": HEAD
                },
+
                "refs": {
+
                  "refs/heads/master": HEAD
+
                },
                "delegate": true
              }
            ])
@@ -1410,9 +1502,13 @@ mod routes {
            response.json().await,
            json!({
                "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
                "alias": CONTRIBUTOR_ALIAS,
                "heads": {
                    "master": HEAD
                },
+
                "refs": {
+
                    "refs/heads/master": HEAD
+
                },
                "delegate": true
            })
        );
@@ -1432,6 +1528,25 @@ mod routes {
    }

    #[tokio::test]
+
    async fn test_repos_multi_peer_canonical_refs() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = seed_multi_peer(tmp.path());
+
        let app =
+
            super::router(ctx).layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))));
+
        let response = get(&app, format!("/repos/{RID}")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+

+
        let body = response.json().await;
+
        let refs = &body["refs"];
+

+
        assert_eq!(refs["refs"]["refs/heads/master"], json!(HEAD));
+
        assert_eq!(refs["tags"]["refs/tags/v1.0"]["commit"], json!(HEAD));
+
        assert!(refs["refs"]["refs/heads/feature/branch"].is_string());
+
        assert!(refs["tags"].get("refs/tags/v2.0-rc").is_none());
+
    }
+

+
    #[tokio::test]
    async fn test_repos_blob() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
modified radicle-httpd/src/test.rs
@@ -21,7 +21,9 @@ use radicle::identity::{project, Visibility};
use radicle::node::device::Device;
use radicle::node::{Features, Timestamp, UserAgent};
use radicle::profile::{env, Home};
-
use radicle::storage::ReadStorage;
+
use radicle::storage::{
+
    ReadRepository, ReadStorage, SignRepository, WriteRepository, WriteStorage,
+
};
use radicle::{node, profile};
use radicle::{Node, Storage};

@@ -338,6 +340,145 @@ fn seed_with_signer<G: Signer<Signature>>(
    Context::new(Arc::new(profile), web_config, &options)
}

+
/// Create a test context with multiple peers and canonical refs configured.
+
///
+
/// This sets up:
+
/// - The hello-world repo from `seed()` as the base
+
/// - A second peer that forks the repo (same `master` head)
+
/// - An additional branch (`feature/branch`) on the second peer only
+
/// - A tag (`v1.0`) on both peers (reaches canonical consensus)
+
/// - A tag (`v2.0-rc`) on the second peer only (does not reach consensus)
+
/// - The identity document is updated with:
+
///   - The second peer added as a delegate
+
///   - A `xyz.radicle.crefs` payload with rules for `refs/heads/*` and `refs/tags/*`
+
pub fn seed_multi_peer(dir: &Path) -> Context {
+
    use radicle::identity::doc::PayloadId;
+
    use radicle::identity::Identity;
+

+
    let ctx = seed(dir);
+

+
    let signer1 = Device::mock_from_seed([0xff; 32]);
+
    let signer2 = Device::mock_from_seed([0xee; 32]);
+

+
    let rid = radicle::identity::RepoId::from_str(RID).unwrap();
+
    let storage = &ctx.profile().storage;
+

+
    {
+
        let mut policies = ctx.profile().policies_mut().unwrap();
+
        policies
+
            .follow(signer2.public_key(), Some(&node::Alias::new("peer2")))
+
            .unwrap();
+
    }
+

+
    {
+
        let repo = storage.repository_mut(rid).unwrap();
+
        let mut identity = Identity::load_mut(&repo).unwrap();
+
        let current_doc = repo.identity_doc().unwrap();
+

+
        let new_doc = current_doc
+
            .doc
+
            .clone()
+
            .with_edits(|raw| {
+
                let did2: radicle::identity::Did = (*signer2.public_key()).into();
+
                if !raw.delegates.contains(&did2) {
+
                    raw.delegates.push(did2);
+
                }
+
                raw.threshold = 1;
+
                let crefs_payload: serde_json::Value = serde_json::json!({
+
                    "rules": {
+
                        "refs/heads/*": {
+
                            "allow": "delegates",
+
                            "threshold": 1
+
                        },
+
                        "refs/tags/*": {
+
                            "allow": "delegates",
+
                            "threshold": 2
+
                        }
+
                    }
+
                });
+
                raw.payload.insert(
+
                    PayloadId::canonical_refs(),
+
                    radicle::identity::doc::Payload::from(crefs_payload),
+
                );
+
            })
+
            .unwrap();
+

+
        identity
+
            .update(
+
                Title::new("Add second delegate and crefs").unwrap(),
+
                "",
+
                &new_doc,
+
                &signer1,
+
            )
+
            .unwrap();
+

+
        let new_head = repo.identity_head_of(signer1.public_key()).unwrap();
+
        repo.set_identity_head_to(new_head).unwrap();
+

+
        repo.sign_refs(&signer1).unwrap();
+
    }
+

+
    radicle::rad::fork(rid, &signer2, storage).unwrap();
+

+
    {
+
        let repo = storage.repository_mut(rid).unwrap();
+
        let raw = repo.raw();
+

+
        let head_oid = radicle::git::raw::Oid::from_str(HEAD).unwrap();
+
        let parent_oid = radicle::git::raw::Oid::from_str(PARENT).unwrap();
+

+
        let pk1 = signer1.public_key();
+
        let pk2 = signer2.public_key();
+

+
        raw.reference(
+
            &format!("refs/namespaces/{pk1}/refs/tags/v1.0"),
+
            head_oid,
+
            true,
+
            "test: add tag v1.0 for peer1",
+
        )
+
        .unwrap();
+
        raw.reference(
+
            &format!("refs/namespaces/{pk2}/refs/tags/v1.0"),
+
            head_oid,
+
            true,
+
            "test: add tag v1.0 for peer2",
+
        )
+
        .unwrap();
+

+
        raw.reference(
+
            &format!("refs/namespaces/{pk2}/refs/tags/v2.0-rc"),
+
            parent_oid,
+
            true,
+
            "test: add tag v2.0-rc for peer2 only",
+
        )
+
        .unwrap();
+

+
        raw.reference(
+
            &format!("refs/namespaces/{pk2}/refs/heads/feature/branch"),
+
            parent_oid,
+
            true,
+
            "test: add feature/branch for peer2",
+
        )
+
        .unwrap();
+

+
        repo.sign_refs(&signer1).unwrap();
+
        repo.sign_refs(&signer2).unwrap();
+

+
        // Materialize canonical refs at the top level, simulating what the
+
        // node does via `set_canonical_refs` after a fetch.
+
        for (refname, oid) in [
+
            ("refs/heads/master", head_oid),
+
            ("refs/tags/v1.0", head_oid),
+
            ("refs/heads/feature/branch", parent_oid),
+
        ] {
+
            raw.reference(refname, oid, true, "test: set canonical ref")
+
                .unwrap();
+
        }
+
    }
+

+
    ctx
+
}
+

pub async fn get(app: &Router, path: impl ToString) -> Response {
    Response(
        app.clone()