Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
remote-helper: Make crate binary-only
Lorenz Leutgeb committed 7 months ago
commit 83613d0da6564539b64ddced7958500c033e53a6
parent e35bbd5725ac79e7aa85cb2753b19a32cc7b8432
5 files changed +383 -383
modified crates/radicle-remote-helper/Cargo.toml
@@ -11,7 +11,7 @@ rust-version.workspace = true

[[bin]]
name = "git-remote-rad"
-
path = "src/git-remote-rad.rs"
+
path = "src/main.rs"

[dependencies]
dunce = { workspace = true }
modified crates/radicle-remote-helper/src/fetch.rs
@@ -54,9 +54,6 @@ pub fn run<R: ReadRepository>(
    // Verify them and prepare the final refspecs.
    let oids = refs.into_iter().map(|(oid, _)| oid);

-
    // Rely on the environment variable `GIT_DIR` pointing at the repository.
-
    let working = None;
-

    // N.b. we shell out to `git`, avoiding using `git2`. This is to
    // avoid an issue where somewhere within the fetch there is an
    // attempt to lookup a `rad/sigrefs` object, which says that the
deleted crates/radicle-remote-helper/src/git-remote-rad.rs
@@ -1,42 +0,0 @@
-
use std::env;
-
use std::process;
-

-
use radicle::version::Version;
-

-
pub const VERSION: Version = Version {
-
    name: "git-remote-rad",
-
    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) = radicle_remote_helper::run(profile) {
-
        eprintln!("error: {err}");
-
        process::exit(1);
-
    }
-
}
deleted crates/radicle-remote-helper/src/lib.rs
@@ -1,337 +0,0 @@
-
#![warn(clippy::unwrap_used)]
-
//! The Radicle Git remote helper.
-
//!
-
//! Communication with the user is done via `stderr` (`eprintln`).
-
//! Communication with Git tooling is done via `stdout` (`println`).
-
mod fetch;
-
mod list;
-
mod push;
-

-
use std::path::PathBuf;
-
use std::str::FromStr;
-
use std::{env, fmt, io};
-

-
use thiserror::Error;
-

-
use radicle::prelude::NodeId;
-
use radicle::storage::git::transport::local::{Url, UrlError};
-
use radicle::storage::{ReadRepository, WriteStorage};
-
use radicle::{cob, profile};
-
use radicle::{git, storage, Profile};
-
use radicle_cli::git::Rev;
-
use radicle_cli::terminal as cli;
-

-
#[derive(Debug, Error)]
-
pub enum Error {
-
    /// Failed to parse `base`.
-
    #[error("failed to parse base revision: {0}")]
-
    Base(Box<dyn std::error::Error>),
-
    /// Remote repository not found (or empty).
-
    #[error("remote repository `{0}` not found")]
-
    RepositoryNotFound(PathBuf),
-
    /// Invalid command received.
-
    #[error("invalid command `{0}`")]
-
    InvalidCommand(String),
-
    /// 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),
-
    /// The `GIT_DIR` env var is not set.
-
    #[error("the `GIT_DIR` environment variable is not set")]
-
    NoGitDir,
-
    /// No parent of `GIT_DIR` was found.
-
    #[error("expected parent of .git but found {path:?}")]
-
    NoWorkingCopy { path: PathBuf },
-
    /// 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),
-
}
-

-
/// 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)
-
    }
-
}
-

-
#[derive(Debug, Default, Clone)]
-
pub 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<Rev>,
-
    /// Patch message.
-
    message: cli::patch::Message,
-
    verbosity: Verbosity,
-
}
-

-
/// Run the radicle remote helper using the given profile.
-
pub 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::RefString>, Url) = {
-
        let args = env::args().skip(1).take(2).collect::<Vec<_>>();
-

-
        match args.as_slice() {
-
            [url] => (None, url.parse()?),
-
            [remote, url] => (git::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()));
-
    }
-

-
    // `GIT_DIR` is set by Git tooling, if we're in a working copy.
