Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add `rad-init` command
Alexis Sellier committed 3 years ago
commit 32cd94dd23ebe05288a151852d4e0e0cd752bcb2
parent 223d36086ec861e181c43ee413a51baf141b2813
12 files changed +813 -21
modified Cargo.lock
@@ -1504,6 +1504,7 @@ dependencies = [
 "radicle",
 "radicle-cob",
 "radicle-crypto",
+
 "serde_json",
 "thiserror",
 "zeroize",
]
modified radicle-cli/Cargo.toml
@@ -12,6 +12,7 @@ dialoguer = { version = "0.10.0" }
indicatif = { version = "0.16.2" }
lexopt = { version = "0.2" }
log = { version = "0.4", features = ["std"] }
+
serde_json = { version = "1" }
thiserror = { version = "1" }
zeroize = { version = "1.1" }

added radicle-cli/src/commands.rs
@@ -0,0 +1 @@
+
pub mod init;
added radicle-cli/src/commands/init.rs
@@ -0,0 +1,313 @@
+
#![allow(clippy::or_fun_call)]
+
use std::convert::TryFrom;
+
use std::env;
+
use std::ffi::OsString;
+
use std::path::PathBuf;
+

+
use anyhow::{anyhow, bail, Context as _};
+

+
use radicle::crypto::ssh;
+
use radicle::git::RefString;
+
use radicle::node::NodeId;
+

+
use crate::git;
+
use crate::terminal as term;
+
use crate::terminal::args::{Args, Error, Help};
+
use crate::terminal::Interactive;
+
use radicle::profile;
+
use serde_json as json;
+

+
pub const HELP: Help = Help {
+
    name: "init",
+
    description: "Initialize radicle projects from git repositories",
+
    version: env!("CARGO_PKG_VERSION"),
+
    usage: r#"
+
Usage
+

+
    rad init [<path>] [<option>...]
+

+
Options
+

+
    --name               Name of the project
+
    --description        Description of the project
+
    --default-branch     The default branch of the project
+
    --set-upstream, -u   Setup the upstream of the default branch
+
    --setup-signing      Setup the radicle key as a signing key for this repository
+
    --no-confirm         Don't ask for confirmation during setup
+
    --help               Print help
+
"#,
+
};
+

+
#[derive(Default)]
+
pub struct Options {
+
    pub path: Option<PathBuf>,
+
    pub name: Option<String>,
+
    pub description: Option<String>,
+
    pub branch: Option<String>,
+
    pub interactive: Interactive,
+
    pub setup_signing: bool,
+
    pub set_upstream: bool,
+
}
+

+
impl Args for Options {
+
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
+
        use lexopt::prelude::*;
+

+
        let mut parser = lexopt::Parser::from_args(args);
+
        let mut path: Option<PathBuf> = None;
+

+
        let mut name = None;
+
        let mut description = None;
+
        let mut branch = None;
+
        let mut interactive = Interactive::Yes;
+
        let mut set_upstream = false;
+
        let mut setup_signing = false;
+

+
        while let Some(arg) = parser.next()? {
+
            match arg {
+
                Long("name") if name.is_none() => {
+
                    let value = parser
+
                        .value()?
+
                        .to_str()
+
                        .ok_or(anyhow::anyhow!(
+
                            "invalid project name specified with `--name`"
+
                        ))?
+
                        .to_owned();
+
                    name = Some(value);
+
                }
+
                Long("description") if description.is_none() => {
+
                    let value = parser
+
                        .value()?
+
                        .to_str()
+
                        .ok_or(anyhow::anyhow!(
+
                            "invalid project description specified with `--description`"
+
                        ))?
+
                        .to_owned();
+

+
                    description = Some(value);
+
                }
+
                Long("default-branch") if branch.is_none() => {
+
                    let value = parser
+
                        .value()?
+
                        .to_str()
+
                        .ok_or(anyhow::anyhow!(
+
                            "invalid branch specified with `--default-branch`"
+
                        ))?
+
                        .to_owned();
+

+
                    branch = Some(value);
+
                }
+
                Long("set-upstream") | Short('u') => {
+
                    set_upstream = true;
+
                }
+
                Long("setup-signing") => {
+
                    setup_signing = true;
+
                }
+
                Long("no-confirm") => {
+
                    interactive = Interactive::No;
+
                }
+
                Long("help") => {
+
                    return Err(Error::Help.into());
+
                }
+
                Value(val) if path.is_none() => {
+
                    path = Some(val.into());
+
                }
+
                _ => return Err(anyhow::anyhow!(arg.unexpected())),
+
            }
+
        }
+

+
        Ok((
+
            Options {
+
                path,
+
                name,
+
                description,
+
                branch,
+
                interactive,
+
                set_upstream,
+
                setup_signing,
+
            },
+
            vec![],
+
        ))
+
    }
