Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
remote-helper: Small improvements
Merged lorenz opened 7 months ago

This patch combines three only loosely connected improvements to radicle-remote-helper, which also touch radicle and radicle-cli:

  1. Interpret the verbosity option part of the Git remote helper protocol.
  2. Move interpretation of output of git commands to binary crates.
  3. Prevent doubly verifying pushes from working copy to storage.
6 files changed +158 -50 11e8b89b 37903795
modified crates/radicle-cli/src/commands/patch/checkout.rs
@@ -132,7 +132,7 @@ fn find_patch_commit<'a>(
    match working.find_commit(head) {
        Ok(commit) => Ok(commit),
        Err(e) if git::ext::is_not_found_err(&e) => {
-
            git::process::fetch_local(workdir, stored, [head.into()])?;
+
            git::process::fetch_local(workdir, stored, [head.into()], git::Verbosity::default())?;
            working.find_commit(head).map_err(|e| e.into())
        }
        Err(e) => Err(e.into()),
modified crates/radicle-cli/src/git.rs
@@ -132,11 +132,23 @@ pub fn repository() -> Result<Repository, anyhow::Error> {
}

/// Execute a git command by spawning a child process.
+
/// Returns [`Result::Ok`] if the command *exited successfully*.
pub fn git<S: AsRef<std::ffi::OsStr>>(
    repo: &std::path::Path,
    args: impl IntoIterator<Item = S>,
-
) -> Result<String, io::Error> {
-
    radicle::git::run::<_, _, &str, &str>(repo, args, [])
+
) -> anyhow::Result<std::process::Output> {
+
    let output = radicle::git::run::<_, _, &str, &str>(repo, args, [])?;
+

+
    if !output.status.success() {
+
        anyhow::bail!(
+
            "`git` exited with status {}, stderr and stdout follow:\n{}\n{}\n",
+
            output.status,
+
            String::from_utf8_lossy(&output.stderr),
+
            String::from_utf8_lossy(&output.stdout),
+
        )
+
    }
+

+
    Ok(output)
}

/// Configure SSH signing in the given git repo, for the given peer.
modified crates/radicle-remote-helper/src/fetch.rs
@@ -7,7 +7,7 @@ use thiserror::Error;
use radicle::git;
use radicle::storage::ReadRepository;

-
use crate::read_line;
+
use crate::{read_line, Verbosity};

#[derive(Debug, Error)]
pub enum Error {
@@ -31,6 +31,7 @@ pub fn run<R: ReadRepository>(
    working: &Path,
    stored: R,
    stdin: &io::Stdin,
+
    verbosity: Verbosity,
) -> Result<(), Error> {
    // Read all the `fetch` lines.
    let mut line = String::new();
@@ -62,7 +63,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.
-
    git::process::fetch_local(working, &stored, oids)?;
+
    git::process::fetch_local(working, &stored, oids, verbosity.into())?;

    // Nb. An empty line means we're done.
    println!();
modified crates/radicle-remote-helper/src/lib.rs
@@ -70,6 +70,40 @@ pub enum Error {
    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.
@@ -84,6 +118,7 @@ pub struct Options {
    base: Option<Rev>,
    /// Patch message.
    message: cli::patch::Message,
+
    verbosity: Verbosity,
}

/// Run the radicle remote helper using the given profile.
@@ -139,9 +174,15 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
                println!("fetch");
                println!();
            }
-
            ["option", "verbosity"] => {
-
                println!("ok");
-
            }
+
            ["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
@@ -167,7 +208,7 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
                    path: working.clone(),
                })?;

-
                return fetch::run(vec![(oid, refstr)], working, stored, &stdin)
+
                return fetch::run(vec![(oid, refstr)], working, stored, &stdin, opts.verbosity)
                    .map_err(Error::from);
            }
            ["push", refspec] => {
modified crates/radicle-remote-helper/src/push.rs
@@ -6,6 +6,7 @@ mod error;
use std::collections::HashMap;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
+
use std::process::ExitStatus;
use std::str::FromStr;
use std::{assert_eq, io};

@@ -31,7 +32,7 @@ use radicle::{git, rad};
use radicle_cli as cli;
use radicle_cli::terminal as term;

-
use crate::{hint, read_line, Options};
+
use crate::{hint, read_line, Options, Verbosity};

#[derive(Debug, Error)]
pub enum Error {
@@ -124,6 +125,15 @@ pub enum Error {
    UnknownObjectType { oid: git::Oid },
    #[error(transparent)]
    FindObjects(#[from] git::canonical::error::FindObjectsError),
+

+
    /// Errors for "internal" pushes, i.e., pushes that this process
+
    /// initiates between the working copy and storage.
+
    #[error("internal push failed with exit status {status}, stderr and stdout follow:\n{stderr}\n{stdout}")]
+
    InternalPushFailed {
+
        status: ExitStatus,
+
        stderr: String,
+
        stdout: String,
+
    },
}

/// Push command.
@@ -346,8 +356,17 @@ pub fn run(
                        let rules = crefs.rules();
                        let me = Did::from(nid);

-
                        let explorer =
-
                            push(src, &dst, *force, &nid, &working, stored, patches, &signer)?;
+
                        let explorer = push(
+
                            src,
+
                            &dst,
+
                            *force,
+
                            &nid,
+
                            &working,
+
                            stored,
+
                            patches,
+
                            &signer,
+
                            opts.verbosity,
+
                        )?;
                        // If we're trying to update the canonical head, make sure
                        // we don't diverge from the current head. This only applies
                        // to repos with more than one delegate.
@@ -487,7 +506,7 @@ where
    //
    // In case the reference is not properly deleted, the next attempt to open a patch should
    // not fail, since the reference will already exist with the correct OID.
-
    push_ref(src, &dst, false, working, stored.raw())?;
+
    push_ref(src, &dst, false, working, stored.raw(), opts.verbosity)?;

    let (_, target) = stored.canonical_head()?;
    let base = if let Some(base) = opts.base {
@@ -601,7 +620,7 @@ where
    let commit = *src;
    let dst = dst.with_namespace(nid.into());

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

    let Ok(Some(patch)) = patches.get(&patch_id) else {
        return Err(Error::NotFound(patch_id));
@@ -671,6 +690,7 @@ fn push<G>(
        cob::cache::StoreWriter,
    >,
    signer: &Device<G>,
+
    verbosity: Verbosity,
) -> Result<Option<ExplorerResource>, Error>
where
    G: crypto::signature::Signer<crypto::Signature>,
@@ -680,7 +700,7 @@ where
    // 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, working, stored.raw())?;
+
    push_ref(src, &dst, force, working, stored.raw(), verbosity)?;

    if let Some(old) = old {
        let proj = stored.project()?;
@@ -863,6 +883,7 @@ fn push_ref(
    force: bool,
    working: &git::raw::Repository,
    stored: &git::raw::Repository,
+
    verbosity: Verbosity,
) -> Result<(), Error> {
    let url = git::url::File::new(stored.path()).to_string();
    // Nb. The *force* indicator (`+`) is processed by Git tooling before we even reach this code.
@@ -870,21 +891,32 @@ fn push_ref(
    let refspec = git::Refspec { src, dst, force };
    let repo = working.workdir().unwrap_or_else(|| working.path());

-
    radicle::git::run::<_, _, &str, &str>(
-
        repo,
-
        [
-
            "push",
-
            url.to_string().as_str(),
-
            refspec.to_string().as_str(),
-
        ],
-
        [],
-
    )
-
    .map_err(|err| {
-
        Error::Io(std::io::Error::other(format!(
-
            "failed to run `git push {url} {refspec}` in {:?}: {err}",
-
            working.path()
-
        )))
-
    })?;
+
    let mut args = vec![
+
        "push".to_string(),
+
        // This push is "internal" from the point of view of the user.
+
        // If they want to run a pre-push hook, then they would configure
+
        // this on their remote, and the hook would run before we get here.
+
        // However, the context of this invocation of `git push` is
+
        // the same repository, so if the user has configured a pre-push
+
        // hook it would run twice, which is not what we want, so set this
+
        // option.
+
        "--no-verify".to_string(),
+
    ];
+

+
    let verbosity: git::Verbosity = verbosity.into();
+
    args.extend(verbosity.into_flag());
+

+
    args.extend([url.to_string(), refspec.to_string()]);
+

+
    let output = radicle::git::run::<_, _, &str, &str>(repo, args, [])?;
+

+
    if !output.status.success() {
+
        return Err(Error::InternalPushFailed {
+
            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
+
            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
+
            status: output.status,
+
        });
+
    }

    Ok(())
}
modified crates/radicle/src/git.rs
@@ -54,6 +54,38 @@ impl std::fmt::Display for Version {
    }
}

+
/// Verbosity level for Git commands.
+
#[derive(Default, Clone, Copy)]
+
pub struct Verbosity(i8);
+

+
impl Verbosity {
+
    /// Transform into a command line flag, helpful for passing to invocations
+
    /// of `git`.
+
    ///
+
    /// See <https://github.com/git/git/blob/c44beea485f0f2feaf460e2ac87fdd5608d63cf0/builtin/pull.c#L264-L276>
+
    pub fn into_flag(&self) -> Option<String> {
+
        const FLAG_PREFIX: &str = "-";
+
        const FLAG_QUIET: &str = "q";
+
        const FLAG_VERBOSE: &str = "v";
+

+
        let repetitions = self.0.unsigned_abs() as usize;
+

+
        if repetitions == 0 {
+
            return None;
+
        }
+

+
        let flag = if self.0 > 0 { FLAG_VERBOSE } else { FLAG_QUIET };
+

+
        Some(FLAG_PREFIX.to_string() + &flag.repeat(repetitions))
+
    }
+
}
+

+
impl From<i8> for Verbosity {
+
    fn from(v: i8) -> Self {
+
        Self(v)
+
    }
+
}
+

#[derive(thiserror::Error, Debug)]
pub enum VersionError {
    #[error("malformed git version string")]
@@ -722,29 +754,18 @@ pub fn run<P, S, K, V>(
    repo: P,
    args: impl IntoIterator<Item = S>,
    envs: impl IntoIterator<Item = (K, V)>,
-
) -> Result<String, io::Error>
+
) -> io::Result<std::process::Output>
where
    P: AsRef<Path>,
    S: AsRef<std::ffi::OsStr>,
    K: AsRef<std::ffi::OsStr>,
    V: AsRef<std::ffi::OsStr>,
{
-
    let output = Command::new("git")
+
    Command::new("git")
        .current_dir(repo)
        .envs(envs)
        .args(args)
-
        .output()?;
-

-
    if output.status.success() {
-
        let out = if output.stdout.is_empty() {
-
            &output.stderr
-
        } else {
-
            &output.stdout
-
        };
-
        return Ok(String::from_utf8_lossy(out).into());
-
    }
-

-
    Err(io::Error::other(String::from_utf8_lossy(&output.stderr)))
+
        .output()
}

/// Functions that call to the `git` CLI instead of `git2`.
@@ -754,7 +775,7 @@ pub mod process {

    use crate::storage::ReadRepository;

-
    use super::{run, url, Oid};
+
    use super::{run, url, Oid, Verbosity};

    /// Perform a local fetch, i.e. `file://<storage path>`.
    ///
@@ -764,20 +785,21 @@ pub mod process {
        working: &Path,
        storage: &R,
        oids: impl IntoIterator<Item = Oid>,
-
    ) -> Result<(), io::Error>
+
        verbosity: Verbosity,
+
    ) -> io::Result<std::process::Output>
    where
        R: ReadRepository,
    {
        let mut fetch = vec![
            "fetch".to_string(),
-
            url::File::new(storage.path()).to_string(),
-
            // N.b. avoid writing fetch head since we're only fetching objects
+
            // Avoid writing fetch head since we're only fetching objects
            "--no-write-fetch-head".to_string(),
        ];
+
        fetch.extend(verbosity.into_flag());
+
        fetch.push(url::File::new(storage.path()).to_string());
        fetch.extend(oids.into_iter().map(|oid| oid.to_string()));
        // N.b. `.` is used since we're fetching within the working copy
-
        run::<_, _, &str, &str>(working, fetch, [])?;
-
        Ok(())
+
        run::<_, _, &str, &str>(working, fetch, [])
    }
}