-
    let working = env::var("GIT_DIR").map(PathBuf::from);
-
    // Whether we should output debug logs.
-
    let debug = radicle::profile::env::debug();
-

-
    let stdin = io::stdin();
-
    let mut line = String::new();
-
    let mut opts = Options::default();
-

-
    if let Err(e) = radicle::io::set_file_limit(4096) {
-
        if debug {
-
            eprintln!("git-remote-rad: unable to set open file limit: {e}");
-
        }
-
    }
-

-
    loop {
-
        let tokens = read_line(&stdin, &mut line)?;
-

-
        if debug {
-
            eprintln!("git-remote-rad: {:?}", &tokens);
-
        }
-

-
        match tokens.as_slice() {
-
            ["capabilities"] => {
-
                println!("option");
-
                println!("push"); // Implies `list` command.
-
                println!("fetch");
-
                println!();
-
            }
-
            ["option", "verbosity", verbosity] => match verbosity.parse::<Verbosity>() {
-
                Ok(verbosity) => {
-
                    opts.verbosity = verbosity;
-
                    println!("ok");
-
                }
-
                Err(err) => {
-
                    println!("error {err}");
-
                }
-
            },
-
            ["option", "push-option", args @ ..] => {
-
                // 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)?;
-
                println!("ok");
-
            }
-
            ["option", "progress", ..] => {
-
                println!("unsupported");
-
            }
-
            ["option", ..] => {
-
                println!("unsupported");
-
            }
-
            ["fetch", oid, refstr] => {
-
                let oid = git::Oid::from_str(oid)?;
-
                let refstr = git::RefString::try_from(*refstr)?;
-

-
                // N.b. `working` is the `.git` folder and `fetch::run`
-
                // requires the working directory.
-
                let working = dunce::canonicalize(working.map_err(|_| Error::NoGitDir)?)?;
-
                let working = working.parent().ok_or_else(|| Error::NoWorkingCopy {
-
                    path: working.clone(),
-
                })?;
-

-
                return fetch::run(vec![(oid, refstr)], working, stored, &stdin, opts.verbosity)
-
                    .map_err(Error::from);
-
            }
-
            ["push", refspec] => {
-
                // We have to be in a working copy to push.
-
                let working = working.map_err(|_| Error::NoGitDir)?;
-

-
                return push::run(
-
                    vec![refspec.to_string()],
-
                    &working,
-
                    // N.b. assume the default remote if there was no remote
-
                    remote.unwrap_or((*radicle::rad::REMOTE_NAME).clone()),
-
                    url,
-
                    &stored,
-
                    &profile,
-
                    &stdin,
-
                    opts,
-
                )
-
                .map_err(Error::from);
-
            }
-
            ["list"] => {
-
                list::for_fetch(&url, &profile, &stored)?;
-
            }
-
            ["list", "for-push"] => {
-
                list::for_push(&profile, &stored)?;
-
            }
-
            [] => {
-
                return Ok(());
-
            }
-
            _ => {
-
                return Err(Error::InvalidCommand(line.trim().to_owned()));
-
            }
-
        }
-
    }
-
}
-

-
/// 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,
-
        _ => {
-
            let args = args.join(" ");
-

-
            if let Some((key, val)) = args.split_once('=') {
-
                match key {
-
                    "patch.message" => {
-
                        opts.message.append(val);
-
                    }
-
                    "patch.base" => {
-
                        let base =
-
                            cli::args::rev(&val.into()).map_err(|e| Error::Base(e.into()))?;
-
                        opts.base = Some(base);
-
                    }
-
                    other => {
-
                        return Err(Error::UnsupportedPushOption(other.to_owned()));
-
                    }
-
                }
-
            } else {
-
                return Err(Error::UnsupportedPushOption(args.to_owned()));
-
            }
-
        }
-
    }
-
    Ok(())
-
}
-