+
}
+

+
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
    let profile = ctx.profile()?;
+

+
    if git::check_version().is_err() {
+
        term::warning(&format!(
+
            "Your git version is unsupported, please upgrade to {} or later",
+
            git::VERSION_REQUIRED,
+
        ));
+
        term::blank();
+
    }
+
    init(options, &profile)
+
}
+

+
pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()> {
+
    let cwd = std::env::current_dir()?;
+
    let path = options.path.unwrap_or_else(|| cwd.clone());
+
    let path = path.as_path().canonicalize()?;
+
    let interactive = options.interactive;
+

+
    term::headline(&format!(
+
        "Initializing local 🌱 project in {}",
+
        if path == cwd {
+
            term::format::highlight(".")
+
        } else {
+
            term::format::highlight(&path.display())
+
        }
+
    ));
+

+
    let repo = git::Repository::open(&path)?;
+
    if let Ok((remote, _)) = git::rad_remote(&repo) {
+
        if let Some(remote) = remote.url() {
+
            bail!("repository is already initialized with remote {remote}");
+
        }
+
    }
+

+
    let signer = term::signer(profile)?;
+
    let head: String = repo
+
        .head()
+
        .ok()
+
        .and_then(|head| head.shorthand().map(|h| h.to_owned()))
+
        .ok_or_else(|| anyhow!("error: repository head does not point to any commits"))?;
+

+
    let name = options.name.unwrap_or_else(|| {
+
        let default = path.file_name().map(|f| f.to_string_lossy().to_string());
+
        term::text_input("Name", default).unwrap()
+
    });
+
    let description = options
+
        .description
+
        .unwrap_or_else(|| term::text_input("Description", None).unwrap());
+
    let branch = options.branch.unwrap_or_else(|| {
+
        if interactive.yes() {
+
            term::text_input("Default branch", Some(head)).unwrap()
+
        } else {
+
            head
+
        }
+
    });
+
    let branch = RefString::try_from(branch.clone())
+
        .map_err(|e| anyhow!("invalid branch name {:?}: {}", branch, e))?;
+

+
    let mut spinner = term::spinner("Initializing...");
+

+
    match radicle::rad::init(
+
        &repo,
+
        &name,
+
        &description,
+
        branch,
+
        &signer,
+
        &profile.storage,
+
    ) {
+
        Ok((id, doc, _)) => {
+
            spinner.message(format!(
+
                "Project {} created",
+
                term::format::highlight(&doc.name)
+
            ));
+
            spinner.finish();
+

+
            if interactive.no() {
+
                term::blob(json::to_string_pretty(&doc.payload)?);
+
                term::blank();
+
            }
+

+
            if options.set_upstream || git::branch_remote(&repo, &doc.default_branch).is_err() {
+
                // Setup eg. `master` -> `rad/master`
+
                radicle::git::set_upstream(
+
                    &repo,
+
                    &radicle::rad::REMOTE_NAME,
+
                    &doc.default_branch,
+
                    &radicle::git::refs::workdir::branch(&doc.default_branch),
+
                )?;
+
            }
+

+
            if options.setup_signing {
+
                // Setup radicle signing key.
+
                self::setup_signing(profile.id(), &repo, interactive)?;
+
            }
+

+
            term::blank();
+
            term::info!(
+
                "Your project id is {}. You can show it any time by running:",
+
                term::format::highlight(id)
+
            );
+
            term::indented(&term::format::secondary("rad ."));
+

+
            term::blank();
+
            term::info!("To publish your project to the network, run:");
+
            term::indented(&term::format::secondary("rad push"));
+
            term::blank();
+
        }
+
        Err(err) => {
+
            spinner.failed();
+
            term::blank();
+
            anyhow::bail!(err);
+

+
            // TODO: Handle error: "this repository is already initialized with remote {}"
+
            // TODO: Handle error: "the `{}` branch was either not found, or has no commits"
+
        }
+
    }
+

+
    Ok(())
+
}
+

