From 040906ba6df574e4562b64928de2a7b4ed067873 Mon Sep 17 00:00:00 2001 From: Daniel Norman Date: Thu, 8 Oct 2026 17:36:47 +0200 Subject: [PATCH] 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 --- crates/radicle-tauri/src/commands/repo.rs | 10 ++++ crates/radicle-tauri/src/lib.rs | 1 + crates/radicle-types/src/traits/repo.rs | 64 +++++++++++++++++++++++ crates/test-http-api/src/api.rs | 10 ++++ src/lib/releases.ts | 10 ---- src/modals/CreateRelease.svelte | 12 +++-- tests/unit/releases.test.ts | 42 --------------- 7 files changed, 92 insertions(+), 57 deletions(-) diff --git a/crates/radicle-tauri/src/commands/repo.rs b/crates/radicle-tauri/src/commands/repo.rs index 57a4441fb..980cd8cde 100644 --- a/crates/radicle-tauri/src/commands/repo.rs +++ b/crates/radicle-tauri/src/commands/repo.rs @@ -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, Error> { + blocking(ctx, move |ctx| ctx.list_canonical_tags(rid)).await +} + #[tauri::command] pub async fn repo_by_id( ctx: tauri::State<'_, AppState>, diff --git a/crates/radicle-tauri/src/lib.rs b/crates/radicle-tauri/src/lib.rs index 97ad70a48..58dfd9497 100644 --- a/crates/radicle-tauri/src/lib.rs +++ b/crates/radicle-tauri/src/lib.rs @@ -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, diff --git a/crates/radicle-types/src/traits/repo.rs b/crates/radicle-types/src/traits/repo.rs index 61c9c13d0..74efdce7e 100644 --- a/crates/radicle-types/src/traits/repo.rs +++ b/crates/radicle-types/src/traits/repo.rs @@ -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, Error> { + let repo = self.profile().storage.repository(rid)?; + + Ok(canonical_refs(&repo)?.tags) + } + fn repo_by_id(&self, rid: identity::RepoId) -> Result { 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!["v1"]); + assert_eq!(tags["v1"].oid, head); + } + #[test] fn repo_split_tree_path() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/test-http-api/src/api.rs b/crates/test-http-api/src/api.rs index af9cb1a80..3b40fd105 100644 --- a/crates/test-http-api/src/api.rs +++ b/crates/test-http-api/src/api.rs @@ -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, +) -> 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())) } diff --git a/src/lib/releases.ts b/src/lib/releases.ts index 50eda0ef9..7936cc515 100644 --- a/src/lib/releases.ts +++ b/src/lib/releases.ts @@ -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( diff --git a/src/modals/CreateRelease.svelte b/src/modals/CreateRelease.svelte index ddbc62d0b..44e3c1d7b 100644 --- a/src/modals/CreateRelease.svelte +++ b/src/modals/CreateRelease.svelte @@ -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("list_repo_refs", { rid: repo.rid }) - .then(refs => { - tags = canonicalTags(refs); + void invoke>("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); diff --git a/tests/unit/releases.test.ts b/tests/unit/releases.test.ts index 47fbf397d..363c457b3 100644 --- a/tests/unit/releases.test.ts +++ b/tests/unit/releases.test.ts @@ -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, - ...remotes: Record[] - ): 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");