//! A Git remote helper for interacting with Radicle storage and notifying //! `radicle-node`. //! //! Refer to for documentation //! on Git remote helpers. //! //! Usage of standard streams: //! - Standard Error ([`eprintln`]) is used for communicating with the user. //! - Standard Output ([`println`]) is used for communicating with Git tooling. //! //! This process assumes that the environment variable `GIT_DIR` is set //! appropriately (to the repository being pushed from or fetched to), as //! mentioned in the documentation on Git remote helpers. //! //! For example, the following two mechanisms rely on `GIT_DIR` being set: //! - [`git::raw::Repository::open_from_env`] to open the repository //! - [`radicle::git::run`] (with [`None`] as first argument) to invoke `git` mod fetch; mod list; mod protocol; mod push; mod service; use std::io::{self, BufRead, Write}; use std::path::PathBuf; use std::process; use std::str::FromStr; use std::{env, fmt}; use radicle::cob::store::access::{ReadOnly, WriteAs}; use thiserror::Error; use radicle::prelude::NodeId; use radicle::storage::git::transport::local::{Url, UrlError}; use radicle::storage::{ReadRepository, WriteStorage}; use radicle::version::Version; use radicle::{Profile, git, storage}; use radicle::{cob, profile}; use radicle_cli::terminal as cli; use crate::protocol::{Command, Line, LineReader}; const VERSION: Version = Version { name: env!("CARGO_BIN_NAME"), commit: env!("GIT_HEAD"), version: env!("RADICLE_VERSION"), timestamp: env!("SOURCE_DATE_EPOCH"), }; fn main() { let mut args = env::args(); if let Some(lvl) = radicle::logger::env_level() { let logger = radicle::logger::StderrLogger::new(lvl); log::set_boxed_logger(Box::new(logger)) .expect("no other logger should have been set already"); log::set_max_level(lvl.to_level_filter()); } if args.nth(1).as_deref() == Some("--version") { if let Err(e) = VERSION.write(std::io::stdout()) { eprintln!("error: {e}"); process::exit(1); }; process::exit(0); } let profile = match radicle::Profile::load() { Ok(profile) => profile, Err(err) => { eprintln!("error: couldn't load profile: {err}"); process::exit(1); } }; if let Err(err) = run(profile) { eprintln!("error: {err}"); process::exit(1); } } #[derive(Debug, Error)] enum Error { /// Failed to parse `base`. #[error("failed to parse base revision: {0}")] Base(#[source] git::raw::Error), /// Base is not a commit. #[error("base must be of type 'commit' but it is of type '{actual_type}'")] BaseNotCommit { actual_type: String }, /// Remote repository not found (or empty). #[error("remote repository `{0}` not found")] RepositoryNotFound(PathBuf), /// Invalid arguments received. #[error("invalid arguments: {0:?}")] InvalidArguments(Vec), /// Unknown push option received. #[error("unknown push option {0:?}")] UnsupportedPushOption(String), /// Error with the remote url. #[error("invalid remote url: {0}")] RemoteUrl(#[from] UrlError), /// I/O error. #[error("i/o error: {0}")] Io(#[from] io::Error), /// Git error. #[error("git: {0}")] Git(#[from] git::raw::Error), /// Invalid reference name. #[error("invalid ref: {0}")] InvalidRef(#[from] radicle::git::fmt::Error), /// Repository error. #[error(transparent)] Repository(#[from] radicle::storage::RepositoryError), /// Fetch error. #[error(transparent)] Fetch(#[from] fetch::Error), /// Push error. #[error(transparent)] Push(#[from] push::Error), /// List error. #[error(transparent)] List(#[from] list::Error), /// Invalid object ID. #[error("invalid oid: {0}")] InvalidOid(#[from] radicle::git::ParseOidError), /// Protocol error. #[error(transparent)] Protocol(#[from] protocol::Error), } /// Models values for the `verbosity` option, see /// . #[derive(Copy, Clone, Debug)] struct Verbosity(u8); impl From for radicle::git::Verbosity { /// Converts the verbosity option passed to a Git remote helper to /// one that can be passed to other Git commands via command line. /// Note that these scales are one off: While the default verbosity /// for remote helpers is 1, the default verbosity via command line /// (omitting the flag) is 0. /// This implementation also cuts off verbosities greater than [`i8::MAX`]. fn from(val: Verbosity) -> Self { radicle::git::Verbosity::from(i8::try_from(val.0).unwrap_or(i8::MAX) - 1) } } /// The documentation on Git remote helpers, see /// /// says: "1 is the default level of verbosity". impl Default for Verbosity { fn default() -> Self { Self(1) } } impl FromStr for Verbosity { type Err = std::num::ParseIntError; fn from_str(s: &str) -> Result { u8::from_str(s).map(Self) } } /// Branch creation options when creating a patch. #[derive(Debug, Default, Clone)] enum Branch { /// Don't create a new branch. #[default] None, /// Create a branch with the same name as the upstream branch (i.e. `patches/`). MirrorUpstream, /// Create a branch with the provided name. Provided(git::fmt::RefString), } impl Branch { /// Return the branch name to be used for the local branch when creating a /// patch. fn into_branch_name(self, object: &radicle::patch::PatchId) -> Option> { match self { Self::None => None, Self::MirrorUpstream => Some(git::refs::patch(object)), Self::Provided(name) => match name.clone().into_qualified() { None => Some(git::fmt::lit::refs_heads(&name).into()), // Ensure that if the reference is already qualified we do not // add `refs/heads` Some(name) => Some(name), }, } } } #[derive(Debug, Default, Clone)] struct Options { /// Don't sync after push. no_sync: bool, /// Sync debugging. sync_debug: bool, /// Enable hints. hints: bool, /// Open patch in draft mode. draft: bool, /// Patch base to use, when opening or updating a patch. base: Option, /// Patch message. message: cli::patch::Message, /// Create a branch and set its upstream when opening a patch. branch: Branch, verbosity: Verbosity, } /// Run the radicle remote helper using the given profile. fn run(profile: radicle::Profile) -> Result<(), Error> { // Since we're going to be writing user output to `stderr`, make sure the paint // module is aware of that. cli::Paint::set_terminal(cli::TerminalFile::Stderr); let (remote, url): (Option, Url) = { let args = env::args().skip(1).take(2).collect::>(); match args.as_slice() { [url] => (None, url.parse()?), [remote, url] => ( git::fmt::RefString::try_from(remote.as_str()).ok(), url.parse()?, ), _ => { return Err(Error::InvalidArguments(args)); } } }; let stored = profile.storage.repository_mut(url.repo)?; if stored.is_empty()? { return Err(Error::RepositoryNotFound(stored.path().to_path_buf())); } // Whether we should output debug logs. let debug = radicle::profile::env::debug(); let stdin = io::stdin(); let stdout = io::stdout(); let git = service::RealGitService; let mut node = service::RealNodeSession::new(&profile); if let Err(e) = radicle::io::set_file_limit(4096) { if debug { eprintln!("{}: unable to set open file limit: {e}", VERSION.name); } } run_loop( stdin.lock(), stdout.lock(), &git, &mut node, &stored, &profile, remote, url, ) } #[allow(clippy::too_many_arguments)] fn run_loop( mut input: R, mut output: W, git: &G, node: &mut N, stored: &storage::git::Repository, profile: &Profile, remote: Option, url: Url, ) -> Result<(), Error> { let mut opts = Options::default(); let mut expected_refs = Vec::new(); let debug = radicle::profile::env::debug(); let mut command_reader = LineReader::new(&mut input); while let Some(line) = command_reader.next() { let line = line??; if debug { eprintln!("{}: {:?}", VERSION.name, line); } match line { Line::Valid(Command::Capabilities) => { writeln!(output, "option")?; writeln!(output, "push")?; // Implies `list` command. writeln!(output, "fetch")?; writeln!(output)?; } Line::Valid(Command::Option { key, value }) => match key.as_str() { "verbosity" => { if let Some(val) = value { match val.parse::() { Ok(verbosity) => { opts.verbosity = verbosity; writeln!(output, "ok")?; } Err(err) => { writeln!(output, "error {err}")?; } } } else { writeln!(output, "error missing value for verbosity")?; } } "push-option" => { if let Some(val) = value { let args = val.split(' ').collect::>(); // Nb. Git documentation says that we can print `error ` or `unsupported` // for options that are not supported, but this results in Git saying that // "push-option" itself is an unsupported option, which is not helpful or correct. // Hence, we just exit with an error in this case. push_option(&args, &mut opts)?; writeln!(output, "ok")?; } else { writeln!(output, "error missing value for push-option")?; } } "cas" => { if let Some(val) = value { expected_refs.push(val); writeln!(output, "ok")?; } else { writeln!(output, "error missing value for cas")?; } } "progress" => { writeln!(output, "unsupported")?; } _ => { writeln!(output, "unsupported")?; } }, Line::Valid(Command::Fetch { oid, refstr }) => { let oid = git::Oid::from_str(&oid)?; let refstr = git::fmt::RefString::try_from(refstr.as_str())?; fetch::run( vec![(oid, refstr)], stored, git, &mut command_reader, opts.verbosity, )?; // Nb. An empty line means we're done writeln!(output)?; return Ok(()); } Line::Valid(Command::Push(refspec)) => { let result = push::run( vec![refspec], remote.clone(), url.clone(), stored, profile, &mut command_reader, opts.clone(), &expected_refs, git, node, )?; for line in result { writeln!(output, "{line}")?; } writeln!(output)?; return Ok(()); } Line::Valid(Command::List) => { let refs = list::for_fetch(&url, profile, stored)?; for line in refs { writeln!(output, "{line}")?; } writeln!(output)?; } Line::Valid(Command::ListForPush) => { let refs = list::for_push(profile, stored)?; for line in refs { writeln!(output, "{line}")?; } writeln!(output)?; } Line::Blank => { break; } } } Ok(()) } /// Parse a single push option. Returns `Ok` if it was successful. /// Note that some push options can contain spaces, eg. `patch.message="Hello World!"`, /// hence the arguments are passed as a slice. fn push_option(args: &[&str], opts: &mut Options) -> Result<(), Error> { match args { ["hints"] => opts.hints = true, ["sync"] => opts.no_sync = false, ["sync.debug"] => opts.sync_debug = true, ["no-sync"] => opts.no_sync = true, ["patch.draft"] => opts.draft = true, ["patch.branch"] => opts.branch = Branch::MirrorUpstream, _ => { let args = args.join(" "); let (key, val) = args .split_once('=') .ok_or_else(|| Error::UnsupportedPushOption(args.to_owned()))?; match key { "patch.message" => { opts.message.append(val); } "patch.base" => { let repo = git::raw::Repository::open_from_env().map_err(Error::Base)?; let commit = repo .revparse_single(val) .map_err(Error::Base)? .into_commit() .map_err(|object| Error::BaseNotCommit { actual_type: object .kind() .map(|kind| kind.to_string()) .unwrap_or_else(|| "".to_string()), })?; opts.base = Some(git::Oid::from(commit.id())); } "patch.branch" => { opts.branch = Branch::Provided(git::fmt::RefString::try_from(val)?) } other => { return Err(Error::UnsupportedPushOption(other.to_owned())); } } } } Ok(()) } /// Write a hint to the user. pub(crate) fn hint(s: impl fmt::Display) { eprintln!("{}", cli::format::hint(format!("hint: {s}"))); } /// Write a warning to the user. pub(crate) fn warn(s: impl fmt::Display) { eprintln!("{}", cli::format::hint(format!("warn: {s}"))); } /// Get the patch store. pub(crate) fn patches<'a, Repo: ReadRepository + cob::Store>( profile: &Profile, repo: &'a Repo, ) -> Result, list::Error> { match profile.patches(repo) { Ok(patches) => Ok(patches), Err(err @ profile::Error::CobsCache(cob::cache::Error::OutOfDate)) => { hint(cli::cob::MIGRATION_HINT); Err(err.into()) } Err(err) => Err(err.into()), } } /// Get the mutable patch store. pub(crate) fn patches_mut<'a, 'b, Signer>( profile: &Profile, repo: &'a storage::git::Repository, signer: &'b Signer, ) -> Result< cob::patch::Cache<'a, storage::git::Repository, WriteAs<'b, Signer>, cob::cache::StoreWriter>, push::Error, > where { match profile.patches_mut(repo, signer) { Ok(patches) => Ok(patches), Err(err @ profile::Error::CobsCache(cob::cache::Error::OutOfDate)) => { hint(cli::cob::MIGRATION_HINT); Err(err.into()) } Err(err) => Err(err.into()), } }