+
/// Setup radicle key as commit signing key in repository.
+
pub fn setup_signing(
+
    node_id: &NodeId,
+
    repo: &git::Repository,
+
    interactive: Interactive,
+
) -> anyhow::Result<()> {
+
    let repo = repo
+
        .workdir()
+
        .ok_or(anyhow!("cannot setup signing in bare repository"))?;
+
    let key = ssh::fmt::fingerprint(node_id);
+
    let yes = if !git::is_signing_configured(repo)? {
+
        term::headline(&format!(
+
            "Configuring 🌱 signing key {}...",
+
            term::format::tertiary(key)
+
        ));
+
        true
+
    } else if interactive.yes() {
+
        term::confirm(&format!(
+
            "Configure 🌱 signing key {} in local checkout?",
+
            term::format::tertiary(key),
+
        ))
+
    } else {
+
        true
+
    };
+

+
    if yes {
+
        match git::write_gitsigners(repo, [node_id]) {
+
            Ok(file) => {
+
                git::ignore(repo, file.as_path())?;
+

+
                term::success!("Created {} file", term::format::tertiary(file.display()));
+
            }
+
            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
+
                let ssh_key = ssh::fmt::key(node_id);
+
                let gitsigners = term::format::tertiary(".gitsigners");
+
                term::success!("Found existing {} file", gitsigners);
+

+
                let ssh_keys =
+
                    git::read_gitsigners(repo).context("error reading .gitsigners file")?;
+

+
                if ssh_keys.contains(&ssh_key) {
+
                    term::success!("Signing key is already in {} file", gitsigners);
+
                } else if term::confirm(&format!("Add signing key to {}?", gitsigners)) {
+
                    git::add_gitsigners(repo, [node_id])?;
+
                }
+
            }
+
            Err(err) => {
+
                return Err(err.into());
+
            }
+
        }
+
        git::configure_signing(repo, node_id)?;
+

+
        term::success!(
+
            "Signing configured in {}",
+
            term::format::tertiary(".git/config")
+
        );
+
    }
+
    Ok(())
+
}
added radicle-cli/src/git.rs
@@ -0,0 +1,462 @@
+
//! Git-related functions and types.
+
use std::collections::HashSet;
+
use std::fs::{File, OpenOptions};
+
use std::io;
+
use std::io::Write;
+
use std::path::{Path, PathBuf};
+
use std::process::Command;
+
use std::str::FromStr;
+

+
use anyhow::anyhow;
+
use anyhow::Context as _;
+

+
use radicle::crypto::ssh;
+
use radicle::git::raw as git2;
+
use radicle::prelude::{Id, NodeId};
+

+
pub use radicle::git::raw::{
+
    build::CheckoutBuilder, AnnotatedCommit, Commit, Direction, ErrorCode, MergeAnalysis,
+
    MergeOptions, Oid, Reference, Repository, Signature,
+
};
+

+
pub const CONFIG_COMMIT_GPG_SIGN: &str = "commit.gpgsign";
+
pub const CONFIG_SIGNING_KEY: &str = "user.signingkey";
+
pub const CONFIG_GPG_FORMAT: &str = "gpg.format";
+
pub const CONFIG_GPG_SSH_PROGRAM: &str = "gpg.ssh.program";
+
pub const CONFIG_GPG_SSH_ALLOWED_SIGNERS: &str = "gpg.ssh.allowedSignersFile";
+

+
/// Minimum required git version.
+
pub const VERSION_REQUIRED: Version = Version {
+
    major: 2,
+
    minor: 34,
+
    patch: 0,
+
};
+

+
/// A parsed git version.
+
#[derive(PartialEq, Eq, Debug, PartialOrd, Ord)]
+
pub struct Version {
+
    pub major: u8,
+
    pub minor: u8,
+
    pub patch: u8,
+
}
+

