Add list_canonical_tags command

- Load the release modal's tags with it
- Sort them with the shared sortedTags helper
- Test that peers' tags are left out
This commit is contained in:
Daniel Norman
2026-10-08 18:21:48 +02:00
committed by Rūdolfs Ošiņš
parent 6f57b811ef
commit 040906ba6d
7 changed files with 92 additions and 57 deletions
+10
View File
@@ -1,3 +1,5 @@
use std::collections::BTreeMap;
use radicle::git;
use radicle::identity::RepoId;
use radicle::node::NodeId;
@@ -37,6 +39,14 @@ pub async fn list_repo_refs(
blocking(ctx, move |ctx| ctx.list_repo_refs(rid)).await
}
#[tauri::command]
pub async fn list_canonical_tags(
ctx: tauri::State<'_, AppState>,
rid: RepoId,
) -> Result<BTreeMap<String, types::repo::Tag>, Error> {
blocking(ctx, move |ctx| ctx.list_canonical_tags(rid)).await
}
#[tauri::command]
pub async fn repo_by_id(
ctx: tauri::State<'_, AppState>,
+1
View File
@@ -122,6 +122,7 @@ pub fn run() {
repo::clean,
repo::diff_stats,
repo::list_commits,
repo::list_canonical_tags,
repo::list_repo_commits,
repo::list_repo_refs,
repo::list_repos,
+64
View File
@@ -655,6 +655,17 @@ pub trait Repo: Profile {
Ok(repo::RepoRefs { canonical, remotes })
}
/// The canonical tags, without the per-peer refs that `list_repo_refs`
/// loads and verifies for every remote.
fn list_canonical_tags(
&self,
rid: identity::RepoId,
) -> Result<BTreeMap<String, repo::Tag>, Error> {
let repo = self.profile().storage.repository(rid)?;
Ok(canonical_refs(&repo)?.tags)
}
fn repo_by_id(&self, rid: identity::RepoId) -> Result<repo::RepoInfo, Error> {
let profile = self.profile();
let repo = profile.storage.repository(rid)?;
@@ -1309,6 +1320,59 @@ mod test {
);
}
#[test]
fn canonical_tags_ignore_peer_tags() {
use radicle::cob::Title;
use radicle::identity::Identity;
use radicle::identity::doc::PayloadId;
use radicle::storage::{SignRepository, WriteRepository, WriteStorage};
let tmp = tempfile::tempdir().unwrap();
let profile = test::profile(&tmp.path().join("home"), [0xff; 32]);
let signer = SigningKey::from_seed(Seed::new([0xff; 32]));
let (rid, _, _, head) =
fixtures::project(tmp.path().join("working"), &profile.storage, &signer).unwrap();
{
let repo = profile.storage.repository_mut(rid).unwrap();
let mut identity = Identity::load_mut(&repo, &signer).unwrap();
let mut doc = identity.doc().clone().edit();
doc.payload.insert(
PayloadId::canonical_refs(),
serde_json::json!({
"rules": {
"refs/tags/*": { "allow": "delegates", "threshold": 1 },
},
})
.into(),
);
identity
.update(
Title::new("Make tags canonical").unwrap(),
"",
&doc.verified().unwrap(),
)
.unwrap();
repo.sign_refs(&signer).unwrap();
repo.set_identity_head().unwrap();
let other = repo.identity_head().unwrap().into();
let peer = "z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7";
let peer_tags = format!("refs/namespaces/{peer}/refs/tags");
for (name, oid) in [
("refs/tags/v1".to_owned(), head),
(format!("{peer_tags}/v1"), other),
(format!("{peer_tags}/v2"), head),
] {
repo.backend.reference(&name, oid, false, "test").unwrap();
}
}
let tags = AppState { profile }.list_canonical_tags(rid).unwrap();
assert_eq!(tags.keys().collect::<Vec<_>>(), vec!["v1"]);
assert_eq!(tags["v1"].oid, head);
}
#[test]
fn repo_split_tree_path() {
let tmp = tempfile::tempdir().unwrap();
+10
View File
@@ -193,6 +193,7 @@ pub fn router(shared: Shared) -> Router {
)
.route("/repo_by_id", post(repo_handler))
.route("/list_repo_refs", post(list_repo_refs_handler))
.route("/list_canonical_tags", post(list_canonical_tags_handler))
.route("/identity_by_repo", post(identity_handler))
.route("/version", post(version_handler))
.route("/git_info", post(git_info_handler))
@@ -468,6 +469,15 @@ async fn list_repo_refs_handler(
Ok::<_, Error>(Json(refs))
}
async fn list_canonical_tags_handler(
Ctx(ctx): Ctx,
Json(RepoBody { rid }): Json<RepoBody>,
) -> impl IntoResponse {
let tags = ctx.list_canonical_tags(rid)?;
Ok::<_, Error>(Json(tags))
}
async fn git_info_handler() -> impl IntoResponse {
Ok::<_, Error>(Json(types::binaries::git_info()))
}
-10
View File
@@ -4,8 +4,6 @@ import type { Location } from "@bindings/cob/release/Location";
import type { ReleaseCounts } from "@bindings/cob/release/ReleaseCounts";
import type { ReleaseScope } from "@bindings/cob/release/ReleaseScope";
import type { Commit } from "@bindings/repo/Commit";
import type { RepoRefs } from "@bindings/repo/RepoRefs";
import type { Tag } from "@bindings/repo/Tag";
// With no scope asked for and no delegate releases, open on the untrusted
// scope rather than on an empty list.
@@ -154,14 +152,6 @@ export function matchCommits(
return matches;
}
// Only canonical tags: a peer's tag of the same name could point a release
// at a commit the delegates never tagged.
export function canonicalTags(refs: RepoRefs): { name: string; tag: Tag }[] {
return Object.entries(refs.canonical.tags)
.map(([name, tag]) => ({ name, tag }))
.sort((a, b) => b.tag.timestamp - a.tag.timestamp);
}
// A release by a non-delegate is untrusted as a whole. On a delegate's
// release, only the artifacts registered by others are.
export function releaseWarning(
+7 -5
View File
@@ -3,7 +3,6 @@
import type { ArtifactDigest } from "@bindings/cob/release/ArtifactDigest";
import type { Commit } from "@bindings/repo/Commit";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { RepoRefs } from "@bindings/repo/RepoRefs";
import type { Tag } from "@bindings/repo/Tag";
import { untrack } from "svelte";
@@ -14,7 +13,8 @@
import { invoke, InvokeError } from "@app/lib/invoke";
import { disableHide, enableHide, forceHide } from "@app/lib/modal";
import { isOid } from "@app/lib/radUri";
import { canonicalTags, matchCommits } from "@app/lib/releases";
import { sortedTags } from "@app/lib/refs";
import { matchCommits } from "@app/lib/releases";
import * as router from "@app/lib/router";
import { duplicatePicks } from "@app/lib/stageArtifacts";
import { formatBytes, formatOid } from "@app/lib/utils";
@@ -102,10 +102,12 @@
}
});
// Only canonical tags: a peer's tag of the same name could point a release
// at a commit the delegates never tagged.
$effect(() => {
void invoke<RepoRefs>("list_repo_refs", { rid: repo.rid })
.then(refs => {
tags = canonicalTags(refs);
void invoke<Record<string, Tag>>("list_canonical_tags", { rid: repo.rid })
.then(result => {
tags = sortedTags(result).map(([name, tag]) => ({ name, tag }));
})
.catch((error: unknown) => {
console.error("Could not list tags:", error);
-42
View File
@@ -1,8 +1,6 @@
import type { Artifact } from "@bindings/cob/release/Artifact";
import type { ReleaseCounts } from "@bindings/cob/release/ReleaseCounts";
import type { Commit } from "@bindings/repo/Commit";
import type { RepoRefs } from "@bindings/repo/RepoRefs";
import type { Tag } from "@bindings/repo/Tag";
import { describe, expect, test } from "vitest";
@@ -11,7 +9,6 @@ import {
attestedBy,
canAttest,
canEditMetadata,
canonicalTags,
delegatesFirst,
displayMetadataValue,
locationsByNode,
@@ -270,45 +267,6 @@ describe("matchCommits", () => {
});
});
describe("canonicalTags", () => {
function tag(timestamp: number, tagOid?: string): Tag {
return { oid: "c", timestamp, tagOid };
}
function refs(
canonical: Record<string, Tag>,
...remotes: Record<string, Tag>[]
): RepoRefs {
return {
canonical: { branches: {}, tags: canonical },
remotes: remotes.map((tags, i) => ({
id: `${i}`,
delegate: false,
branches: {},
tags,
})),
};
}
test("lists canonical tags, newest first", () => {
expect(
canonicalTags(refs({ v1: tag(1), v2: tag(2) })).map(t => t.name),
).toEqual(["v2", "v1"]);
});
test("ignores tags only a peer has", () => {
expect(canonicalTags(refs({ v1: tag(1) }, { v2: tag(2) }))).toEqual([
{ name: "v1", tag: tag(1) },
]);
});
test("keeps the canonical tag over a peer's annotated one", () => {
const canonical = tag(1);
expect(
canonicalTags(refs({ v1: canonical }, { v1: tag(2, "evil") }))[0].tag,
).toBe(canonical);
});
});
describe("releaseWarning", () => {
test("warns about a whole release by a non-delegate", () => {
expect(releaseWarning(them, delegates, "trusted")).toBe("release");