Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Implement canonical references in explorer
Merged rudolfs opened 2 months ago
20 files changed +1323 -133 114b2532 ecbea99f
modified http-client/index.ts
@@ -3,9 +3,11 @@ import type {
  Blob,
  DiffResponse,
  Job,
+
  PeerRefs,
  Remote,
  Repo,
  RepoListQuery,
+
  TagInfo,
  Tree,
  TreeStats,
} from "./lib/repo.js";
@@ -77,6 +79,7 @@ export type {
  Merge,
  Patch,
  PatchState,
+
  PeerRefs,
  Reaction,
  Remote,
  Repo,
@@ -84,6 +87,7 @@ export type {
  Review,
  Revision,
  SeedingPolicy,
+
  TagInfo,
  Tree,
  TreeStats,
  Verdict,
modified http-client/lib/repo.ts
@@ -1,4 +1,3 @@
-
import type { ZodSchema } from "zod";
import type { Fetcher, RequestOptions } from "./fetcher.js";
import type { Commit, Commits } from "./repo/commit.js";
import type { Issue } from "./repo/issue.js";
@@ -15,6 +14,7 @@ import {
  string,
  union,
  z,
+
  ZodSchema,
} from "zod";

import {
@@ -28,6 +28,25 @@ import { issueSchema, issuesSchema } from "./repo/issue.js";
import { patchSchema, patchesSchema } from "./repo/patch.js";
import { authorSchema } from "./shared.js";

+
export type PeerRefs = {
+
  id: string;
+
  alias?: string;
+
  delegate: boolean;
+
  refs: Record<string, string>;
+
};
+

+
const tagInfoSchema = object({
+
  commit: string(),
+
  tagger: object({
+
    name: string(),
+
    email: string(),
+
    timestamp: number(),
+
  }).optional(),
+
  message: string().optional(),
+
});
+

+
export type TagInfo = z.infer<typeof tagInfoSchema>;
+

const repoSchema = object({
  rid: string(),
  payloads: object({
@@ -59,6 +78,10 @@ const repoSchema = object({
    object({ type: literal("private"), allow: optional(array(string())) }),
  ]),
  seeding: number(),
+
  refs: object({
+
    tags: record(string(), tagInfoSchema),
+
    refs: record(string(), string()),
+
  }).optional(),
});
const reposSchema = array(repoSchema);

@@ -106,16 +129,17 @@ const treeSchema = object({
  path: string(),
});

-
export type Remote = z.infer<typeof remoteSchema>;
-

-
export const remoteSchema = object({
+
const remoteSchema = object({
  id: string(),
  alias: string().optional(),
-
  heads: record(string(), string()),
  delegate: boolean(),
+
  heads: record(string(), string()),
+
  refs: record(string(), string()).optional(),
});

-
const remotesSchema = array(remoteSchema) satisfies ZodSchema<Remote[]>;
+
export type Remote = z.infer<typeof remoteSchema>;
+

+
const remotesSchema = array(remoteSchema);

export type DiffResponse = z.infer<typeof diffResponseSchema>;

modified http-client/tests/repo.test.ts
@@ -52,10 +52,6 @@ describe("repo", () => {
    await api.repo.getTree(sourceBrowsingRid, aliceMainHead, "src");
  });

-
  test("#getAllRemotes(rid)", async () => {
-
    await api.repo.getAllRemotes(sourceBrowsingRid);
-
  });
-

  test("#getRemoteByPeer(rid, peer)", async () => {
    await api.repo.getRemoteByPeer(sourceBrowsingRid, aliceRemote.substring(8));
  });
modified package.json
@@ -14,6 +14,7 @@
    "test:e2e": "NODE_CONFIG_ENV='test' TZ='UTC' playwright test",
    "test:e2e:local": "scripts/compile-local-httpd && USE_LOCAL_HTTPD=true NODE_CONFIG_ENV='test' TZ='UTC' playwright test",
    "test:http-client:unit": "NODE_CONFIG_ENV='test' TZ='UTC' vitest run --config http-client/vite.config.ts --reporter verbose",
+
    "test:http-client:unit:local": "scripts/compile-local-httpd && USE_LOCAL_HTTPD=true NODE_CONFIG_ENV='test' TZ='UTC' vitest run --config http-client/vite.config.ts --reporter verbose",
    "test:radicle-httpd": "cd radicle-httpd && cargo test --all-features",
    "deploy": "rimraf build && npm clean-install && npm run build && scripts/inject-plausible && npx wrangler deploy",
    "deploy:open-graph": "cd workers/open-graph && npx wrangler deploy",
modified public/typography.css
@@ -73,11 +73,13 @@
}

[data-codefont="system"] {
+
  --txt-code-small: 400 0.75rem/1rem monospace;
  --txt-code-regular: 400 0.875rem/1.25rem monospace;
  --txt-code-semibold: 600 0.875rem/1.25rem monospace;
}

[data-codefont="jetbrains"] {
+
  --txt-code-small: 400 0.75rem/1rem "JetBrains Mono";
  --txt-code-regular: 400 0.875rem/1.25rem "JetBrains Mono";
  --txt-code-semibold: 600 0.875rem/1.25rem "JetBrains Mono";
}
@@ -158,6 +160,10 @@ html {
  font: var(--txt-body-l-semibold);
}

+
.txt-code-small {
+
  font: var(--txt-code-small);
+
}
+

.txt-code-regular {
  font: var(--txt-code-regular);
}
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()
modified src/components/TextInput.svelte
@@ -11,6 +11,8 @@
  export let placeholder: string | undefined = undefined;
  export let value: string | undefined = undefined;

+
  export let size: "small" | "regular" = "regular";
+

  export let autofocus: boolean = false;
  export let autoselect: boolean = false;
  export let disabled: boolean = false;
@@ -76,9 +78,14 @@
    position: relative;
    flex: 1;
    align-items: center;
-
    height: var(--button-regular-height);
    background: var(--color-surface-base);
  }
+
  .wrapper.small {
+
    height: var(--button-small-height);
+
  }
+
  .wrapper.regular {
+
    height: var(--button-regular-height);
+
  }
  input {
    background: var(--color-surface-base);
    font-family: inherit;
@@ -139,7 +146,7 @@
  }
</style>

-
<div class="wrapper">
+
<div class="wrapper {size}">
  <input
    class:invalid={!valid && value}
    style:padding-right={rightContainerWidth
modified src/lib/utils.ts
@@ -266,3 +266,27 @@ export function formatQualifiedRefname(
): string {
  return peer ? `refs/namespaces/${peer}/refs/heads/${refname}` : refname;
}
+

+
export function getBranchesFromRefs(
+
  refs: Record<string, string>,
+
): Record<string, string> {
+
  const branches: Record<string, string> = {};
+
  for (const [name, oid] of Object.entries(refs)) {
+
    if (name.startsWith("refs/heads/")) {
+
      branches[name.slice("refs/heads/".length)] = oid;
+
    }
+
  }
+
  return branches;
+
}
+

+
export function getTagsFromRefs(
+
  refs: Record<string, string>,
+
): Record<string, string> {
+
  const tags: Record<string, string> = {};
+
  for (const [name, oid] of Object.entries(refs)) {
+
    if (name.startsWith("refs/tags/")) {
+
      tags[name.slice("refs/tags/".length)] = oid;
+
    }
+
  }
+
  return tags;
+
}
modified src/views/repos/History.svelte
@@ -2,8 +2,8 @@
  import type {
    BaseUrl,
    CommitHeader,
+
    PeerRefs,
    Repo,
-
    Remote,
    SeedingPolicy,
    Tree,
  } from "@http-client";
@@ -31,7 +31,7 @@
  export let commit: string;
  export let commitHeaders: CommitHeader[];
  export let peer: string | undefined;
-
  export let peers: Remote[];
+
  export let peers: PeerRefs[];
  export let repo: Repo;
  export let revision: string | undefined;
  export let tree: Tree;
modified src/views/repos/Source.svelte
@@ -1,8 +1,8 @@
<script lang="ts">
  import type {
    BaseUrl,
+
    PeerRefs,
    Repo,
-
    Remote,
    SeedingPolicy,
    Tree,
  } from "@http-client";
@@ -28,7 +28,7 @@
  export let commit: string;
  export let path: string;
  export let peer: string | undefined;
-
  export let peers: Remote[];
+
  export let peers: PeerRefs[];
  export let repo: Repo;
  export let rawPath: (commit?: string) => string;
  export let revision: string | undefined;
modified src/views/repos/Source/Header.svelte
@@ -5,7 +5,7 @@

<script lang="ts">
  import type { RepoRoute } from "../router";
-
  import type { BaseUrl, Repo, Remote, Tree } from "@http-client";
+
  import type { BaseUrl, PeerRefs, Repo, Tree } from "@http-client";
  import type { ComponentProps } from "svelte";

  import { HttpdClient } from "@http-client";
@@ -23,7 +23,7 @@
  export let historyLinkActive: boolean;
  export let node: BaseUrl;
  export let peer: string | undefined;
-
  export let peers: Remote[];
+
  export let peers: PeerRefs[];
  export let repo: Repo;
  export let baseRoute: Extract<
    RepoRoute,
modified src/views/repos/Source/PeerBranchSelector.svelte
@@ -1,14 +1,23 @@
<script lang="ts">
  import type { RepoRoute } from "@app/views/repos/router";
-
  import type { Repo, Remote } from "@http-client";
+
  import type { Repo, PeerRefs } from "@http-client";

  import fuzzysort from "fuzzysort";
  import orderBy from "lodash/orderBy";
-
  import { formatCommit, formatNodeId } from "@app/lib/utils";
+
  import {
+
    absoluteTimestamp,
+
    formatCommit,
+
    formatNodeId,
+
    formatTimestamp,
+
    getBranchesFromRefs,
+
    getTagsFromRefs,
+
    gravatarURL,
+
  } from "@app/lib/utils";

  import Badge from "@app/components/Badge.svelte";
  import Button from "@app/components/Button.svelte";
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
+
  import HoverPopover from "@app/components/HoverPopover.svelte";
  import Icon from "@app/components/Icon.svelte";
  import Link from "@app/components/Link.svelte";
  import Peer from "./PeerBranchSelector/Peer.svelte";
@@ -22,7 +31,7 @@
  >;
  export let onCanonical: boolean;
  export let peer: string | undefined;
-
  export let peers: Remote[];
+
  export let peers: PeerRefs[];
  export let repo: Repo;
  export let selectedBranch: string | undefined;

@@ -33,22 +42,72 @@
    "</span>",
  ];
  let searchInput = "";
+
  let selectedTab: "branches" | "tags" = "branches";

-
  const searchElements = [
+
  type SearchElement = {
+
    peer?: { id: string; alias?: string; delegate: boolean };
+
    revision: string;
+
    head: string;
+
    type: "branch" | "tag";
+
  };
+

+
  $: canonicalBranchesMap = getBranchesFromRefs(repo.refs?.refs ?? {});
+
  $: canonicalTagsInfo = Object.fromEntries(
+
    Object.entries(repo.refs?.tags ?? {}).map(([name, info]) => [
+
      name.slice("refs/tags/".length),
+
      info,
+
    ]),
+
  );
+

+
  $: branchElements = [
    {
      peer: undefined,
      revision: repo.payloads["xyz.radicle.project"].data.defaultBranch,
      head: repo.payloads["xyz.radicle.project"].meta.head,
+
      type: "branch",
    },
-
    ...peers.flatMap(peer =>
-
      Object.entries(peer.heads).map(([name, head]) => ({
-
        peer: { id: peer.id, alias: peer.alias, delegate: peer.delegate },
+
    ...Object.entries(canonicalBranchesMap)
+
      .filter(
+
        ([branchName]) =>
+
          branchName !==
+
          repo.payloads["xyz.radicle.project"].data.defaultBranch,
+
      )
+
      .map(([name, head]) => ({
+
        peer: undefined,
        revision: name,
        head,
+
        type: "branch",
      })),
-
    ),
-
  ];
+
    ...peers.flatMap(peer => {
+
      const peerBranches = getBranchesFromRefs(peer.refs);
+
      return Object.entries(peerBranches).map(([name, head]) => ({
+
        peer: { id: peer.id, alias: peer.alias, delegate: peer.delegate },
+
        revision: name,
+
        head,
+
        type: "branch",
+
      }));
+
    }),
+
  ] as SearchElement[];
+

+
  $: tagElements = [
+
    ...Object.entries(canonicalTagsInfo).map(([name, info]) => ({
+
      peer: undefined,
+
      revision: name,
+
      head: info.commit,
+
      type: "tag",
+
    })),
+
    ...peers.flatMap(peer => {
+
      const peerTags = getTagsFromRefs(peer.refs);
+
      return Object.entries(peerTags).map(([name, head]) => ({
+
        peer: { id: peer.id, alias: peer.alias, delegate: peer.delegate },
+
        revision: name,
+
        head,
+
        type: "tag",
+
      }));
+
    }),
+
  ] as SearchElement[];

+
  $: searchElements = selectedTab === "branches" ? branchElements : tagElements;
  $: selectedPeer = peers.find(p => p.id === peer);
  $: searchResults = fuzzysort.go(searchInput, searchElements, {
    keys: ["peer.alias", "revision"],
@@ -58,6 +117,81 @@
      (r.obj.peer === undefined ? 10 : 1) *
      (r.obj.peer?.alias ? 2 : 1),
  });
+
  $: canonicalTags = Object.entries(canonicalTagsInfo).sort(
+
    ([nameA, infoA], [nameB, infoB]) => {
+
      const tsA = infoA.tagger?.timestamp ?? 0;
+
      const tsB = infoB.tagger?.timestamp ?? 0;
+
      if (tsA !== tsB) return tsB - tsA;
+
      return nameB.localeCompare(nameA);
+
    },
+
  );
+
  $: canonicalBranches = Object.entries(canonicalBranchesMap).filter(
+
    ([branchName]) =>
+
      branchName !== repo.payloads["xyz.radicle.project"].data.defaultBranch,
+
  );
+
  $: hasTags =
+
    Object.keys(canonicalTagsInfo).length > 0 ||
+
    peers.some(p => Object.keys(getTagsFromRefs(p.refs)).length > 0);
+

+
  $: selectedTag = (() => {
+
    if (!selectedBranch) return undefined;
+

+
    if (peer) {
+
      const p = peers.find(x => x.id === peer);
+
      if (!p) return undefined;
+
      const peerTags = getTagsFromRefs(p.refs);
+
      for (const [tagName, oid] of Object.entries(peerTags)) {
+
        if (
+
          oid === selectedBranch ||
+
          encodeURIComponent(tagName) === selectedBranch
+
        ) {
+
          return { name: tagName, peer: p };
+
        }
+
      }
+
      return undefined;
+
    }
+

+
    for (const [tagName, info] of Object.entries(canonicalTagsInfo)) {
+
      if (
+
        info.commit === selectedBranch ||
+
        encodeURIComponent(tagName) === selectedBranch
+
      ) {
+
        return { name: tagName, peer: undefined };
+
      }
+
    }
+

+
    return undefined;
+
  })();
+

+
  $: selectedTagName = selectedTag?.name;
+
  $: selectedTagPeer = selectedTag?.peer;
+

+
  let lastSelectedBranch: string | undefined;
+
  $: {
+
    if (selectedBranch !== lastSelectedBranch) {
+
      if (selectedTagName) {
+
        selectedTab = "tags";
+
      } else if (!selectedBranch) {
+
        selectedTab = "branches";
+
      }
+
      lastSelectedBranch = selectedBranch;
+
    }
+
  }
+

+
  $: if (!hasTags && selectedTab === "tags") {
+
    selectedTab = "branches";
+
  }
+

+
  $: isSelectedBranchCanonical = (() => {
+
    if (onCanonical) return true;
+
    if (!selectedBranch || peer) return false;
+

+
    const branchNames = Object.keys(canonicalBranchesMap);
+
    return (
+
      branchNames.includes(selectedBranch) ||
+
      branchNames.includes(decodeURIComponent(selectedBranch))
+
    );
+
  })();
</script>

<style>
@@ -68,6 +202,7 @@
    overflow-y: auto;
    overscroll-behavior: contain;
    padding: 0.25rem;
+
    background-color: var(--color-background-default);
  }
  .subgrid-item {
    display: grid;
@@ -100,6 +235,44 @@
    height: 1rem;
    font: var(--txt-body-m-regular);
  }
+
  .tabs {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
  }
+
  .sticky-header {
+
    position: sticky;
+
    top: -0.25rem;
+
    margin: -0.25rem -0.25rem 0;
+
    padding: 0.25rem;
+
    background-color: var(--color-surface-canvas);
+
    z-index: 1;
+
  }
+
  .tag-details {
+
    display: flex;
+
    flex-direction: column;
+
    width: 32rem;
+
    grid-column: span 2;
+
    color: var(--color-text-secondary);
+
    min-width: 0;
+
    font: var(--txt-body-m-regular);
+
  }
+
  .tag-tagger {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.375rem;
+
    margin-bottom: 1rem;
+
  }
+
  .tag-avatar {
+
    width: 1rem;
+
    height: 1rem;
+
  }
+
  .tag-message {
+
    margin: 0;
+
    white-space: pre-wrap;
+
    overflow-wrap: anywhere;
+
    font: var(--txt-code-small);
+
  }
  @media (max-width: 719.98px) {
    .dropdown {
      width: 100%;
@@ -120,16 +293,17 @@
      styleBorderRadius="var(--border-radius-sm) 0 0 var(--border-radius-sm)"
      styleWidth="100%"
      on:click={toggle}
-
      title="Change branch"
+
      title={hasTags ? "Change branch or tag" : "Change branch"}
      disabled={!peers}>
-
      {#if selectedPeer}
+
      {@const displayPeer = selectedPeer || selectedTagPeer}
+
      {#if displayPeer}
        <div class="global-flex-item">
          <div class="node-id">
-
            <UserAvatar nodeId={selectedPeer.id} styleWidth="1rem" />
-
            {selectedPeer.alias || formatNodeId(selectedPeer.id)}
+
            <UserAvatar nodeId={displayPeer.id} styleWidth="1rem" />
+
            {displayPeer.alias || formatNodeId(displayPeer.id)}
          </div>

-
          {#if selectedPeer.delegate}
+
          {#if displayPeer.delegate}
            <Badge size="tiny" variant="delegate">
              <Icon name="badge" />
              <span class="global-hide-on-small-desktop-down">Delegate</span>
@@ -137,15 +311,25 @@
          {/if}
        </div>
      {/if}
-
      {#if selectedPeer && selectedBranch}
+
      {#if displayPeer && (selectedBranch || selectedTagName)}
        <span>/</span>
      {/if}
-
      {#if selectedBranch}
+
      {#if selectedTagName}
+
        <Icon name="label" />
+
        <span class="txt-overflow">
+
          {selectedTagName}
+
        </span>
+
        {#if Object.keys(canonicalTagsInfo).includes(selectedTagName)}
+
          <Badge title="Canonical tag" variant="foreground-emphasized">
+
            Canonical
+
          </Badge>
+
        {/if}
+
      {:else if selectedBranch}
        <Icon name="branch" />
        <span class="txt-overflow">
          {selectedBranch}
        </span>
-
        {#if onCanonical}
+
        {#if isSelectedBranchCanonical}
          <Badge title="Canonical branch" variant="foreground-emphasized">
            Canonical
          </Badge>
@@ -155,34 +339,88 @@
    </Button>

    <div slot="popover" class="dropdown" let:toggle>
-
      <TextInput
-
        showKeyHint={false}
-
        placeholder="Search"
-
        bind:value={searchInput} />
+
      <div class="sticky-header">
+
        {#if hasTags}
+
          <div class="tabs">
+
            <Button
+
              variant={selectedTab === "branches" ? "selected" : "background"}
+
              on:click={() => {
+
                selectedTab = "branches";
+
                searchInput = "";
+
              }}>
+
              <Icon name="branch" />
+
              Branches
+
            </Button>
+
            <Button
+
              variant={selectedTab === "tags" ? "selected" : "background"}
+
              on:click={() => {
+
                selectedTab = "tags";
+
                searchInput = "";
+
              }}>
+
              <Icon name="label" />
+
              Tags
+
            </Button>
+
            <div class="global-hide-on-mobile-down" style:flex="1">
+
              <TextInput
+
                size="small"
+
                showKeyHint={false}
+
                placeholder={selectedTab === "branches"
+
                  ? "Filter branches"
+
                  : "Filter tags"}
+
                bind:value={searchInput} />
+
            </div>
+
          </div>
+
        {:else}
+
          <div style="margin-bottom: 0.5rem;">
+
            <TextInput
+
              showKeyHint={false}
+
              placeholder="Filter branches"
+
              bind:value={searchInput} />
+
          </div>
+
        {/if}
+
        {#if hasTags}
+
          <div
+
            class="global-hide-on-small-desktop-up"
+
            style="margin-bottom: 0.5rem;">
+
            <TextInput
+
              showKeyHint={false}
+
              placeholder={selectedTab === "branches"
+
                ? "Filter branches"
+
                : "Filter tags"}
+
              bind:value={searchInput} />
+
          </div>
+
        {/if}
+
      </div>
      <div class="dropdown-grid">
-
        <div class="dropdown-header">Branch</div>
+
        <div class="dropdown-header">
+
          {selectedTab === "branches" ? "Branch" : "Tag"}
+
        </div>
        <div class="dropdown-header" style="padding-left: 0;">Head</div>

        {#if searchInput}
          {#each searchResults as result}
-
            {@const { revision, peer, head } = result.obj}
+
            {@const { revision, peer, head, type } = result.obj}
            <Link
              style={subgridStyle}
              route={{
                ...baseRoute,
-
                peer: peer?.id,
-
                revision: peer ? revision : undefined,
+
                peer: type === "branch" ? peer?.id : undefined,
+
                revision:
+
                  type === "tag" ? encodeURIComponent(revision) : revision,
              }}
              on:afterNavigate={() => {
                searchInput = "";
                toggle();
              }}>
              <DropdownListItem
-
                selected={selectedPeer?.id === peer?.id &&
-
                  selectedBranch === revision}
-
                style={subgridStyle}>
+
                selected={type === "tag"
+
                  ? selectedTagName === revision ||
+
                    selectedBranch === encodeURIComponent(revision)
+
                  : selectedPeer?.id === peer?.id &&
+
                    selectedBranch === revision}
+
                style={`${subgridStyle} gap: inherit;`}>
                <div class="global-flex-item">
-
                  <Icon name="branch" />
+
                  <Icon name={type === "tag" ? "label" : "branch"} />
                  <span class="txt-overflow">
                    {#if peer?.id}
                      <span class="global-flex-item">
@@ -216,7 +454,9 @@
                      <div class="global-flex-item">
                        {revision}
                        <Badge
-
                          title="Canonical branch"
+
                          title={type === "tag"
+
                            ? "Canonical tag"
+
                            : "Canonical branch"}
                          variant="foreground-emphasized">
                          Canonical
                        </Badge>
@@ -237,7 +477,7 @@
              No entries found
            </div>
          {/each}
-
        {:else}
+
        {:else if selectedTab === "branches"}
          <Link
            style={subgridStyle}
            route={{ ...baseRoute, revision: undefined }}
@@ -258,12 +498,123 @@
              </div>
            </DropdownListItem>
          </Link>
+
          {#each canonicalBranches as [branchName, branchHead]}
+
            <Link
+
              style={subgridStyle}
+
              route={{
+
                ...baseRoute,
+
                peer: undefined,
+
                revision: encodeURIComponent(branchName),
+
              }}
+
              on:afterNavigate={() => {
+
                searchInput = "";
+
                toggle();
+
              }}>
+
              <DropdownListItem
+
                selected={!peer &&
+
                  (selectedBranch === branchName ||
+
                    selectedBranch === encodeURIComponent(branchName))}
+
                style={subgridStyle}>
+
                <div class="global-flex-item">
+
                  <Icon name="branch" />
+
                  <span class="txt-overflow">{branchName}</span>
+
                  <Badge
+
                    title="Canonical branch"
+
                    variant="foreground-emphasized">
+
                    Canonical
+
                  </Badge>
+
                </div>
+
                <div class="txt-id">
+
                  {formatCommit(branchHead)}
+
                </div>
+
              </DropdownListItem>
+
            </Link>
+
          {/each}
          {#each orderBy(peers, ["delegate", o => o.alias?.toLowerCase()], ["desc", "asc"]) as peer}
            <Peer
              {baseRoute}
              revision={selectedBranch}
              peer={{ remote: peer, selected: selectedPeer?.id === peer.id }} />
          {/each}
+
        {:else if selectedTab === "tags"}
+
          {#if canonicalTags.length > 0}
+
            {#each canonicalTags as [tagName, info]}
+
              {@const annotated = info.tagger || info.message}
+
              <Link
+
                style={subgridStyle}
+
                route={{
+
                  ...baseRoute,
+
                  peer: undefined,
+
                  revision: encodeURIComponent(tagName),
+
                }}
+
                on:afterNavigate={() => {
+
                  searchInput = "";
+
                  toggle();
+
                }}>
+
                <DropdownListItem
+
                  selected={!peer &&
+
                    (selectedBranch === tagName ||
+
                      selectedBranch === encodeURIComponent(tagName) ||
+
                      selectedTagName === tagName)}
+
                  style={subgridStyle}>
+
                  <div class="global-flex-item">
+
                    {#if annotated}
+
                      <HoverPopover>
+
                        <svelte:fragment slot="toggle">
+
                          <Icon name="label" />
+
                        </svelte:fragment>
+
                        <div slot="popover" class="tag-details">
+
                          {#if info.tagger}
+
                            <div
+
                              class="tag-tagger"
+
                              title={`${info.tagger.name} <${info.tagger.email}>`}>
+
                              <img
+
                                class="tag-avatar"
+
                                alt="avatar"
+
                                src={gravatarURL(info.tagger.email)} />
+
                              {info.tagger.name}
+
                              tagged
+
                              <span
+
                                title={absoluteTimestamp(
+
                                  info.tagger.timestamp,
+
                                )}>
+
                                {formatTimestamp(info.tagger.timestamp)}
+
                              </span>
+
                            </div>
+
                          {/if}
+
                          {#if info.message}
+
                            <pre class="tag-message">{info.message}</pre>
+
                          {/if}
+
                        </div>
+
                      </HoverPopover>
+
                    {:else}
+
                      <Icon name="label" />
+
                    {/if}
+
                    <span class="txt-overflow">{tagName}</span>
+
                    <Badge
+
                      title="Canonical tag"
+
                      variant="foreground-emphasized">
+
                      Canonical
+
                    </Badge>
+
                  </div>
+
                  <div class="txt-id">
+
                    {formatCommit(info.commit)}
+
                  </div>
+
                </DropdownListItem>
+
              </Link>
+
            {/each}
+
          {/if}
+
          {#each orderBy(peers, ["delegate", o => o.alias?.toLowerCase()], ["desc", "asc"]).filter(p => Object.keys(getTagsFromRefs(p.refs)).length > 0) as peer}
+
            <Peer
+
              {baseRoute}
+
              revision={selectedBranch}
+
              type="tags"
+
              {selectedTagName}
+
              peer={{
+
                remote: peer,
+
                selected: selectedTagPeer?.id === peer.id,
+
              }} />
+
          {/each}
        {/if}
      </div>
    </div>
modified src/views/repos/Source/PeerBranchSelector/Peer.svelte
@@ -1,9 +1,13 @@
<script lang="ts">
  import type { RepoRoute } from "@app/views/repos/router";
-
  import type { Remote } from "@http-client";
+
  import type { PeerRefs } from "@http-client";

  import { closeFocused } from "@app/components/Popover.svelte";
-
  import { formatCommit } from "@app/lib/utils";
+
  import {
+
    formatCommit,
+
    getBranchesFromRefs,
+
    getTagsFromRefs,
+
  } from "@app/lib/utils";
  import { replace } from "@app/lib/router";

  import Badge from "@app/components/Badge.svelte";
@@ -17,12 +21,23 @@
    RepoRoute,
    { resource: "repo.source" } | { resource: "repo.history" }
  >;
-
  export let peer: { remote: Remote; selected: boolean };
+
  export let peer: { remote: PeerRefs; selected: boolean };
  export let revision: string | undefined = undefined;
+
  export let type: "branches" | "tags" = "branches";
+
  export let selectedTagName: string | undefined = undefined;

  const subgridStyle =
    "display: grid; grid-template-columns: subgrid; grid-column: span 2;";
  let expanded = false;
+

+
  $: refs =
+
    type === "branches"
+
      ? getBranchesFromRefs(peer.remote.refs)
+
      : getTagsFromRefs(peer.remote.refs);
+

+
  $: if (peer.selected) {
+
    expanded = true;
+
  }
</script>

<style>
@@ -51,26 +66,29 @@
  </div>
</div>
{#if expanded}
-
  {#each Object.entries(peer.remote.heads) as [name, head]}
+
  {#each Object.entries(refs) as [name, head]}
    <Link
      style={subgridStyle}
      route={{
        ...baseRoute,
        peer: peer.remote.id,
-
        revision: name,
+
        revision: type === "tags" ? encodeURIComponent(name) : name,
      }}
      on:afterNavigate={() => closeFocused()}>
      <DropdownListItem
-
        selected={peer.selected && revision === name}
+
        selected={type === "tags"
+
          ? peer.selected &&
+
            (selectedTagName === name || revision === encodeURIComponent(name))
+
          : peer.selected && revision === name}
        on:click={() =>
          replace({
            ...baseRoute,
            peer: peer.remote.id,
-
            revision: name,
+
            revision: type === "tags" ? encodeURIComponent(name) : name,
          })}
        style={`${subgridStyle} padding-left: 2.3rem;`}>
        <div class="global-flex-item">
-
          <Icon name="branch" />
+
          <Icon name={type === "branches" ? "branch" : "label"} />
          <span class="txt-overflow">
            {name}
          </span>
modified src/views/repos/router.ts
@@ -16,8 +16,9 @@ import type {
  Node,
  Patch,
  PatchState,
-
  Repo,
+
  PeerRefs,
  Remote,
+
  Repo,
  Revision,
  SeedingPolicy,
  Tree,
@@ -29,12 +30,55 @@ import { HttpdClient } from "@http-client";
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
import { cached } from "@app/lib/cache";
import { handleError, unreachableError } from "@app/views/repos/error";
-
import { unreachable } from "@app/lib/utils";
+
import {
+
  getBranchesFromRefs,
+
  getTagsFromRefs,
+
  unreachable,
+
} from "@app/lib/utils";
import { nodePath } from "@app/views/nodes/router";

export const PATCHES_PER_PAGE = 10;
export const ISSUES_PER_PAGE = 10;

+
function peerHasBranches(peer: PeerRefs): boolean {
+
  return Object.keys(peer.refs).some(name => name.startsWith("refs/heads/"));
+
}
+

+
function canonicalOids(
+
  refs: Repo["refs"] | undefined,
+
): Array<[string, string]> {
+
  return [
+
    ...Object.entries(refs?.refs ?? {}),
+
    ...Object.entries(refs?.tags ?? {}).map(
+
      ([name, info]): [string, string] => [name, info.commit],
+
    ),
+
  ];
+
}
+

+
function remoteToPeerRefs(remote: Remote): PeerRefs {
+
  if (remote.refs) {
+
    return {
+
      id: remote.id,
+
      alias: remote.alias,
+
      delegate: remote.delegate,
+
      refs: remote.refs,
+
    };
+
  }
+

+
  const refs: Record<string, string> = {};
+

+
  for (const [name, oid] of Object.entries(remote.heads)) {
+
    refs[`refs/heads/${name}`] = oid;
+
  }
+

+
  return {
+
    id: remote.id,
+
    alias: remote.alias,
+
    delegate: remote.delegate,
+
    refs,
+
  };
+
}
+

export type RepoRoute =
  | RepoTreeRoute
  | RepoHistoryRoute
@@ -116,7 +160,7 @@ export type RepoLoadedRoute =
        seedingPolicy: SeedingPolicy;
        commit: string;
        repo: Repo;
-
        peers: Remote[];
+
        peers: PeerRefs[];
        peer: string | undefined;
        revision: string | undefined;
        tree: Tree;
@@ -133,7 +177,7 @@ export type RepoLoadedRoute =
        seedingPolicy: SeedingPolicy;
        commit: string;
        repo: Repo;
-
        peers: Remote[];
+
        peers: PeerRefs[];
        peer: string | undefined;
        revision: string | undefined;
        tree: Tree;
@@ -373,7 +417,6 @@ async function loadTreeView(

  let repoPromise: Promise<Repo>;
  let seedingPolicyPromise: Promise<SeedingPolicy>;
-
  let peersPromise: Promise<Remote[]>;
  let nodePromise: Promise<Partial<Node>>;
  if (
    (previousLoaded.resource === "repo.source" ||
@@ -382,25 +425,25 @@ async function loadTreeView(
    previousLoaded.params.peer === route.peer
  ) {
    repoPromise = Promise.resolve(previousLoaded.params.repo);
-
    peersPromise = Promise.resolve(previousLoaded.params.peers);
    seedingPolicyPromise = Promise.resolve(previousLoaded.params.seedingPolicy);
    nodePromise = Promise.resolve({
      avatarUrl: previousLoaded.params.nodeAvatarUrl,
    });
  } else {
    repoPromise = api.repo.getByRid(route.repo);
-
    peersPromise = api.repo.getAllRemotes(route.repo);
    seedingPolicyPromise = api.getPolicyByRid(route.repo);
    nodePromise = api.getNode();
  }

-
  const [repo, peers, seedingPolicy, node] = await Promise.all([
+
  const [repo, seedingPolicy, node] = await Promise.all([
    repoPromise,
-
    peersPromise,
    seedingPolicyPromise,
    nodePromise,
  ]);

+
  const remotes = await api.repo.getAllRemotes(route.repo);
+
  const peers: PeerRefs[] = remotes.map(remoteToPeerRefs);
+

  if (!repo["payloads"]["xyz.radicle.project"]) {
    throw new Error(
      `Repository ${repo.rid} does not have a xyz.radicle.project payload.`,
@@ -411,6 +454,25 @@ async function loadTreeView(
  let branchMap: Record<string, string> = {
    [project.data.defaultBranch]: project.meta.head,
  };
+

+
  for (const [refName, oid] of canonicalOids(repo.refs)) {
+
    const shortName = refName.startsWith("refs/heads/")
+
      ? refName.slice("refs/heads/".length)
+
      : refName.startsWith("refs/tags/")
+
        ? refName.slice("refs/tags/".length)
+
        : refName;
+
    branchMap[shortName] = oid;
+
    branchMap[encodeURIComponent(shortName)] = oid;
+
  }
+

+
  for (const peer of peers) {
+
    const tags = getTagsFromRefs(peer.refs);
+
    for (const [tagName, oid] of Object.entries(tags)) {
+
      branchMap[tagName] = oid;
+
      branchMap[encodeURIComponent(tagName)] = oid;
+
    }
+
  }
+

  if (route.peer) {
    const peer = peers.find(peer => peer.id === route.peer);
    if (!peer) {
@@ -419,7 +481,11 @@ async function loadTreeView(
        params: { title: `Peer ${route.peer} could not be found` },
      };
    } else {
-
      branchMap = peer.heads;
+
      branchMap = { ...getBranchesFromRefs(peer.refs) };
+
      for (const [tagName, oid] of Object.entries(getTagsFromRefs(peer.refs))) {
+
        branchMap[tagName] = oid;
+
        branchMap[encodeURIComponent(tagName)] = oid;
+
      }
    }
  }

@@ -446,7 +512,7 @@ async function loadTreeView(
      seedingPolicy,
      commit,
      repo,
-
      peers: peers.filter(remote => Object.keys(remote.heads).length > 0),
+
      peers: peers.filter(peerHasBranches),
      peer: route.peer,
      rawPath,
      revision: route.revision,
@@ -515,7 +581,6 @@ async function loadHistoryView(

  let repoPromise: Promise<Repo>;
  let seedingPolicyPromise: Promise<SeedingPolicy>;
-
  let peersPromise: Promise<Remote[]>;
  let nodePromise: Promise<Partial<Node>>;
  if (
    (previousLoaded.resource === "repo.source" ||
@@ -524,26 +589,33 @@ async function loadHistoryView(
    previousLoaded.params.peer === route.peer
  ) {
    repoPromise = Promise.resolve(previousLoaded.params.repo);
-
    peersPromise = Promise.resolve(previousLoaded.params.peers);
    seedingPolicyPromise = Promise.resolve(previousLoaded.params.seedingPolicy);
    nodePromise = Promise.resolve({
      avatarUrl: previousLoaded.params.nodeAvatarUrl,
    });
  } else {
    repoPromise = api.repo.getByRid(route.repo);
-
    peersPromise = api.repo.getAllRemotes(route.repo);
    seedingPolicyPromise = api.getPolicyByRid(route.repo);
    nodePromise = api.getNode();
  }

-
  const [repo, peers, seedingPolicy, branchMap, node] = await Promise.all([
+
  const [repo, seedingPolicy, node] = await Promise.all([
    repoPromise,
-
    peersPromise,
    seedingPolicyPromise,
-
    getPeerBranches(api, route.repo, route.peer),
    nodePromise,
  ]);

+
  const remotes = await api.repo.getAllRemotes(route.repo);
+
  const peers: PeerRefs[] = remotes.map(remoteToPeerRefs);
+

+
  const branchMap = await getPeerBranches(
+
    api,
+
    route.repo,
+
    route.peer,
+
    repo,
+
    peers,
+
  );
+

  if (!repo["payloads"]["xyz.radicle.project"]) {
    throw new Error(
      `Repository ${repo.rid} does not have a xyz.radicle.project payload.`,
@@ -556,8 +628,6 @@ async function loadHistoryView(
    commitId = route.revision;
  } else if (branchMap) {
    commitId = branchMap[route.revision || project.data.defaultBranch];
-
  } else if (!route.revision) {
-
    commitId = project.meta.head;
  }

  if (!commitId) {
@@ -595,7 +665,7 @@ async function loadHistoryView(
      seedingPolicy,
      commit: commitId,
      repo,
-
      peers: peers.filter(remote => Object.keys(remote.heads).length > 0),
+
      peers: peers.filter(peerHasBranches),
      peer: route.peer,
      revision: route.revision,
      tree,
@@ -733,9 +803,51 @@ async function loadPatchView(
  };
}

-
async function getPeerBranches(api: HttpdClient, repo: string, peer?: string) {
+
async function getPeerBranches(
+
  api: HttpdClient,
+
  repoId: string,
+
  peer?: string,
+
  repo?: Repo,
+
  loadedPeers?: PeerRefs[],
+
) {
  if (peer) {
-
    return (await api.repo.getRemoteByPeer(repo, peer)).heads;
+
    const remote = await api.repo.getRemoteByPeer(repoId, peer);
+
    const refs = remoteToPeerRefs(remote).refs;
+
    const map: Record<string, string> = { ...getBranchesFromRefs(refs) };
+
    for (const [tagName, oid] of Object.entries(getTagsFromRefs(refs))) {
+
      map[tagName] = oid;
+
      map[encodeURIComponent(tagName)] = oid;
+
    }
+
    return map;
+
  } else if (repo) {
+
    const branchMap: Record<string, string> = {};
+
    const peers = loadedPeers ?? [];
+

+
    const project = repo.payloads["xyz.radicle.project"];
+
    if (project) {
+
      branchMap[project.data.defaultBranch] = project.meta.head;
+
      branchMap[encodeURIComponent(project.data.defaultBranch)] =
+
        project.meta.head;
+
    }
+

+
    for (const [refName, oid] of canonicalOids(repo.refs)) {
+
      const shortName = refName.startsWith("refs/heads/")
+
        ? refName.slice("refs/heads/".length)
+
        : refName.startsWith("refs/tags/")
+
          ? refName.slice("refs/tags/".length)
+
          : refName;
+
      branchMap[shortName] = oid;
+
      branchMap[encodeURIComponent(shortName)] = oid;
+
    }
+

+
    for (const p of peers) {
+
      const tags = getTagsFromRefs(p.refs);
+
      for (const [tagName, oid] of Object.entries(tags)) {
+
        branchMap[tagName] = oid;
+
        branchMap[encodeURIComponent(tagName)] = oid;
+
      }
+
    }
+
    return branchMap;
  } else {
    return undefined;
  }
modified tests/e2e/repo.spec.ts
@@ -349,8 +349,7 @@ test("peer and branch switching", async ({ page }) => {
      await changeBranch("alice", "feature/branch", page);
      await page.locator('[title="Change branch"]:visible').first().click();
      await page
-
        .getByRole("button", { name: "feature/branch" })
-
        .first()
+
        .getByRole("button", { name: "feature/branch 1aded56" })
        .click();

      await expect(
modified tests/support/repo.ts
@@ -6,8 +6,15 @@ import * as Path from "node:path";
export async function changeBranch(peer: string, branch: string, page: Page) {
  await page.locator('[title="Change branch"]:visible').first().click();
  const peerLocator = page.getByLabel("peer-item").filter({ hasText: peer });
-
  await peerLocator.getByTitle("Expand peer").click();
-
  await page.getByRole("button", { name: branch }).click();
+

+
  const branchButton = page.getByRole("button", { name: branch });
+
  const isVisible = await branchButton.isVisible().catch(() => false);
+

+
  if (!isVisible) {
+
    await peerLocator.getByTitle("Expand peer").click();
+
  }
+

+
  await branchButton.click();
}

// Create a repo using the rad CLI.