+
impl std::fmt::Display for Version {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
+
    }
+
}
+

+
impl std::str::FromStr for Version {
+
    type Err = anyhow::Error;
+

+
    fn from_str(input: &str) -> Result<Self, Self::Err> {
+
        let rest = input
+
            .strip_prefix("git version ")
+
            .ok_or_else(|| anyhow!("malformed git version string"))?;
+
        let rest = rest
+
            .split(' ')
+
            .next()
+
            .ok_or_else(|| anyhow!("malformed git version string"))?;
+
        let rest = rest.trim_end();
+

+
        let mut parts = rest.split('.');
+
        let major = parts
+
            .next()
+
            .ok_or_else(|| anyhow!("malformed git version string"))?
+
            .parse()?;
+
        let minor = parts
+
            .next()
+
            .ok_or_else(|| anyhow!("malformed git version string"))?
+
            .parse()?;
+

+
        let patch = match parts.next() {
+
            None => 0,
+
            Some(patch) => patch.parse()?,
+
        };
+

+
        Ok(Self {
+
            major,
+
            minor,
+
            patch,
+
        })
+
    }
+
}
+

+
/// Get the system's git version.
+
pub fn version() -> Result<Version, anyhow::Error> {
+
    let output = Command::new("git").arg("version").output()?;
+

+
    if output.status.success() {
+
        let output = String::from_utf8(output.stdout)?;
+
        let version = output
+
            .parse()
+
            .with_context(|| format!("unable to parse git version string {:?}", output))?;
+

+
        return Ok(version);
+
    }
+
    Err(anyhow!("failed to run `git version`"))
+
}
+

+
/// Get the git repository in the current directory.
+
pub fn repository() -> Result<Repository, anyhow::Error> {
+
    match Repository::open(".") {
+
        Ok(repo) => Ok(repo),
+
        Err(err) => Err(err).context("the current working directory is not a git repository"),
+
    }
+
}
+

+
/// Execute a git command by spawning a child process.
+
pub fn git<S: AsRef<std::ffi::OsStr>>(
+
    repo: &std::path::Path,
+
    args: impl IntoIterator<Item = S>,
+
) -> Result<String, anyhow::Error> {
+
    let output = Command::new("git").current_dir(repo).args(args).output()?;
+

+
    if output.status.success() {
+
        let out = if output.stdout.is_empty() {
+
            &output.stderr
+
        } else {
+
            &output.stdout
+
        };
+
        return Ok(String::from_utf8_lossy(out).into());
+
    }
+

+
    Err(anyhow::Error::new(std::io::Error::new(
+
        std::io::ErrorKind::Other,
+
        String::from_utf8_lossy(&output.stderr),
+
    )))
+
}
+

+
/// Configure SSH signing in the given git repo, for the given peer.
+
pub fn configure_signing(repo: &Path, node_id: &NodeId) -> Result<(), anyhow::Error> {
+
    let key = ssh::fmt::key(node_id);
+

+
    git(repo, ["config", "--local", CONFIG_SIGNING_KEY, &key])?;
+
    git(repo, ["config", "--local", CONFIG_GPG_FORMAT, "ssh"])?;
+
    git(repo, ["config", "--local", CONFIG_COMMIT_GPG_SIGN, "true"])?;
+
    git(
+
        repo,
+
        ["config", "--local", CONFIG_GPG_SSH_PROGRAM, "ssh-keygen"],
+
    )?;
+
    git(
+
        repo,
+
        [
+
            "config",
+
            "--local",
+
            CONFIG_GPG_SSH_ALLOWED_SIGNERS,
+
            ".gitsigners",
+
        ],
+
    )?;
+

+
    Ok(())
+
}
+

+
/// Write a `.gitsigners` file in the given repository.
+
/// Fails if the file already exists.
+
pub fn write_gitsigners<'a>(
+
    repo: &Path,
+
    signers: impl IntoIterator<Item = &'a NodeId>,
+
) -> Result<PathBuf, io::Error> {
+
    let path = Path::new(".gitsigners");
+
    let mut file = OpenOptions::new()
+
        .write(true)
+
        .create_new(true)
+
        .open(repo.join(path))?;
+

+
    for node_id in signers.into_iter() {
+
        write_gitsigner(&mut file, node_id)?;
+
    }
+
    Ok(path.to_path_buf())
+
}
+

+
/// Add signers to the repository's `.gitsigners` file.
+
pub fn add_gitsigners<'a>(
+
    path: &Path,
+
    signers: impl IntoIterator<Item = &'a NodeId>,
+
) -> Result<(), io::Error> {
+
    let mut file = OpenOptions::new()
+
        .append(true)
+
        .open(path.join(".gitsigners"))?;
+

+
    for node_id in signers.into_iter() {
+
        write_gitsigner(&mut file, node_id)?;
+
    }
+
    Ok(())
+
}
+

+
/// Read a `.gitsigners` file. Returns SSH keys.
+
pub fn read_gitsigners(path: &Path) -> Result<HashSet<String>, io::Error> {
+
    use std::io::BufRead;
+

+
    let mut keys = HashSet::new();
+
    let file = File::open(path.join(".gitsigners"))?;
+

+
    for line in io::BufReader::new(file).lines() {
+
        let line = line?;
+
        if let Some((label, key)) = line.split_once(' ') {
+
            if let Ok(peer) = NodeId::from_str(label) {
+
                let expected = ssh::fmt::key(&peer);
+
                if key != expected {
+
                    return Err(io::Error::new(
+
                        io::ErrorKind::InvalidData,
+
                        "key does not match peer id",
+
                    ));
+
                }
+
            }
+
            keys.insert(key.to_owned());
+
        }
+
    }
+
    Ok(keys)
+
}
+

+
/// Add a path to the repository's git ignore file. Creates the
+
/// ignore file if it does not exist.
+
pub fn ignore(repo: &Path, item: &Path) -> Result<(), io::Error> {
+
    let mut ignore = OpenOptions::new()
+
        .append(true)
+
        .create(true)
+
        .open(repo.join(".gitignore"))?;
+

+
    writeln!(ignore, "{}", item.display())
+
}
+

+
/// Check whether SSH or GPG signing is configured in the given repository.
+
pub fn is_signing_configured(repo: &Path) -> Result<bool, anyhow::Error> {
+
    Ok(git(repo, ["config", CONFIG_SIGNING_KEY]).is_ok())
+
}
+

+
/// Return the list of radicle remotes for the given repository.
+
pub fn remotes(repo: &git2::Repository) -> anyhow::Result<Vec<(String, NodeId)>> {
+
    let mut remotes = Vec::new();
+

+
    for name in repo.remotes().iter().flatten().flatten() {
+
        let remote = repo.find_remote(name)?;
+
        for refspec in remote.refspecs() {
+
            if refspec.direction() != git2::Direction::Fetch {
+
                continue;
+
            }
+
            if let Some((peer, _)) = refspec.src().and_then(self::parse_remote) {
+
                remotes.push((name.to_owned(), peer));
+
            }
+
        }
+
    }
+

+
    Ok(remotes)
+
}
+

+
/// Get the repository's "rad" remote.
+
pub fn rad_remote(repo: &Repository) -> anyhow::Result<(git2::Remote, Id)> {
+
    match radicle::rad::remote(repo) {
+
        Ok((remote, id)) => Ok((remote, id)),
+
        Err(radicle::rad::RemoteError::NotFound(_)) => Err(anyhow!(
+
            "could not find radicle remote in git config; did you forget to run `rad init`?"
+
        )),
+
        Err(err) => Err(err).context("could not read git remote configuration"),
+
    }
+
}
+

+
/// Setup an upstream tracking branch for the given remote and branch.
+
/// Creates the tracking branch if it does not exist.
+
///
+
/// > scooby/master...rad/scooby/heads/master
+
///
+
pub fn set_tracking(repo: &Repository, remote: &NodeId, branch: &str) -> anyhow::Result<String> {
+
    // The tracking branch name, eg. 'scooby/master'
+
    let branch_name = format!("{remote}/{branch}");
+
    // The remote branch being tracked, eg. 'rad/scooby/heads/master'
+
    let remote_branch_name = format!("rad/{remote}/heads/{branch}");
+
    // The target reference this branch should be set to.
+
    let target = format!("refs/remotes/{remote_branch_name}");
+
    let reference = repo.find_reference(&target)?;
+
    let commit = reference.peel_to_commit()?;
+

+
    repo.branch(&branch_name, &commit, true)?
+
        .set_upstream(Some(&remote_branch_name))?;
+

+
    Ok(branch_name)
+
}
+

