diff --git a/radicle-cli/examples/rad-config.md b/radicle-cli/examples/rad-config.md index e30eda98..09ffa2ec 100644 --- a/radicle-cli/examples/rad-config.md +++ b/radicle-cli/examples/rad-config.md @@ -4,7 +4,7 @@ In its simplest form, `rad config` prints the current configuration. ``` $ rad config { - "publicExplorer": "https://app.radicle.xyz/nodes/$host/$rid", + "publicExplorer": "https://app.radicle.xyz/nodes/$host/$rid$path", "preferredSeeds": [], "cli": { "hints": false diff --git a/radicle-cli/examples/rad-patch-open-explore.md b/radicle-cli/examples/rad-patch-open-explore.md new file mode 100644 index 00000000..a5dad59a --- /dev/null +++ b/radicle-cli/examples/rad-patch-open-explore.md @@ -0,0 +1,44 @@ +When preferred seeds are configured, opening a patch outputs the patch URL. + +``` (stderr) +$ git checkout -b changes -q +$ git commit --allow-empty -q -m "Changes" +$ git push rad HEAD:refs/patches +✓ Patch e0b35c56eb265d49cddd72d91cf873f64037d96c opened +✓ Synced with 1 node(s) + + https://app.radicle.xyz/nodes/[..]/rad:z3yXbb1sR6UG6ixxV2YF9jUP7ABra/patches/e0b35c56eb265d49cddd72d91cf873f64037d96c + +To rad://z3yXbb1sR6UG6ixxV2YF9jUP7ABra/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk + * [new reference] HEAD -> refs/patches +``` + +If we update the patch, the URL is also output. + +``` (stderr) +$ git commit --amend --allow-empty -q -m "Other changes" +$ git push -f +✓ Patch e0b35c5 updated to revision 0ab4697ba5beee387f1211bdf0880a06564842ce +✓ Synced with 1 node(s) + + https://app.radicle.xyz/nodes/[..]/rad:z3yXbb1sR6UG6ixxV2YF9jUP7ABra/patches/e0b35c56eb265d49cddd72d91cf873f64037d96c + +To rad://z3yXbb1sR6UG6ixxV2YF9jUP7ABra/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk + + e12525d...b2b6432 changes -> patches/e0b35c56eb265d49cddd72d91cf873f64037d96c (forced update) +``` + +While simply pushing a commit outputs a URL to the new source tree. + +``` (stderr) +$ git checkout master -q +$ git merge changes -q +$ git push rad master +✓ Patch e0b35c56eb265d49cddd72d91cf873f64037d96c merged +✓ Canonical head updated to b2b6432af93f8fe188e32d400263021b602cfec8 +✓ Synced with 1 node(s) + + https://app.radicle.xyz/nodes/[..]/rad:z3yXbb1sR6UG6ixxV2YF9jUP7ABra/tree/b2b6432af93f8fe188e32d400263021b602cfec8 + +To rad://z3yXbb1sR6UG6ixxV2YF9jUP7ABra/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk + f2de534..b2b6432 master -> master +``` diff --git a/radicle-cli/src/commands/init.rs b/radicle-cli/src/commands/init.rs index 2c51506b..1073334c 100644 --- a/radicle-cli/src/commands/init.rs +++ b/radicle-cli/src/commands/init.rs @@ -11,6 +11,7 @@ use anyhow::{anyhow, bail, Context as _}; use serde_json as json; use radicle::crypto::{ssh, Verified}; +use radicle::explorer::ExplorerUrl; use radicle::git::RefString; use radicle::identity::{Id, Visibility}; use radicle::node::policy::Scope; @@ -356,7 +357,7 @@ fn sync( rid: Id, node: &mut Node, config: &profile::Config, -) -> Result>, radicle::node::Error> { +) -> Result>, radicle::node::Error> { if !node.is_running() { return Ok(SyncResult::NodeStopped); } @@ -423,11 +424,7 @@ fn sync( for seed in &config.preferred_seeds { if replicas.contains(&seed.id) { return Ok(SyncResult::Synced { - result: Some( - config - .public_explorer - .url(seed.addr.host.to_string().as_str(), &rid), - ), + result: Some(config.public_explorer.url(seed.addr.host.to_string(), rid)), }); } } diff --git a/radicle-cli/tests/commands.rs b/radicle-cli/tests/commands.rs index f2b530a4..14b22087 100644 --- a/radicle-cli/tests/commands.rs +++ b/radicle-cli/tests/commands.rs @@ -1674,6 +1674,41 @@ fn rad_patch_pull_update() { .unwrap(); } +#[test] +fn rad_patch_open_explore() { + logger::init(log::Level::Debug); + + let mut environment = Environment::new(); + let mut alice = environment + .node(Config { + policy: Policy::Allow, + ..Config::test(Alias::new("alice")) + }) + .spawn(); + + let bob = environment.profile(profile::Config { + preferred_seeds: vec![alice.address()], + ..config::profile("bob") + }); + let mut bob = Node::new(bob).spawn(); + let working = environment.tmp().join("working"); + + fixtures::repository(&working); + + bob.init("heartwood", "", &working).unwrap(); + bob.connect(&alice); + alice.handle.follow(bob.id, None).unwrap(); + alice.converge([&bob]); + + test( + "examples/rad-patch-open-explore.md", + &working, + Some(&bob.home), + [], + ) + .unwrap(); +} + #[test] fn rad_init_private() { let mut environment = Environment::new(); diff --git a/radicle-httpd/src/api/v1/profile.rs b/radicle-httpd/src/api/v1/profile.rs index a0ab0958..6f4d6cd5 100644 --- a/radicle-httpd/src/api/v1/profile.rs +++ b/radicle-httpd/src/api/v1/profile.rs @@ -71,7 +71,7 @@ mod routes { response.json().await, json!({ "config": { - "publicExplorer": "https://app.radicle.xyz/nodes/$host/$rid", + "publicExplorer": "https://app.radicle.xyz/nodes/$host/$rid$path", "preferredSeeds": [ "z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@seed.radicle.garden:8776" ], diff --git a/radicle-node/src/test/environment.rs b/radicle-node/src/test/environment.rs index c5d236a3..64648dc8 100644 --- a/radicle-node/src/test/environment.rs +++ b/radicle-node/src/test/environment.rs @@ -9,7 +9,6 @@ use std::{ use crossbeam_channel as chan; -use radicle::cob; use radicle::cob::issue; use radicle::crypto::ssh::{keystore::MemorySigner, Keystore}; use radicle::crypto::test::signer::MockSigner; @@ -31,6 +30,7 @@ use radicle::storage::{ReadStorage as _, RemoteRepository as _, SignRepository a use radicle::test::fixtures; use radicle::Storage; use radicle::{cli, node}; +use radicle::{cob, explorer}; use crate::node::NodeId; use crate::service::Event; @@ -91,7 +91,7 @@ impl Environment { profile::Config { node: node::Config::test(alias), cli: cli::Config { hints: false }, - public_explorer: profile::Explorer::default(), + public_explorer: explorer::Explorer::default(), preferred_seeds: vec![], } } @@ -315,6 +315,23 @@ impl NodeHandle { self.rad("sync", &[rid.to_string().as_str(), "--announce"], cwd) } + /// Init a repo. + pub fn init>(&self, name: &str, desc: &str, cwd: P) -> io::Result<()> { + self.rad( + "init", + &[ + "--name", + name, + "--description", + desc, + "--default-branch", + "master", + "--public", + ], + cwd, + ) + } + /// Run a `rad` CLI command. pub fn rad>(&self, cmd: &str, args: &[&str], cwd: P) -> io::Result<()> { let cwd = cwd.as_ref(); diff --git a/radicle-remote-helper/src/push.rs b/radicle-remote-helper/src/push.rs index 23f4548d..c81311f5 100644 --- a/radicle-remote-helper/src/push.rs +++ b/radicle-remote-helper/src/push.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::io::IsTerminal; use std::ops::ControlFlow; use std::path::Path; @@ -11,6 +11,7 @@ use thiserror::Error; use radicle::cob::object::ParseObjectId; use radicle::cob::patch; use radicle::crypto::Signer; +use radicle::explorer::ExplorerResource; use radicle::identity::Did; use radicle::node; use radicle::node::{Handle, NodeId}; @@ -97,8 +98,11 @@ pub enum Error { Repository(#[from] radicle::storage::RepositoryError), } +/// Push command. enum Command { + /// Update ref. Push(git::Refspec), + /// Delete ref. Delete(git::RefString), } @@ -159,7 +163,7 @@ pub fn run( })?; let signer = profile.signer()?; let mut line = String::new(); - let mut ok = HashSet::new(); + let mut ok = HashMap::new(); let hints = profile.hints(); assert_eq!(signer.public_key(), &nid); @@ -198,6 +202,7 @@ pub fn run( .raw() .find_reference(&refname) .and_then(|mut r| r.delete()) + .map(|_| None) .map_err(Error::from) } Command::Push(git::Refspec { src, dst, force }) => { @@ -269,9 +274,9 @@ pub fn run( match result { // Let Git tooling know that this ref has been pushed. - Ok(()) => { + Ok(resource) => { println!("ok {}", cmd.dst()); - ok.insert(spec); + ok.insert(spec, resource); } // Let Git tooling know that there was an error pushing the ref. Err(e) => println!("error {} {e}", cmd.dst()), @@ -298,7 +303,7 @@ pub fn run( let node = radicle::Node::new(profile.socket()); if node.is_running() { // Nb. allow this to fail. The push to local storage was still successful. - sync(stored.id, node).ok(); + sync(stored.id, ok.into_values().flatten(), node, profile).ok(); } else if hints { hint("offline push, your node is not running"); hint("to sync with the network, run `rad node start`"); @@ -321,7 +326,7 @@ fn patch_open( signer: &G, profile: &Profile, opts: Options, -) -> Result<(), Error> { +) -> Result, Error> { let reference = working.find_reference(src.as_str())?; let commit = reference.peel_to_commit()?; let dst = git::refs::storage::staging::patch(nid, commit.id()); @@ -412,8 +417,7 @@ fn patch_open( } } } - - Ok(()) + Ok(Some(ExplorerResource::Patch { id: patch })) } Err(e) => Err(e), }; @@ -440,7 +444,7 @@ fn patch_update( stored: &storage::git::Repository, signer: &G, opts: Options, -) -> Result<(), Error> { +) -> Result, Error> { let reference = working.find_reference(src.as_str())?; let commit = reference.peel_to_commit()?; let patch_id = radicle::cob::ObjectId::from(oid); @@ -455,7 +459,7 @@ fn patch_update( // Don't update patch if it already has a revision matching this commit. if patch.revisions().any(|(_, r)| *r.head() == commit.id()) { - return Ok(()); + return Ok(None); } let message = cli::patch::get_update_message( opts.message, @@ -490,7 +494,7 @@ fn patch_update( patch_merge(patch, revision, head, working, signer)?; } - Ok(()) + Ok(Some(ExplorerResource::Patch { id: patch_id })) } fn push( @@ -501,7 +505,7 @@ fn push( working: &git::raw::Repository, stored: &storage::git::Repository, signer: &G, -) -> Result<(), Error> { +) -> Result, Error> { let head = working.find_reference(src.as_str())?; let head = head.peel_to_commit()?.id(); let dst = dst.with_namespace(nid.into()); @@ -524,7 +528,7 @@ fn push( } } } - Ok(()) + Ok(Some(ExplorerResource::Tree { oid: head.into() })) } /// Merge all patches that have been included in the base branch. @@ -626,9 +630,15 @@ fn push_ref( } /// Sync with the network. -fn sync(rid: Id, mut node: radicle::Node) -> Result<(), radicle::node::Error> { +fn sync( + rid: Id, + updated: impl Iterator, + mut node: radicle::Node, + profile: &Profile, +) -> Result<(), radicle::node::Error> { let seeds = node.seeds(rid)?; let connected = seeds.connected().map(|s| s.nid).collect::>(); + let mut replicated = HashSet::new(); if connected.is_empty() { eprintln!("Not connected to any seeds."); @@ -647,6 +657,7 @@ fn sync(rid: Id, mut node: radicle::Node) -> Result<(), radicle::node::Error> { |event, _| match event { node::AnnounceEvent::Announced => ControlFlow::Continue(()), node::AnnounceEvent::RefsSynced { remote } => { + replicated.insert(remote); spinner.message(format!("Synced with {remote}..")); ControlFlow::Continue(()) } @@ -659,5 +670,31 @@ fn sync(rid: Id, mut node: radicle::Node) -> Result<(), radicle::node::Error> { spinner.message(format!("Synced with {} node(s)", result.synced.len())); spinner.finish(); } + let mut urls = Vec::new(); + + for seed in &profile.config.preferred_seeds { + if replicated.contains(&seed.id) { + for resource in updated { + let url = profile + .config + .public_explorer + .url(seed.addr.host.clone(), rid) + .resource(resource); + + urls.push(url); + } + break; + } + } + + // Print URLs to the updated resources. + if !urls.is_empty() { + eprintln!(); + for url in urls { + eprintln!(" {}", cli::format::dim(url)); + } + eprintln!(); + } + Ok(()) } diff --git a/radicle/src/explorer.rs b/radicle/src/explorer.rs new file mode 100644 index 00000000..5cd3ff6f --- /dev/null +++ b/radicle/src/explorer.rs @@ -0,0 +1,128 @@ +use std::str::FromStr; + +use thiserror::Error; + +use crate::prelude::Id; +use crate::{cob, git}; + +#[derive(Debug, Error)] +pub enum ExplorerError { + #[error("invalid explorer URL {0:?}: unknown protocol")] + UnknownProtocol(String), + #[error("invalid explorer URL {0:?}: missing `$host` component")] + MissingHost(String), + #[error("invalid explorer URL {0:?}: missing `$rid` component")] + MissingRid(String), + #[error("invalid explorer URL {0:?}: missing `$path` component")] + MissingPath(String), +} + +/// A resource such as a branch, patch or commit. +#[derive(Debug, Hash, PartialEq, Eq)] +pub enum ExplorerResource { + /// Git tree object. Used for the repository root. + Tree { oid: git::Oid }, + /// A Patch COB. + Patch { id: cob::ObjectId }, +} + +impl std::fmt::Display for ExplorerResource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Tree { oid } => { + write!(f, "/tree/{oid}") + } + Self::Patch { id } => { + write!(f, "/patches/{id}") + } + } + } +} + +/// A URL to a specific repository or resource within a repository. +#[derive(Debug, PartialEq, Eq)] +pub struct ExplorerUrl { + /// URL template. + pub template: Explorer, + /// Host serving the repository. + pub host: String, + /// Repository. + pub rid: Id, + /// Resource under the repository. + pub resource: Option, +} + +impl ExplorerUrl { + /// Set a resource on for this URL. + pub fn resource(mut self, resource: ExplorerResource) -> Self { + self.resource = Some(resource); + self + } +} + +impl std::fmt::Display for ExplorerUrl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str( + self.template + .0 + .replace("$host", &self.host) + .replace("$rid", self.rid.urn().as_str()) + .replace( + "$path", + self.resource + .as_ref() + .map(|r| r.to_string()) + .as_deref() + .unwrap_or(""), + ) + .as_str(), + ) + } +} + +/// A public explorer, eg. `https://app.radicle.xyz`. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(transparent)] +pub struct Explorer(String); + +impl Default for Explorer { + fn default() -> Self { + Self(String::from( + "https://app.radicle.xyz/nodes/$host/$rid$path", + )) + } +} + +impl Explorer { + /// Get the explorer URL, filling in the host and RID. + pub fn url(&self, host: impl ToString, rid: Id) -> ExplorerUrl { + ExplorerUrl { + template: self.clone(), + host: host.to_string(), + rid, + resource: None, + } + } +} + +impl FromStr for Explorer { + type Err = ExplorerError; + + fn from_str(s: &str) -> Result { + let url = s.to_owned(); + + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err(ExplorerError::UnknownProtocol(url)); + } + if !url.contains("$host") { + return Err(ExplorerError::MissingHost(url)); + } + if !url.contains("$rid") { + return Err(ExplorerError::MissingRid(url)); + } + if !url.contains("$path") { + return Err(ExplorerError::MissingPath(url)); + } + Ok(Explorer(url)) + } +} diff --git a/radicle/src/lib.rs b/radicle/src/lib.rs index 322670c9..b6e48468 100644 --- a/radicle/src/lib.rs +++ b/radicle/src/lib.rs @@ -13,6 +13,7 @@ mod canonical; pub mod cli; pub mod cob; pub mod collections; +pub mod explorer; pub mod git; pub mod identity; #[cfg(feature = "logger")] diff --git a/radicle/src/profile.rs b/radicle/src/profile.rs index e2a6ee3b..71042505 100644 --- a/radicle/src/profile.rs +++ b/radicle/src/profile.rs @@ -20,10 +20,11 @@ use thiserror::Error; use crate::crypto::ssh::agent::Agent; use crate::crypto::ssh::{keystore, Keystore, Passphrase}; use crate::crypto::{PublicKey, Signer}; +use crate::explorer::Explorer; use crate::node::policy::config::store::Read; use crate::node::{policy, Alias, AliasStore}; use crate::prelude::Did; -use crate::prelude::{Id, NodeId}; +use crate::prelude::NodeId; use crate::storage::git::transport; use crate::storage::git::Storage; use crate::{cli, git, node}; @@ -81,55 +82,6 @@ pub mod env { } } -#[derive(Debug, Error)] -pub enum ExplorerUrlError { - #[error("invalid explorer URL {0:?}: unknown protocol")] - UnknownProtocol(String), - #[error("invalid explorer URL {0:?}: missing `$host` component")] - MissingHost(String), - #[error("invalid explorer URL {0:?}: missing `$rid` component")] - MissingRid(String), -} - -/// A public explorer, eg. `https://app.radicle.xyz`. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -#[serde(transparent)] -pub struct Explorer(String); - -impl Default for Explorer { - fn default() -> Self { - Self(String::from("https://app.radicle.xyz/nodes/$host/$rid")) - } -} - -impl Explorer { - /// Get the explorer URL, filling in the host and RID. - pub fn url(&self, host: &str, rid: &Id) -> String { - self.0 - .replace("$host", host) - .replace("$rid", rid.urn().as_str()) - } -} - -impl FromStr for Explorer { - type Err = ExplorerUrlError; - - fn from_str(s: &str) -> Result { - let url = s.to_owned(); - - if !url.starts_with("http://") && !url.starts_with("https://") { - return Err(ExplorerUrlError::UnknownProtocol(url)); - } - if !url.contains("$host") { - return Err(ExplorerUrlError::MissingHost(url)); - } - if !url.contains("$rid") { - return Err(ExplorerUrlError::MissingRid(url)); - } - Ok(Explorer(url)) - } -} - #[derive(Debug, Error)] pub enum Error { #[error(transparent)]