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-cob",
|
||||
"radicle-crypto",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"zeroize",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
pub mod commands;
|
||||
pub mod git;
|
||||
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());
|
||||
|
||||
// Alice creates a new project.
|
||||
let (proj_id, _) = rad::init(
|
||||
let (proj_id, _, _) = rad::init(
|
||||
&repo,
|
||||
"alice",
|
||||
"alice's repo",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -886,7 +886,7 @@ mod tests {
|
|||
|
||||
transport::local::register(storage.clone());
|
||||
|
||||
let (proj, _) = rad::init(
|
||||
let (proj, _, _) = rad::init(
|
||||
&source,
|
||||
"radicle",
|
||||
"radicle",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue