Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
heartwood crates radicle-remote-helper src main.rs
//! 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()),
    }
}