//! A Git remote helper for interacting with Radicle storage and notifying
//! `radicle-node`.
//!
//! Refer to <https://git-scm.com/docs/gitremote-helpers.html> 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<String>),
/// 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
/// <https://git-scm.com/docs/gitremote-helpers#Documentation/gitremote-helpers.txt-optionverbosityn>.
#[derive(Copy, Clone, Debug)]
struct Verbosity(u8);
impl From<Verbosity> 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
/// <https://git-scm.com/docs/gitremote-helpers#Documentation/gitremote-helpers.txt-optionverbosityn>
/// 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<Self, Self::Err> {
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/<patch id>`).
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<git::fmt::Qualified<'_>> {
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<git::Oid>,
/// 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<git::fmt::RefString>, Url) = {
let args = env::args().skip(1).take(2).collect::<Vec<_>>();
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<R: BufRead, W: Write, G: service::GitService, N: service::NodeSession>(
mut input: R,
mut output: W,
git: &G,
node: &mut N,
stored: &storage::git::Repository,
profile: &Profile,
remote: Option<git::fmt::RefString>,
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::<Verbosity>() {
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::<Vec<_>>();
// Nb. Git documentation says that we can print `error <msg>` 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(|| "<unknown type encountered>".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<Namespace = NodeId>>(
profile: &Profile,
repo: &'a Repo,
) -> Result<cob::patch::Cache<'a, Repo, ReadOnly, cob::cache::StoreReader>, 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()),
}
}