+
/// Get the name of the remote of the given branch, if any.
+
pub fn branch_remote(repo: &Repository, branch: &str) -> anyhow::Result<String> {
+
    let cfg = repo.config()?;
+
    let remote = cfg.get_string(&format!("branch.{}.remote", branch))?;
+

+
    Ok(remote)
+
}
+

+
/// Call `git pull`, optionally with `--force`.
+
pub fn pull(repo: &Path, force: bool) -> anyhow::Result<String> {
+
    let mut args = vec!["-c", "color.diff=always", "pull", "-v"];
+
    if force {
+
        args.push("--force");
+
    }
+
    git(repo, args)
+
}
+

+
/// Clone the given repository via `git clone` into a directory.
+
pub fn clone(repo: &str, destination: &Path) -> Result<String, anyhow::Error> {
+
    git(
+
        Path::new("."),
+
        ["clone", repo, &destination.to_string_lossy()],
+
    )
+
}
+

+
/// Check that the system's git version is supported. Returns an error otherwise.
+
pub fn check_version() -> Result<Version, anyhow::Error> {
+
    let git_version = self::version()?;
+

+
    if git_version < VERSION_REQUIRED {
+
        anyhow::bail!("a minimum git version of {} is required", VERSION_REQUIRED);
+
    }
+
    Ok(git_version)
+
}
+

+
/// Parse a remote refspec into a peer id and ref.
+
pub fn parse_remote(refspec: &str) -> Option<(NodeId, &str)> {
+
    refspec
+
        .strip_prefix("refs/remotes/")
+
        .and_then(|s| s.split_once('/'))
+
        .and_then(|(peer, r)| NodeId::from_str(peer).ok().map(|p| (p, r)))
+
}
+

+
pub fn view_diff(
+
    repo: &git2::Repository,
+
    left: &git2::Oid,
+
    right: &git2::Oid,
+
) -> anyhow::Result<()> {
+
    // TODO(erikli): Replace with repo.diff()
+
    let workdir = repo
+
        .workdir()
+
        .ok_or_else(|| anyhow!("Could not get workdir current repository."))?;
+

+
    let left = format!("{:.7}", left.to_string());
+
    let right = format!("{:.7}", right.to_string());
+

+
    let mut git = Command::new("git")
+
        .current_dir(workdir)
+
        .args(["diff", &left, &right])
+
        .spawn()?;
+
    git.wait()?;
+

+
    Ok(())
+
}
+

+
pub fn add_tag(
+
    repo: &git2::Repository,
+
    message: &str,
+
    patch_tag_name: &str,
+
) -> anyhow::Result<git2::Oid> {
+
    let head = repo.head()?;
+
    let commit = head.peel(git2::ObjectType::Commit).unwrap();
+
    let oid = repo.tag(patch_tag_name, &commit, &repo.signature()?, message, false)?;
+

+
    Ok(oid)
+
}
+

+
pub fn push_tag(tag_name: &str) -> anyhow::Result<String> {
+
    git(Path::new("."), vec!["push", "rad", "tag", tag_name])
+
}
+

+
pub fn push_branch(name: &str) -> anyhow::Result<String> {
+
    git(Path::new("."), vec!["push", "rad", name])
+
}
+

+
fn write_gitsigner(mut w: impl io::Write, signer: &NodeId) -> io::Result<()> {
+
    writeln!(w, "{} {}", signer, ssh::fmt::key(signer))
+
}
+

