Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
remote-helper: prefactor to be more testable
Merged ade opened 2 months ago

To introduce new remote-helper changes like ‘dont push by default’ and ‘self’ / ‘rad’ remotes, this crate has been refactored to ease testing.

  • the command parsing has been refactored into a sans i/o esque typed parser.
  • the radicle node interactions have been hoisted into a NodeSession trait.
  • the git send_pack and fetch_pack interactions have hoisted into GitService trait.
  • the direct stdin/out printing has been replaced with BufRead and Writer traits.
6 files changed +568 -182 119445ce 4d7b942b
modified crates/radicle-remote-helper/src/fetch.rs
@@ -4,8 +4,8 @@ use std::{io, process::ExitStatus};
use thiserror::Error;

use radicle::git;
-
use radicle::storage::ReadRepository;

+
use crate::service::GitService;
use crate::{read_line, Verbosity};

#[derive(Debug, Error)]
@@ -33,16 +33,18 @@ pub enum Error {
}

/// Run a git fetch command.
-
pub fn run<R: ReadRepository>(
+
pub fn run<G: GitService>(
    mut refs: Vec<(git::Oid, git::fmt::RefString)>,
-
    stored: R,
-
    stdin: &io::Stdin,
+
    stored: &radicle::storage::git::Repository,
+
    git: &G,
+
    mut stdin: impl io::BufRead,
    verbosity: Verbosity,
) -> Result<(), Error> {
    // Read all the `fetch` lines.
    let mut line = String::new();
    loop {
-
        let tokens = read_line(stdin, &mut line)?;
+
        let tokens = read_line(&mut stdin, &mut line)?;
+
        let tokens: Vec<&str> = tokens.iter().map(|s| s.as_str()).collect();
        match tokens.as_slice() {
            ["fetch", oid, refstr] => {
                let oid = git::Oid::from_str(oid)?;
@@ -58,7 +60,7 @@ pub fn run<R: ReadRepository>(
    }

    // Verify them and prepare the final refspecs.
-
    let oids = refs.into_iter().map(|(oid, _)| oid);
+
    let oids = refs.into_iter().map(|(oid, _)| oid).collect();

    // Rely on the environment variable `GIT_DIR` pointing at the repository.
    let working = None;
@@ -72,7 +74,7 @@ pub fn run<R: ReadRepository>(
    // used in the working copy, this will always result in the object
    // missing. This seems to only be an issue with `libgit2`/`git2`
    // and not `git` itself.
-
    let output = git::process::fetch_pack(working, &stored, oids, verbosity.into())?;
+
    let output = git.fetch_pack(working, stored, oids, verbosity.into())?;

    if !output.status.success() {
        return Err(Error::FetchPackFailed {
@@ -82,8 +84,5 @@ pub fn run<R: ReadRepository>(
        });
    }

-
    // Nb. An empty line means we're done.
-
    println!();
-

    Ok(())
}
modified crates/radicle-remote-helper/src/list.rs
@@ -39,17 +39,19 @@ pub fn for_fetch<R: ReadRepository + cob::Store<Namespace = NodeId> + 'static>(
    url: &Url,
    profile: &Profile,
    stored: &R,
-
) -> Result<(), Error> {
+
) -> Result<Vec<String>, Error> {
+
    let mut lines = Vec::new();
+

    if let Some(namespace) = url.namespace {
        // Listing namespaced refs.
        for (name, oid) in stored.references_of(&namespace)? {
-
            println!("{oid} {name}");
+
            lines.push(format!("{oid} {name}"));
        }
    } else {
        // List the symbolic reference `HEAD`, which is interpreted by
        // Git clients to determine the default branch.
        match stored.head() {
-
            Ok((target, _)) => println!("@{target} HEAD"),
+
            Ok((target, _)) => lines.push(format!("@{target} HEAD")),
            Err(err) => eprintln!("remote: error resolving HEAD: {err}"),
        }

@@ -60,43 +62,45 @@ pub fn for_fetch<R: ReadRepository + cob::Store<Namespace = NodeId> + 'static>(
            git::fmt::pattern!("refs/tags/*"),
        ] {
            for (name, oid) in stored.references_glob(&glob)? {
-
                println!("{oid} {name}");
+
                lines.push(format!("{oid} {name}"));
            }
        }

        // List the patch refs, but do not abort if there is an error,
        // as this would break all fetch behavior.
        // Instead, just output an error to the user.
-
        if let Err(e) = patch_refs(profile, stored) {
-
            eprintln!("remote: error listing patch refs: {e}");
+
        match patch_refs(profile, stored) {
+
            Ok(mut refs) => lines.append(&mut refs),
+
            Err(e) => eprintln!("remote: error listing patch refs: {e}"),
        }
    }
-
    println!();

-
    Ok(())
+
    Ok(lines)
}

/// List refs for pushing (`git push`).
-
pub fn for_push<R: ReadRepository>(profile: &Profile, stored: &R) -> Result<(), Error> {
+
pub fn for_push<R: ReadRepository>(profile: &Profile, stored: &R) -> Result<Vec<String>, Error> {
+
    let mut lines = Vec::new();
+

    // Only our own refs can be pushed to.
    for (name, oid) in stored.references_of(profile.id())? {
        // Only branches and tags can be pushed to.
        if name.starts_with(git::fmt::refname!("refs/heads").as_str())
            || name.starts_with(git::fmt::refname!("refs/tags").as_str())
        {
-
            println!("{oid} {name}");
+
            lines.push(format!("{oid} {name}"));
        }
    }
-
    println!();

-
    Ok(())
+
    Ok(lines)
}

/// List canonical patch references. These are magic refs that can be used to pull patch updates.
fn patch_refs<R: ReadRepository + cob::Store<Namespace = NodeId> + 'static>(
    profile: &Profile,
    stored: &R,
-
) -> Result<(), Error> {
+
) -> Result<Vec<String>, Error> {
+
    let mut lines = Vec::new();
    let patches = crate::patches(profile, stored)?;
    for patch in patches.list()? {
        let Ok((id, patch)) = patch else {
@@ -106,8 +110,8 @@ fn patch_refs<R: ReadRepository + cob::Store<Namespace = NodeId> + 'static>(
        let head = patch.head();

        if patch.is_open() && stored.commit(*head).is_ok() {
-
            println!("{} {}", patch.head(), git::refs::patch(&id));
+
            lines.push(format!("{} {}", patch.head(), git::refs::patch(&id)));
        }
    }
-
    Ok(())
+
    Ok(lines)
}
modified crates/radicle-remote-helper/src/main.rs
@@ -18,12 +18,15 @@

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, io};
+
use std::{env, fmt};

use thiserror::Error;

@@ -35,6 +38,8 @@ use radicle::{cob, profile};
use radicle::{git, storage, Profile};
use radicle_cli::terminal as cli;

+
use crate::protocol::{Command, Line};
+

pub const VERSION: Version = Version {
    name: env!("CARGO_BIN_NAME"),
    commit: env!("GIT_HEAD"),
@@ -120,6 +125,9 @@ pub enum 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
@@ -238,9 +246,9 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
    let debug = radicle::profile::env::debug();

    let stdin = io::stdin();
-
    let mut line = String::new();
-
    let mut opts = Options::default();
-
    let mut expected_refs = Vec::new();
+
    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 {
@@ -248,81 +256,151 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
        }
    }

+
    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 line = String::new();
+
    let mut opts = Options::default();
+
    let mut expected_refs = Vec::new();
+
    let debug = radicle::profile::env::debug();
+

    loop {
-
        let tokens = read_line(&stdin, &mut line)?;
+
        line.clear();
+
        let read = input.read_line(&mut line)?;
+
        if read == 0 {
+
            break;
+
        }
+

+
        let cmd = Command::parse_line(&line)?;

        if debug {
-
            eprintln!("{}: {}", VERSION.name, &tokens.join(" "));
+
            eprintln!("{}: {:?}", VERSION.name, cmd);
        }

-
        match tokens.as_slice() {
-
            ["capabilities"] => {
-
                println!("option");
-
                println!("push"); // Implies `list` command.
-
                println!("fetch");
-
                println!();
+
        match cmd {
+
            Line::Valid(Command::Capabilities) => {
+
                writeln!(output, "option")?;
+
                writeln!(output, "push")?; // Implies `list` command.
+
                writeln!(output, "fetch")?;
+
                writeln!(output)?;
            }
-
            ["option", "verbosity", verbosity] => match verbosity.parse::<Verbosity>() {
-
                Ok(verbosity) => {
-
                    opts.verbosity = verbosity;
-
                    println!("ok");
+
            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")?;
                }
-
                Err(err) => {
-
                    println!("error {err}");
+
                _ => {
+
                    writeln!(output, "unsupported")?;
                }
            },
-
            ["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", "cas", refstr] => {
-
                expected_refs.push((*refstr).to_owned());
-
                println!("ok");
-
            }
-
            ["option", "progress", ..] | ["option", ..] => {
-
                println!("unsupported");
-
            }
-
            ["fetch", oid, refstr] => {
-
                let oid = git::Oid::from_str(oid)?;
-
                let refstr = git::fmt::RefString::try_from(*refstr)?;
+
            Line::Valid(Command::Fetch { oid, refstr }) => {
+
                let oid = git::Oid::from_str(&oid)?;
+
                let refstr = git::fmt::RefString::try_from(refstr.as_str())?;

-
                return Ok(fetch::run(
-
                    vec![(oid, refstr)],
-
                    stored,
-
                    &stdin,
-
                    opts.verbosity,
-
                )?);
+
                fetch::run(vec![(oid, refstr)], stored, git, &mut input, opts.verbosity)?;
+

+
                // Nb. An empty line means we're done
+
                writeln!(output)?;
+

+
                return Ok(());
            }
-
            ["push", refspec] => {
-
                return Ok(push::run(
-
                    vec![refspec.to_string()],
-
                    remote,
-
                    url,
-
                    &stored,
-
                    &profile,
-
                    &stdin,
-
                    opts,
+
            Line::Valid(Command::Push(refspec)) => {
+
                let result = push::run(
+
                    vec![refspec],
+
                    remote.clone(),
+
                    url.clone(),
+
                    stored,
+
                    profile,
+
                    &mut input,
+
                    opts.clone(),
                    &expected_refs,
-
                )?);
+
                    git,
+
                    node,
+
                )?;
+

+
                for line in result {
+
                    writeln!(output, "{line}")?;
+
                }
+
                writeln!(output)?;
+

+
                return Ok(());
            }
-
            ["list"] => {
-
                list::for_fetch(&url, &profile, &stored)?;
+
            Line::Valid(Command::List) => {
+
                let refs = list::for_fetch(&url, profile, stored)?;
+
                for line in refs {
+
                    writeln!(output, "{line}")?;
+
                }
+
                writeln!(output)?;
            }
-
            ["list", "for-push"] => {
-
                list::for_push(&profile, &stored)?;
+
            Line::Valid(Command::ListForPush) => {
+
                let refs = list::for_push(profile, stored)?;
+
                for line in refs {
+
                    writeln!(output, "{line}")?;
+
                }
+
                writeln!(output)?;
            }
-
            [] => {
+
            Line::Blank => {
                return Ok(());
            }
-
            _ => {
-
                return Err(Error::InvalidCommand(line.trim().to_owned()));
-
            }
        }
    }
+

+
    Ok(())
}

/// Parse a single push option. Returns `Ok` if it was successful.
@@ -374,20 +452,6 @@ fn push_option(args: &[&str], opts: &mut Options) -> Result<(), Error> {
    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}")));
@@ -430,3 +494,12 @@ pub(crate) fn patches_mut<'a>(
        Err(err) => Err(err.into()),
    }
}
+

+
pub fn read_line(r: &mut impl io::BufRead, line: &mut String) -> Result<Vec<String>, io::Error> {
+
    line.clear();
+
    let read = r.read_line(line)?;
+
    if read == 0 {
+
        return Ok(Vec::new());
+
    }
+
    Ok(line.split_whitespace().map(|s| s.to_string()).collect())
+
}
added crates/radicle-remote-helper/src/protocol.rs
@@ -0,0 +1,190 @@
+
use thiserror::Error;
+

+
#[derive(Debug, Error)]
+
pub enum Error {
+
    #[error("invalid command `{0}`")]
+
    InvalidCommand(String),
+
}
+

+
#[derive(Debug, PartialEq, Eq)]
+
pub enum Command {
+
    Capabilities,
+
    List,
+
    ListForPush,
+
    Fetch { oid: String, refstr: String },
+
    Push(String),
+
    Option { key: String, value: Option<String> },
+
}
+

+
#[derive(Debug, PartialEq, Eq)]
+
pub enum Line {
+
    Valid(Command),
+
    Blank,
+
}
+

+
impl Command {
+
    pub fn parse_line(line: &str) -> Result<Line, Error> {
+
        let line = line.trim();
+
        if line.is_empty() {
+
            return Ok(Line::Blank);
+
        }
+

+
        // Split the command verb from the rest of the line.
+
        let (cmd, args) = line.split_once(' ').unwrap_or((line, ""));
+
        let args = args.trim();
+

+
        match cmd {
+
            "capabilities" => Ok(Line::Valid(Command::Capabilities)),
+
            "list" => {
+
                if args == "for-push" {
+
                    Ok(Line::Valid(Command::ListForPush))
+
                } else if args.is_empty() {
+
                    Ok(Line::Valid(Command::List))
+
                } else {
+
                    Err(Error::InvalidCommand(line.to_owned()))
+
                }
+
            }
+
            "fetch" => {
+
                // fetch <oid> <name>
+
                // Use split_whitespace to handle multiple spaces between OID and Ref,
+
                // which is permitted.
+
                let mut parts = args.split_whitespace();
+
                let oid = parts
+
                    .next()
+
                    .ok_or_else(|| Error::InvalidCommand(line.to_owned()))?;
+
                let refstr = parts
+
                    .next()
+
                    .ok_or_else(|| Error::InvalidCommand(line.to_owned()))?;
+
                Ok(Line::Valid(Command::Fetch {
+
                    oid: oid.to_owned(),
+
                    refstr: refstr.to_owned(),
+
                }))
+
            }
+
            "push" => Ok(Line::Valid(Command::Push(args.to_owned()))),
+
            "option" => {
+
                // option <key> [value]
+
                // Use split_once to preserve whitespace in the value.
+
                let (key, val) = args.split_once(' ').unwrap_or((args, ""));
+
                let value = if val.is_empty() {
+
                    None
+
                } else {
+
                    Some(val.to_owned())
+
                };
+
                Ok(Line::Valid(Command::Option {
+
                    key: key.to_owned(),
+
                    value,
+
                }))
+
            }
+
            _ => Err(Error::InvalidCommand(line.to_owned())),
+
        }
+
    }
+
}
+

+
#[cfg(test)]
+
mod tests {
+
    use super::*;
+

+
    #[test]
+
    fn test_capabilities() {
+
        assert_eq!(
+
            Command::parse_line("capabilities").unwrap(),
+
            Line::Valid(Command::Capabilities)
+
        );
+
    }
+

+
    #[test]
+
    fn test_list() {
+
        assert_eq!(
+
            Command::parse_line("list").unwrap(),
+
            Line::Valid(Command::List)
+
        );
+
    }
+

+
    #[test]
+
    fn test_list_for_push() {
+
        assert_eq!(
+
            Command::parse_line("list for-push").unwrap(),
+
            Line::Valid(Command::ListForPush)
+
        );
+
    }
+

+
    #[test]
+
    fn test_fetch() {
+
        assert_eq!(
+
            Command::parse_line("fetch oid ref").unwrap(),
+
            Line::Valid(Command::Fetch {
+
                oid: "oid".to_owned(),
+
                refstr: "ref".to_owned()
+
            })
+
        );
+
    }
+

+
    #[test]
+
    fn test_fetch_whitespace() {
+
        assert_eq!(
+
            Command::parse_line("fetch   oid     ref").unwrap(),
+
            Line::Valid(Command::Fetch {
+
                oid: "oid".to_owned(),
+
                refstr: "ref".to_owned()
+
            })
+
        );
+
    }
+

+
    #[test]
+
    fn test_push() {
+
        assert_eq!(
+
            Command::parse_line("push src:dst").unwrap(),
+
            Line::Valid(Command::Push("src:dst".to_owned()))
+
        );
+
    }
+

+
    #[test]
+
    fn test_push_force() {
+
        assert_eq!(
+
            Command::parse_line("push +src:dst").unwrap(),
+
            Line::Valid(Command::Push("+src:dst".to_owned()))
+
        );
+
    }
+

+
    #[test]
+
    fn test_push_delete() {
+
        assert_eq!(
+
            Command::parse_line("push :dst").unwrap(),
+
            Line::Valid(Command::Push(":dst".to_owned()))
+
        );
+
    }
+

+
    #[test]
+
    fn test_option() {
+
        assert_eq!(
+
            Command::parse_line("option verbosity 2").unwrap(),
+
            Line::Valid(Command::Option {
+
                key: "verbosity".to_owned(),
+
                value: Some("2".to_owned())
+
            })
+
        );
+
    }
+

+
    #[test]
+
    fn test_option_whitespace_preservation() {
+
        assert_eq!(
+
            Command::parse_line("option patch.message Fix:  whitespace").unwrap(),
+
            Line::Valid(Command::Option {
+
                key: "patch.message".to_owned(),
+
                value: Some("Fix:  whitespace".to_owned())
+
            })
+
        );
+
    }
+

+
    #[test]
+
    fn test_empty() {
+
        assert_eq!(Command::parse_line("").unwrap(), Line::Blank);
+
        assert_eq!(Command::parse_line("   ").unwrap(), Line::Blank);
+
    }
+

+
    #[test]
+
    fn test_invalid() {
+
        assert!(Command::parse_line("invalid command").is_err());
+
        assert!(Command::parse_line("list invalid").is_err());
+
    }
+
}
modified crates/radicle-remote-helper/src/push.rs
@@ -4,7 +4,6 @@ mod canonical;
mod error;

use std::collections::HashMap;
-
use std::io::IsTerminal;
use std::process::ExitStatus;
use std::str::FromStr;
use std::{assert_eq, io};
@@ -22,15 +21,15 @@ use radicle::crypto;
use radicle::explorer::ExplorerResource;
use radicle::identity::{CanonicalRefs, Did};
use radicle::node;
-
use radicle::node::{Handle, NodeId};
+
use radicle::node::NodeId;
use radicle::storage;
use radicle::storage::git::transport::local::Url;
use radicle::storage::{ReadRepository, SignRepository as _, WriteRepository};
use radicle::Profile;
use radicle::{git, rad};
-
use radicle_cli as cli;
use radicle_cli::terminal as term;

+
use crate::service::{GitService, NodeSession};
use crate::{hint, read_line, warn, Options, Verbosity};

#[derive(Debug, Error)]
@@ -249,10 +248,12 @@ pub fn run(
    url: Url,
    stored: &storage::git::Repository,
    profile: &Profile,
-
    stdin: &io::Stdin,
+
    mut stdin: impl io::BufRead,
    opts: Options,
    expected_refs: &[String],
-
) -> Result<(), Error> {
+
    git: &impl GitService,
+
    node: &mut impl NodeSession,
+
) -> Result<Vec<String>, Error> {
    // Don't allow push if either of these conditions is true:
    //
    // 1. Our key is not in ssh-agent, which means we won't be able to sign the refs.
@@ -268,14 +269,16 @@ pub fn run(
    let mut line = String::new();
    let mut ok = HashMap::new();
    let hints = opts.hints || profile.hints();
+
    let mut output = Vec::new();

    assert_eq!(signer.public_key(), &nid);

    // Read all the `push` lines.
    loop {
-
        let tokens = read_line(stdin, &mut line)?;
+
        let tokens = read_line(&mut stdin, &mut line)?;
+
        let tokens: Vec<&str> = tokens.iter().map(|s| s.as_str()).collect();
        match tokens.as_slice() {
-
            ["push", spec] => {
+
            [cmd, spec] if *cmd == "push" => {
                specs.push(spec.to_string());
            }
            // An empty line means end of input.
@@ -330,6 +333,7 @@ pub fn run(
                        &signer,
                        profile,
                        opts.clone(),
+
                        git,
                    ),
                    PushAction::UpdatePatch { dst, patch } => patch_update(
                        src,
@@ -343,6 +347,7 @@ pub fn run(
                        &signer,
                        opts.clone(),
                        expected_refs,
+
                        git,
                    ),
                    PushAction::PushRef { dst } => {
                        let identity = stored.identity()?;
@@ -364,6 +369,7 @@ pub fn run(
                            &signer,
                            opts.verbosity,
                            expected_refs,
+
                            git,
                        )?;
                        // If we're trying to update the canonical head, make sure
                        // we don't diverge from the current head. This only applies
@@ -392,11 +398,11 @@ pub fn run(
        match result {
            // Let Git tooling know that this ref has been pushed.
            Ok(resource) => {
-
                println!("ok {}", cmd.dst());
+
                output.push(format!("ok {}", cmd.dst()));
                ok.insert(spec, resource);
            }
            // Let Git tooling know that there was an error pushing the ref.
-
            Err(e) => println!("error {} {e}", cmd.dst()),
+
            Err(e) => output.push(format!("error {} {e}", cmd.dst())),
        }
    }

@@ -456,10 +462,15 @@ pub fn run(
                // Connect to local node and announce refs to the network.
                // If our node is not running, we simply skip this step, as the
                // refs will be announced eventually, when the node restarts.
-
                let node = radicle::Node::new(profile.socket());
                if node.is_running() {
                    // Nb. allow this to fail. The push to local storage was still successful.
-
                    sync(stored, ok.into_values().flatten(), opts, node, profile).ok();
+
                    node.sync(
+
                        stored,
+
                        ok.into_values().flatten().collect(),
+
                        opts,
+
                        profile,
+
                    )
+
                    .ok();
                } else if hints {
                    hint("offline push, your node is not running");
                    hint("to sync with the network, run `rad node start`");
@@ -470,10 +481,7 @@ pub fn run(
        }
    }

-
    // Done.
-
    println!();
-

-
    Ok(())
+
    Ok(output)
}

fn patch_base(
@@ -503,15 +511,25 @@ fn patch_base(
///
/// We choose to push a temporary reference to storage, which gets deleted on
/// [`Drop::drop`].
-
struct TempPatchRef<'a> {
+
struct TempPatchRef<'a, G> {
    stored: &'a storage::git::Repository,
    reference: git::fmt::Namespaced<'a>,
+
    git: &'a G,
}

-
impl<'a> TempPatchRef<'a> {
-
    fn new(stored: &'a storage::git::Repository, head: &git::Oid, nid: &NodeId) -> Self {
+
impl<'a, G: GitService> TempPatchRef<'a, G> {
+
    fn new(
+
        stored: &'a storage::git::Repository,
+
        head: &git::Oid,
+
        nid: &NodeId,
+
        git: &'a G,
+
    ) -> Self {
        let reference = git::refs::storage::staging::patch(nid, *head);
-
        Self { stored, reference }
+
        Self {
+
            stored,
+
            reference,
+
            git,
+
        }
    }

    fn push(&self, src: &git::Oid, verbosity: Verbosity) -> Result<(), Error> {
@@ -522,11 +540,12 @@ impl<'a> TempPatchRef<'a> {
            self.stored.raw(),
            verbosity,
            &[],
+
            self.git,
        )
    }
}

-
impl<'a> Drop for TempPatchRef<'a> {
+
impl<'a, G> Drop for TempPatchRef<'a, G> {
    fn drop(&mut self) {
        if let Err(err) = self
            .stored
@@ -544,7 +563,7 @@ impl<'a> Drop for TempPatchRef<'a> {
}

/// Open a new patch.
-
fn patch_open<G>(
+
fn patch_open<G, S>(
    head: &git::Oid,
    upstream: &Option<git::fmt::RefString>,
    nid: &NodeId,
@@ -557,11 +576,13 @@ fn patch_open<G>(
    signer: &Device<G>,
    profile: &Profile,
    opts: Options,
+
    git: &S,
) -> Result<Option<ExplorerResource>, Error>
where
    G: crypto::signature::Signer<crypto::Signature>,
+
    S: GitService,
{
-
    let temp = TempPatchRef::new(stored, head, nid);
+
    let temp = TempPatchRef::new(stored, head, nid, git);
    temp.push(head, opts.verbosity)?;
    let base = patch_base(head, &opts, stored)?;

@@ -680,7 +701,7 @@ where

/// Update an existing patch.
#[allow(clippy::too_many_arguments)]
-
fn patch_update<G>(
+
fn patch_update<G, S>(
    head: &git::Oid,
    dst: &git::fmt::Qualified,
    force: bool,
@@ -695,15 +716,17 @@ fn patch_update<G>(
    signer: &Device<G>,
    opts: Options,
    expected_refs: &[String],
+
    git: &S,
) -> Result<Option<ExplorerResource>, Error>
where
    G: crypto::signature::Signer<crypto::Signature>,
+
    S: GitService,
{
    let Ok(Some(patch)) = patches.get(&patch_id) else {
        return Err(Error::NotFound(patch_id));
    };

-
    let temp = TempPatchRef::new(stored, head, nid);
+
    let temp = TempPatchRef::new(stored, head, nid, git);
    temp.push(head, opts.verbosity)?;

    let base = patch_base(head, &opts, stored)?;
@@ -730,6 +753,7 @@ where
        stored.raw(),
        opts.verbosity,
        expected_refs,
+
        git,
    )?;

    let mut patch_mut = patch::PatchMut::new(patch_id, patch, &mut patches);
@@ -766,7 +790,7 @@ where
    Ok(Some(ExplorerResource::Patch { id: patch_id }))
}

-
fn push<G>(
+
fn push<G, S>(
    src: &git::Oid,
    dst: &git::fmt::Qualified,
    force: bool,
@@ -780,16 +804,26 @@ fn push<G>(
    signer: &Device<G>,
    verbosity: Verbosity,
    expected_refs: &[String],
+
    git: &S,
) -> Result<Option<ExplorerResource>, Error>
where
    G: crypto::signature::Signer<crypto::Signature>,
+
    S: GitService,
{
    let head = *src;
    let dst = dst.with_namespace(nid.into());
    // It's ok for the destination reference to be unknown, eg. when pushing a new branch.
    let old = stored.backend.find_reference(dst.as_str()).ok();

-
    push_ref(src, &dst, force, stored.raw(), verbosity, expected_refs)?;
+
    push_ref(
+
        src,
+
        &dst,
+
        force,
+
        stored.raw(),
+
        verbosity,
+
        expected_refs,
+
        git,
+
    )?;

    if let Some(old) = old {
        let proj = stored.project()?;
@@ -973,6 +1007,7 @@ fn push_ref(
    stored: &git::raw::Repository,
    verbosity: Verbosity,
    expected_refs: &[String],
+
    git: &impl GitService,
) -> Result<(), Error> {
    let path = dunce::canonicalize(stored.path())?.display().to_string();
    // Nb. The *force* indicator (`+`) is processed by Git tooling before we even reach this code.
@@ -996,7 +1031,7 @@ fn push_ref(
    // Rely on the environment variable `GIT_DIR`.
    let working = None;

-
    let output = radicle::git::run(working, args)?;
+
    let output = git.send_pack(working, &args)?;

    if !output.status.success() {
        return Err(Error::SendPackFailed {
@@ -1008,59 +1043,3 @@ fn push_ref(

    Ok(())
}
-

-
/// Sync with the network.
-
fn sync(
-
    repo: &storage::git::Repository,
-
    updated: impl Iterator<Item = ExplorerResource>,
-
    opts: Options,
-
    mut node: radicle::Node,
-
    profile: &Profile,
-
) -> Result<(), cli::node::SyncError> {
-
    let progress = if io::stderr().is_terminal() {
-
        term::PaintTarget::Stderr
-
    } else {
-
        term::PaintTarget::Hidden
-
    };
-
    let result = cli::node::announce(
-
        repo,
-
        cli::node::SyncSettings::default().with_profile(profile),
-
        cli::node::SyncReporting {
-
            progress,
-
            completion: term::PaintTarget::Stderr,
-
            debug: opts.sync_debug,
-
        },
-
        &mut node,
-
        profile,
-
    )?;
-

-
    let mut urls = Vec::new();
-

-
    if let Some(result) = result {
-
        for seed in profile.config.preferred_seeds.iter() {
-
            if result.is_synced(&seed.id) {
-
                for resource in updated {
-
                    let url = profile
-
                        .config
-
                        .public_explorer
-
                        .url(seed.addr.host.clone(), repo.id)
-
                        .resource(resource);
-

-
                    urls.push(url);
-
                }
-
                break;
-
            }
-
        }
-
    }
-

-
    // Print URLs to the updated resources.
-
    if !urls.is_empty() {
-
        eprintln!();
-
        for url in urls {
-
            eprintln!("  {}", term::format::dim(url));
-
        }
-
        eprintln!();
-
    }
-

-
    Ok(())
-
}
added crates/radicle-remote-helper/src/service.rs
@@ -0,0 +1,141 @@
+
use std::io;
+
use std::io::IsTerminal;
+
use std::path::Path;
+
use std::process;
+

+
use radicle::explorer::ExplorerResource;
+
use radicle::git;
+
use radicle::node::Handle;
+
use radicle::storage;
+
use radicle::Profile;
+
use radicle_cli::node::{SyncError, SyncReporting, SyncSettings};
+
use radicle_cli::terminal as term;
+

+
/// Abstraction for Git subprocess calls.
+
pub trait GitService {
+
    /// Run `git fetch-pack`.
+
    fn fetch_pack(
+
        &self,
+
        working: Option<&Path>,
+
        stored: &storage::git::Repository,
+
        oids: Vec<git::Oid>,
+
        verbosity: git::Verbosity,
+
    ) -> io::Result<process::Output>;
+

+
    /// Run `git send-pack` (via `radicle::git::run`).
+
    fn send_pack(
+
        &self,
+
        working: Option<&Path>,
+
        args: &[String],
+
    ) -> io::Result<process::Output>;
+
}
+

+
/// Production implementation using real Git subprocesses.
+
pub struct RealGitService;
+

+
impl GitService for RealGitService {
+
    fn fetch_pack(
+
        &self,
+
        working: Option<&Path>,
+
        stored: &storage::git::Repository,
+
        oids: Vec<git::Oid>,
+
        verbosity: git::Verbosity,
+
    ) -> io::Result<process::Output> {
+
        git::process::fetch_pack(working, stored, oids, verbosity)
+
    }
+

+
    fn send_pack(
+
        &self,
+
        working: Option<&Path>,
+
        args: &[String],
+
    ) -> io::Result<process::Output> {
+
        git::run(working, args)
+
    }
+
}
+

+
/// Abstraction for Node interaction.
+
pub trait NodeSession {
+
    fn is_running(&self) -> bool;
+

+
    fn sync(
+
        &mut self,
+
        repo: &storage::git::Repository,
+
        updated: Vec<ExplorerResource>,
+
        opts: crate::Options,
+
        profile: &Profile,
+
    ) -> Result<(), SyncError>;
+
}
+

+
pub struct RealNodeSession {
+
    node: radicle::Node,
+
}
+

+
impl RealNodeSession {
+
    pub fn new(profile: &Profile) -> Self {
+
        Self {
+
            node: radicle::Node::new(profile.socket()),
+
        }
+
    }
+
}
+

+
impl NodeSession for RealNodeSession {
+
    fn is_running(&self) -> bool {
+
        self.node.is_running()
+
    }
+

+
    fn sync(
+
        &mut self,
+
        repo: &storage::git::Repository,
+
        updated: Vec<ExplorerResource>,
+
        opts: crate::Options,
+
        profile: &Profile,
+
    ) -> Result<(), SyncError> {
+
        let progress = if io::stderr().is_terminal() {
+
            term::PaintTarget::Stderr
+
        } else {
+
            term::PaintTarget::Hidden
+
        };
+

+
        let result = radicle_cli::node::announce(
+
            repo,
+
            SyncSettings::default().with_profile(profile),
+
            SyncReporting {
+
                progress,
+
                completion: term::PaintTarget::Stderr,
+
                debug: opts.sync_debug,
+
            },
+
            &mut self.node,
+
            profile,
+
        )?;
+

+
        let mut urls = Vec::new();
+

+
        if let Some(result) = result {
+
            for seed in profile.config.preferred_seeds.iter() {
+
                if result.is_synced(&seed.id) {
+
                    for resource in updated {
+
                        let url = profile
+
                            .config
+
                            .public_explorer
+
                            .url(seed.addr.host.clone(), repo.id)
+
                            .resource(resource);
+

+
                        urls.push(url);
+
                    }
+
                    break;
+
                }
+
            }
+
        }
+

+
        // Print URLs to the updated resources.
+
        if !urls.is_empty() {
+
            eprintln!();
+
            for url in urls {
+
                eprintln!("  {}", term::format::dim(url));
+
            }
+
            eprintln!();
+
        }
+

+
        Ok(())
+
    }
+
}