-
/// Read one line from stdin, and split it into tokens.
-
pub(crate) fn read_line<'a>(stdin: &io::Stdin, line: &'a mut String) -> io::Result<Vec<&'a str>> {
-
    line.clear();
-

-
    let read = stdin.read_line(line)?;
-
    if read == 0 {
-
        return Ok(vec![]);
-
    }
-
    let line = line.trim();
-
    let tokens = line.split(' ').filter(|t| !t.is_empty()).collect();
-

-
    Ok(tokens)
-
}
-

-
/// 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, R: ReadRepository + cob::Store<Namespace = NodeId>>(
-
    profile: &Profile,
-
    repo: &'a R,
-
) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, 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>(
-
    profile: &Profile,
-
    repo: &'a storage::git::Repository,
-
) -> Result<
-
    cob::patch::Cache<cob::patch::Patches<'a, storage::git::Repository>, cob::cache::StoreWriter>,
-
    push::Error,
-
> {
-
    match profile.patches_mut(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()),
-
    }
-
}
added crates/radicle-remote-helper/src/main.rs
@@ -0,0 +1,382 @@
+
//! 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.
+

+
mod fetch;
+
mod list;
+
mod push;
+

+
use std::path::PathBuf;
+
use std::process;
+
use std::str::FromStr;
+
use std::{env, fmt, io};
+

+
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::{cob, profile};
+
use radicle::{git, storage, Profile};
+
use radicle_cli::git::Rev;
+
use radicle_cli::terminal as cli;
+

+
pub const VERSION: Version = Version {
+
    name: "git-remote-rad",
+
    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)]
+
pub enum Error {
+
    /// Failed to parse `base`.
+
    #[error("failed to parse base revision: {0}")]
+
    Base(Box<dyn std::error::Error>),
+
    /// Remote repository not found (or empty).
+
    #[error("remote repository `{0}` not found")]
+
    RepositoryNotFound(PathBuf),
+
    /// Invalid command received.
+
    #[error("invalid command `{0}`")]
+
    InvalidCommand(String),
+
    /// 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),
+
    /// The `GIT_DIR` env var is not set.
+
    #[error("the `GIT_DIR` environment variable is not set")]
+
    NoGitDir,
+
    /// No parent of `GIT_DIR` was found.
+
    #[error("expected parent of .git but found {path:?}")]
+
    NoWorkingCopy { path: PathBuf },
+
    /// 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),
+
}
+

+
/// 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)
+
    }
+
}
+

+
#[derive(Debug, Default, Clone)]
+
pub 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<Rev>,
+
    /// Patch message.
+
    message: cli::patch::Message,
+
    verbosity: Verbosity,
+
}
+

+
/// Run the radicle remote helper using the given profile.
+
pub 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::RefString>, Url) = {
+
        let args = env::args().skip(1).take(2).collect::<Vec<_>>();
+

+
        match args.as_slice() {
+
            [url] => (None, url.parse()?),
+
            [remote, url] => (git::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()));
+
    }
+

+
    // `GIT_DIR` is set by Git tooling, if we're in a working copy.
+
    let working = env::var("GIT_DIR").map(PathBuf::from);
+
    // Whether we should output debug logs.
+
    let debug = radicle::profile::env::debug();
+

+
    let stdin = io::stdin();
+
    let mut line = String::new();
+
    let mut opts = Options::default();
+

+
    if let Err(e) = radicle::io::set_file_limit(4096) {
+
        if debug {
+
            eprintln!("git-remote-rad: unable to set open file limit: {e}");
+
        }
+
    }
+