+
/// From a commit hash, return the signer's fingerprint, if any.
+
pub fn commit_ssh_fingerprint(path: &Path, sha1: &str) -> Result<Option<String>, io::Error> {
+
    use std::io::BufRead;
+
    use std::io::BufReader;
+

+
    let output = Command::new("git")
+
        .current_dir(path) // We need to place the command execution in the git dir
+
        .args(["show", sha1, "--pretty=%GF", "--raw"])
+
        .output()?;
+

+
    if !output.status.success() {
+
        return Err(io::Error::new(
+
            io::ErrorKind::Other,
+
            String::from_utf8_lossy(&output.stderr),
+
        ));
+
    }
+

+
    let string = BufReader::new(output.stdout.as_slice())
+
        .lines()
+
        .next()
+
        .transpose()?;
+

+
    // We only return a fingerprint if it's not an empty string
+
    if let Some(s) = string {
+
        if !s.is_empty() {
+
            return Ok(Some(s));
+
        }
+
    }
+

+
    Ok(None)
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use super::*;
+
    use std::str::FromStr;
+

+
    #[test]
+
    fn test_version_ord() {
+
        assert!(
+
            Version {
+
                major: 2,
+
                minor: 34,
+
                patch: 1
+
            } > Version {
+
                major: 2,
+
                minor: 34,
+
                patch: 0
+
            }
+
        );
+
        assert!(
+
            Version {
+
                major: 2,
+
                minor: 24,
+
                patch: 12
+
            } < Version {
+
                major: 2,
+
                minor: 34,
+
                patch: 0
+
            }
+
        );
+
    }
+

+
    #[test]
+
    fn test_version_from_str() {
+
        assert_eq!(
+
            Version::from_str("git version 2.34.1\n").ok(),
+
            Some(Version {
+
                major: 2,
+
                minor: 34,
+
                patch: 1
+
            })
+
        );
+

+
        assert_eq!(
+
            Version::from_str("git version 2.34.1 (macOS)").ok(),
+
            Some(Version {
+
                major: 2,
+
                minor: 34,
+
                patch: 1
+
            })
+
        );
+

+
        assert_eq!(
+
            Version::from_str("git version 2.34").ok(),
+
            Some(Version {
+
                major: 2,
+
                minor: 34,
+
                patch: 0
+
            })
+
        );
+

+
        assert!(Version::from_str("2.34").is_err());
+
    }
+
}
modified radicle-cli/src/lib.rs
@@ -1,2 +1,4 @@
#![allow(clippy::collapsible_if)]
+
pub mod commands;
+
pub mod git;
pub mod terminal;
modified radicle-node/src/test/tests.rs
@@ -682,7 +682,7 @@ fn test_push_and_pull() {
    sim.run_while([&mut alice, &mut bob, &mut eve], |s| !s.is_settled());

    // Alice creates a new project.
-
    let (proj_id, _) = rad::init(
+
    let (proj_id, _, _) = rad::init(
        &repo,
        "alice",
        "alice's repo",
modified radicle-tools/src/rad-init.rs
@@ -8,7 +8,7 @@ fn main() -> anyhow::Result<()> {
    let repo = radicle::git::raw::Repository::open(cwd)?;
    let profile = Profile::load()?;
    let signer = profile.signer()?;
-
    let (id, _) = radicle::rad::init(
+
    let (id, _, _) = radicle::rad::init(
        &repo,
        &name,
        "",
modified radicle/src/lib.rs
@@ -20,3 +20,13 @@ pub mod test;

pub use profile::Profile;
pub use storage::git::Storage;
+

+
pub mod prelude {
+
    use super::*;
+

+
    pub use crypto::{Signer, Verified};
+
    pub use identity::{Doc, Id};
+
    pub use node::NodeId;
+
    pub use profile::Profile;
+
    pub use storage::BranchName;
+
}
modified radicle/src/rad.rs
@@ -49,7 +49,7 @@ pub fn init<G: Signer>(
    default_branch: BranchName,
    signer: &G,
    storage: &Storage,
-
) -> Result<(Id, SignedRefs<Verified>), InitError> {
+
) -> Result<(Id, identity::Doc<Verified>, SignedRefs<Verified>), InitError> {
    // TODO: Better error when project id already exists in storage, but remote doesn't.
    let pk = signer.public_key();
    let delegate = identity::Delegate {
@@ -68,13 +68,6 @@ pub fn init<G: Signer>(
    let (id, _, project) = doc.create(pk, "Initialize Radicle\n", storage)?;
    let url = git::Url::from(id).with_namespace(*pk);

-
    git::set_upstream(
-
        repo,
-
        &REMOTE_NAME,
-
        &default_branch,
-
        &git::refs::workdir::branch(&default_branch),
-
    )?;
-

    git::configure_remote(repo, &REMOTE_NAME, &url)?;
    git::push(
        repo,
@@ -87,7 +80,7 @@ pub fn init<G: Signer>(
    let signed = project.sign_refs(signer)?;
    let _head = project.set_head()?;

-
    Ok((id, signed))
+
    Ok((id, doc, signed))
}

#[derive(Error, Debug)]
@@ -121,10 +114,10 @@ pub fn fork_remote<G: Signer, S: storage::WriteStorage>(

    // Creates or copies the following references:
    //
-
    // refs/remotes/<pk>/heads/master
-
    // refs/remotes/<pk>/rad/id
-
    // refs/remotes/<pk>/tags/*
-
    // refs/remotes/<pk>/rad/signature
+
    // refs/namespaces/<pk>/refs/heads/master
+
    // refs/namespaces/<pk>/refs/rad/id
+
    // refs/namespaces/<pk>/refs/rad/sigrefs
+
    // refs/namespaces/<pk>/refs/tags/*

    let me = signer.public_key();
    let project = storage
@@ -306,11 +299,19 @@ pub enum RemoteError {
    Url(#[from] transport::local::UrlError),
    #[error("invalid utf-8 string")]
    InvalidUtf8,
+
    #[error("remote `{0}` not found")]
+
    NotFound(String),
}

/// Get the radicle ("rad") remote of a repository, and return the associated project id.
pub fn remote(repo: &git2::Repository) -> Result<(git2::Remote<'_>, Id), RemoteError> {
-
    let remote = repo.find_remote(&REMOTE_NAME)?;
+
    let remote = repo.find_remote(&REMOTE_NAME).map_err(|e| {
+
        if e.code() == git2::ErrorCode::NotFound {
+
            RemoteError::NotFound(REMOTE_NAME.to_string())
+
        } else {
+
            RemoteError::from(e)
+
        }
+
    })?;
    let url = remote.url().ok_or(RemoteError::InvalidUtf8)?;
    let url = git::Url::from_str(url)?;

@@ -341,7 +342,7 @@ mod tests {
        transport::local::register(storage.clone());

        let (repo, _) = fixtures::repository(tempdir.path().join("working"));
-
        let (proj, refs) = init(
+
        let (proj, _, refs) = init(
            &repo,
            "acme",
            "Acme's repo",
@@ -399,7 +400,7 @@ mod tests {

        // Alice creates a project.
        let (original, _) = fixtures::repository(tempdir.path().join("original"));
-
        let (id, alice_refs) = init(
+
        let (id, _, alice_refs) = init(
            &original,
            "acme",
            "Acme's repo",
@@ -431,7 +432,7 @@ mod tests {
        transport::local::register(storage.clone());

        let (original, _) = fixtures::repository(tempdir.path().join("original"));
-
        let (id, _) = init(
+
        let (id, _, _) = init(
            &original,
            "acme",
            "Acme's repo",
@@ -440,6 +441,7 @@ mod tests {
            &storage,
        )
        .unwrap();
+
        git::set_upstream(&original, "rad", "master", "refs/heads/master").unwrap();

        let copy = checkout(id, remote_id, tempdir.path().join("copy"), &storage).unwrap();

modified radicle/src/storage/git.rs
@@ -886,7 +886,7 @@ mod tests {

        transport::local::register(storage.clone());

-
        let (proj, _) = rad::init(
+
        let (proj, _, _) = rad::init(
            &source,
            "radicle",
            "radicle",
modified radicle/src/test/fixtures.rs
@@ -37,7 +37,7 @@ pub fn project<P: AsRef<Path>, G: Signer>(
    transport::local::register(storage.clone());

    let (repo, head) = repository(path);
-
    let (id, refs) = rad::init(
+
    let (id, _, refs) = rad::init(
        &repo,
        "acme",
        "Acme's repository",