Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli: Output explore URLs on push
cloudhead committed 2 years ago
commit 1e2776cb0801129b8f1de375b76dac446ead9f17
parent fd38d88e14027698497dc724b529d4a69f5abdca
10 files changed +285 -74
modified 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
added 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
+
```
modified 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<SyncResult<Option<String>>, radicle::node::Error> {
+
) -> Result<SyncResult<Option<ExplorerUrl>>, 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)),
                });
            }
        }
modified radicle-cli/tests/commands.rs
@@ -1675,6 +1675,41 @@ fn rad_patch_pull_update() {
}

#[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();
    let alice = environment.node(Config::test(Alias::new("alice")));
modified 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"
                ],
modified 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<G: Signer + cyphernet::Ecdh> NodeHandle<G> {
        self.rad("sync", &[rid.to_string().as_str(), "--announce"], cwd)
    }

+
    /// Init a repo.
+
    pub fn init<P: AsRef<Path>>(&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<P: AsRef<Path>>(&self, cmd: &str, args: &[&str], cwd: P) -> io::Result<()> {
        let cwd = cwd.as_ref();
modified 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<git::RefString, git::RefString>),
+
    /// 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<G: Signer>(
    signer: &G,
    profile: &Profile,
    opts: Options,
-
) -> Result<(), Error> {
+
) -> Result<Option<ExplorerResource>, 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<G: Signer>(
                    }
                }
            }
-

-
            Ok(())
+
            Ok(Some(ExplorerResource::Patch { id: patch }))
        }
        Err(e) => Err(e),
    };
@@ -440,7 +444,7 @@ fn patch_update<G: Signer>(
    stored: &storage::git::Repository,
    signer: &G,
    opts: Options,
-
) -> Result<(), Error> {
+
) -> Result<Option<ExplorerResource>, 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<G: Signer>(

    // 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<G: Signer>(
        patch_merge(patch, revision, head, working, signer)?;
    }

-
    Ok(())
+
    Ok(Some(ExplorerResource::Patch { id: patch_id }))
}

fn push<G: Signer>(
@@ -501,7 +505,7 @@ fn push<G: Signer>(
    working: &git::raw::Repository,
    stored: &storage::git::Repository,
    signer: &G,
-
) -> Result<(), Error> {
+
) -> Result<Option<ExplorerResource>, 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<G: Signer>(
            }
        }
    }
-
    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<Item = ExplorerResource>,
+
    mut node: radicle::Node,
+
    profile: &Profile,
+
) -> Result<(), radicle::node::Error> {
    let seeds = node.seeds(rid)?;
    let connected = seeds.connected().map(|s| s.nid).collect::<Vec<_>>();
+
    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(())
}
added 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<ExplorerResource>,
+
}
+

+
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<Self, Self::Err> {
+
        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))
+
    }
+
}
modified 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")]
modified 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};
@@ -82,55 +83,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<Self, Self::Err> {
-
        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)]
    Io(#[from] io::Error),