+
    loop {
+
        let tokens = read_line(&stdin, &mut line)?;
+

+
        if debug {
+
            eprintln!("git-remote-rad: {:?}", &tokens);
+
        }
+

+
        match tokens.as_slice() {
+
            ["capabilities"] => {
+
                println!("option");
+
                println!("push"); // Implies `list` command.
+
                println!("fetch");
+
                println!();
+
            }
+
            ["option", "verbosity", verbosity] => match verbosity.parse::<Verbosity>() {
+
                Ok(verbosity) => {
+
                    opts.verbosity = verbosity;
+
                    println!("ok");
+
                }
+
                Err(err) => {
+
                    println!("error {err}");
+
                }
+
            },
+
            ["option", "push-option", args @ ..] => {
+
                // 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)?;
+
                println!("ok");
+
            }
+
            ["option", "progress", ..] => {
+
                println!("unsupported");
+
            }
+
            ["option", ..] => {
+
                println!("unsupported");
+
            }
+
            ["fetch", oid, refstr] => {
+
                let oid = git::Oid::from_str(oid)?;
+
                let refstr = git::RefString::try_from(*refstr)?;
+

+
                // N.b. `working` is the `.git` folder and `fetch::run`
+
                // requires the working directory.
+
                let working = dunce::canonicalize(working.map_err(|_| Error::NoGitDir)?)?;
+
                let working = working.parent().ok_or_else(|| Error::NoWorkingCopy {
+
                    path: working.clone(),
+
                })?;
+

+
                return fetch::run(vec![(oid, refstr)], working, stored, &stdin, opts.verbosity)
+
                    .map_err(Error::from);
+
            }
+
            ["push", refspec] => {
+
                // We have to be in a working copy to push.
+
                let working = working.map_err(|_| Error::NoGitDir)?;
+

+
                return push::run(
+
                    vec![refspec.to_string()],
+
                    &working,
+
                    // N.b. assume the default remote if there was no remote
+
                    remote.unwrap_or((*radicle::rad::REMOTE_NAME).clone()),
+
                    url,
+
                    &stored,
+
                    &profile,
+
                    &stdin,
+
                    opts,
+
                )
+
                .map_err(Error::from);
+
            }
+
            ["list"] => {
+
                list::for_fetch(&url, &profile, &stored)?;
+
            }
+
            ["list", "for-push"] => {
+
                list::for_push(&profile, &stored)?;
+
            }
+
            [] => {
+
                return Ok(());
+
            }
+
            _ => {
+
                return Err(Error::InvalidCommand(line.trim().to_owned()));
+
            }
+
        }
+
    }
+
}
+

+
/// 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,
+
        _ => {
+
            let args = args.join(" ");
+

+
            if let Some((key, val)) = args.split_once('=') {
+
                match key {
+
                    "patch.message" => {
+
                        opts.message.append(val);
+
                    }
+
                    "patch.base" => {
+
                        let base =
+
                            cli::args::rev(&val.into()).map_err(|e| Error::Base(e.into()))?;
+
                        opts.base = Some(base);
+
                    }
+
                    other => {
+
                        return Err(Error::UnsupportedPushOption(other.to_owned()));
+
                    }
+
                }
+
            } else {
+
                return Err(Error::UnsupportedPushOption(args.to_owned()));
+
            }
+
        }
+
    }
+
    Ok(())
+
}
+

+
/// Read one line from stdin, and split it into tokens.
+
pub(crate) fn read_line<'a>(stdin: &io::Stdin, line: &'a mut String) -> io::Result<Vec<&'a str>> {
+
    line.clear();
+

+
    let read = stdin.read_line(line)?;
+
    if read == 0 {
+
        return Ok(vec![]);
+
    }
+
    let line = line.trim();
+
    let tokens = line.split(' ').filter(|t| !t.is_empty()).collect();
+

+
    Ok(tokens)
+
}
+

+
/// 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, R: ReadRepository + cob::Store<Namespace = NodeId>>(
+
    profile: &Profile,
+
    repo: &'a R,
+
) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, 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>(
+
    profile: &Profile,
+
    repo: &'a storage::git::Repository,
+
) -> Result<
+
    cob::patch::Cache<cob::patch::Patches<'a, storage::git::Repository>, cob::cache::StoreWriter>,
+
    push::Error,
+
> {
+
    match profile.patches_mut(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()),
+
    }
+
}