Add `rad-init` command
Signed-off-by: Alexis Sellier <alexis@radicle.xyz>
This commit is contained in:
parent
223d36086e
commit
32cd94dd23
|
|
@ -1504,6 +1504,7 @@ dependencies = [
|
||||||
"radicle",
|
"radicle",
|
||||||
"radicle-cob",
|
"radicle-cob",
|
||||||
"radicle-crypto",
|
"radicle-crypto",
|
||||||
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ dialoguer = { version = "0.10.0" }
|
||||||
indicatif = { version = "0.16.2" }
|
indicatif = { version = "0.16.2" }
|
||||||
lexopt = { version = "0.2" }
|
lexopt = { version = "0.2" }
|
||||||
log = { version = "0.4", features = ["std"] }
|
log = { version = "0.4", features = ["std"] }
|
||||||
|
serde_json = { version = "1" }
|
||||||
thiserror = { version = "1" }
|
thiserror = { version = "1" }
|
||||||
zeroize = { version = "1.1" }
|
zeroize = { version = "1.1" }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod init;
|
||||||
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,2 +1,4 @@
|
||||||
#![allow(clippy::collapsible_if)]
|
#![allow(clippy::collapsible_if)]
|
||||||
|
pub mod commands;
|
||||||
|
pub mod git;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
|
|
|
||||||
|
|
@ -682,7 +682,7 @@ fn test_push_and_pull() {
|
||||||
sim.run_while([&mut alice, &mut bob, &mut eve], |s| !s.is_settled());
|
sim.run_while([&mut alice, &mut bob, &mut eve], |s| !s.is_settled());
|
||||||
|
|
||||||
// Alice creates a new project.
|
// Alice creates a new project.
|
||||||
let (proj_id, _) = rad::init(
|
let (proj_id, _, _) = rad::init(
|
||||||
&repo,
|
&repo,
|
||||||
"alice",
|
"alice",
|
||||||
"alice's repo",
|
"alice's repo",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ fn main() -> anyhow::Result<()> {
|
||||||
let repo = radicle::git::raw::Repository::open(cwd)?;
|
let repo = radicle::git::raw::Repository::open(cwd)?;
|
||||||
let profile = Profile::load()?;
|
let profile = Profile::load()?;
|
||||||
let signer = profile.signer()?;
|
let signer = profile.signer()?;
|
||||||
let (id, _) = radicle::rad::init(
|
let (id, _, _) = radicle::rad::init(
|
||||||
&repo,
|
&repo,
|
||||||
&name,
|
&name,
|
||||||
"",
|
"",
|
||||||
|
|
|
||||||
|
|
@ -20,3 +20,13 @@ pub mod test;
|
||||||
|
|
||||||
pub use profile::Profile;
|
pub use profile::Profile;
|
||||||
pub use storage::git::Storage;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ pub fn init<G: Signer>(
|
||||||
default_branch: BranchName,
|
default_branch: BranchName,
|
||||||
signer: &G,
|
signer: &G,
|
||||||
storage: &Storage,
|
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.
|
// TODO: Better error when project id already exists in storage, but remote doesn't.
|
||||||
let pk = signer.public_key();
|
let pk = signer.public_key();
|
||||||
let delegate = identity::Delegate {
|
let delegate = identity::Delegate {
|
||||||
|
|
@ -68,13 +68,6 @@ pub fn init<G: Signer>(
|
||||||
let (id, _, project) = doc.create(pk, "Initialize Radicle\n", storage)?;
|
let (id, _, project) = doc.create(pk, "Initialize Radicle\n", storage)?;
|
||||||
let url = git::Url::from(id).with_namespace(*pk);
|
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::configure_remote(repo, &REMOTE_NAME, &url)?;
|
||||||
git::push(
|
git::push(
|
||||||
repo,
|
repo,
|
||||||
|
|
@ -87,7 +80,7 @@ pub fn init<G: Signer>(
|
||||||
let signed = project.sign_refs(signer)?;
|
let signed = project.sign_refs(signer)?;
|
||||||
let _head = project.set_head()?;
|
let _head = project.set_head()?;
|
||||||
|
|
||||||
Ok((id, signed))
|
Ok((id, doc, signed))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
|
|
@ -121,10 +114,10 @@ pub fn fork_remote<G: Signer, S: storage::WriteStorage>(
|
||||||
|
|
||||||
// Creates or copies the following references:
|
// Creates or copies the following references:
|
||||||
//
|
//
|
||||||
// refs/remotes/<pk>/heads/master
|
// refs/namespaces/<pk>/refs/heads/master
|
||||||
// refs/remotes/<pk>/rad/id
|
// refs/namespaces/<pk>/refs/rad/id
|
||||||
// refs/remotes/<pk>/tags/*
|
// refs/namespaces/<pk>/refs/rad/sigrefs
|
||||||
// refs/remotes/<pk>/rad/signature
|
// refs/namespaces/<pk>/refs/tags/*
|
||||||
|
|
||||||
let me = signer.public_key();
|
let me = signer.public_key();
|
||||||
let project = storage
|
let project = storage
|
||||||
|
|
@ -306,11 +299,19 @@ pub enum RemoteError {
|
||||||
Url(#[from] transport::local::UrlError),
|
Url(#[from] transport::local::UrlError),
|
||||||
#[error("invalid utf-8 string")]
|
#[error("invalid utf-8 string")]
|
||||||
InvalidUtf8,
|
InvalidUtf8,
|
||||||
|
#[error("remote `{0}` not found")]
|
||||||
|
NotFound(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the radicle ("rad") remote of a repository, and return the associated project id.
|
/// 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> {
|
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 = remote.url().ok_or(RemoteError::InvalidUtf8)?;
|
||||||
let url = git::Url::from_str(url)?;
|
let url = git::Url::from_str(url)?;
|
||||||
|
|
||||||
|
|
@ -341,7 +342,7 @@ mod tests {
|
||||||
transport::local::register(storage.clone());
|
transport::local::register(storage.clone());
|
||||||
|
|
||||||
let (repo, _) = fixtures::repository(tempdir.path().join("working"));
|
let (repo, _) = fixtures::repository(tempdir.path().join("working"));
|
||||||
let (proj, refs) = init(
|
let (proj, _, refs) = init(
|
||||||
&repo,
|
&repo,
|
||||||
"acme",
|
"acme",
|
||||||
"Acme's repo",
|
"Acme's repo",
|
||||||
|
|
@ -399,7 +400,7 @@ mod tests {
|
||||||
|
|
||||||
// Alice creates a project.
|
// Alice creates a project.
|
||||||
let (original, _) = fixtures::repository(tempdir.path().join("original"));
|
let (original, _) = fixtures::repository(tempdir.path().join("original"));
|
||||||
let (id, alice_refs) = init(
|
let (id, _, alice_refs) = init(
|
||||||
&original,
|
&original,
|
||||||
"acme",
|
"acme",
|
||||||
"Acme's repo",
|
"Acme's repo",
|
||||||
|
|
@ -431,7 +432,7 @@ mod tests {
|
||||||
transport::local::register(storage.clone());
|
transport::local::register(storage.clone());
|
||||||
|
|
||||||
let (original, _) = fixtures::repository(tempdir.path().join("original"));
|
let (original, _) = fixtures::repository(tempdir.path().join("original"));
|
||||||
let (id, _) = init(
|
let (id, _, _) = init(
|
||||||
&original,
|
&original,
|
||||||
"acme",
|
"acme",
|
||||||
"Acme's repo",
|
"Acme's repo",
|
||||||
|
|
@ -440,6 +441,7 @@ mod tests {
|
||||||
&storage,
|
&storage,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
git::set_upstream(&original, "rad", "master", "refs/heads/master").unwrap();
|
||||||
|
|
||||||
let copy = checkout(id, remote_id, tempdir.path().join("copy"), &storage).unwrap();
|
let copy = checkout(id, remote_id, tempdir.path().join("copy"), &storage).unwrap();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -886,7 +886,7 @@ mod tests {
|
||||||
|
|
||||||
transport::local::register(storage.clone());
|
transport::local::register(storage.clone());
|
||||||
|
|
||||||
let (proj, _) = rad::init(
|
let (proj, _, _) = rad::init(
|
||||||
&source,
|
&source,
|
||||||
"radicle",
|
"radicle",
|
||||||
"radicle",
|
"radicle",
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ pub fn project<P: AsRef<Path>, G: Signer>(
|
||||||
transport::local::register(storage.clone());
|
transport::local::register(storage.clone());
|
||||||
|
|
||||||
let (repo, head) = repository(path);
|
let (repo, head) = repository(path);
|
||||||
let (id, refs) = rad::init(
|
let (id, _, refs) = rad::init(
|
||||||
&repo,
|
&repo,
|
||||||
"acme",
|
"acme",
|
||||||
"Acme's repository",
|
"Acme's repository",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue