Radish alpha
r
rad:z6cFWeWpnZNHh9rUW8phgA3b5yGt
Git libraries for Radicle
Radicle
Git
Merge remote-tracking branch 'origin/git-commit'
Fintan Halpenny committed 3 years ago
commit 1216dd0b420e2fb9c07ad7fb0f13f86e07f8d2f6
parent 7fd4028
10 files changed +677 -0
modified Cargo.toml
@@ -1,6 +1,7 @@
[workspace]
members = [
  "git-ref-format",
+
  "git-commit",
  "git-storage",
  "git-trailers",
  "link-git",
added git-commit/Cargo.toml
@@ -0,0 +1,22 @@
+
[package]
+
name = "git-commit"
+
version = "0.1.0"
+
license = "MIT OR Apache-2.0"
+
edition = "2021"
+
authors = [
+
  "Alexis Sellier <alexis@radicle.xyz>",
+
  "Fintan Halpenny <fintan.halpenny@gmail.com>",
+
]
+
keywords = ["git", "radicle"]
+

+
[dependencies]
+
thiserror = "1"
+

+
[dependencies.git2]
+
version = "0.15.0"
+
default-features = false
+
features = ["vendored-libgit2"]
+

+
[dependencies.git-trailers]
+
version = "0.1.0"
+
path = "../git-trailers"
added git-commit/src/author.rs
@@ -0,0 +1,119 @@
+
use std::{
+
    fmt,
+
    num::ParseIntError,
+
    str::{self, FromStr},
+
};
+

+
use thiserror::Error;
+

+
/// The data for indicating authorship of an action within a
+
/// [`super::Commit`].
+
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+
pub struct Author {
+
    /// Name corresponding to `user.name` in the git config.
+
    ///
+
    /// Note: this must not contain `<` or `>`.
+
    pub name: String,
+
    /// Email corresponding to `user.email` in the git config.
+
    ///
+
    /// Note: this must not contain `<` or `>`.
+
    pub email: String,
+
    /// The time of this author's action.
+
    pub time: Time,
+
}
+

+
/// The time of a [`Author`]'s action.
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+
pub struct Time {
+
    seconds: i64,
+
    offset: i32,
+
}
+

+
impl Time {
+
    pub fn new(seconds: i64, offset: i32) -> Self {
+
        Self { seconds, offset }
+
    }
+
}
+

+
impl From<Time> for git2::Time {
+
    fn from(t: Time) -> Self {
+
        Self::new(t.seconds, t.offset)
+
    }
+
}
+

+
impl From<git2::Time> for Time {
+
    fn from(t: git2::Time) -> Self {
+
        Self::new(t.seconds(), t.offset_minutes())
+
    }
+
}
+

+
impl fmt::Display for Time {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        let sign = if self.offset.is_negative() { '-' } else { '+' };
+
        write!(f, "{} {}{:0>4}", self.seconds, sign, self.offset.abs())
+
    }
+
}
+

+
impl fmt::Display for Author {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        write!(f, "{} <{}> {}", self.name, self.email, self.time,)
+
    }
+
}
+

+
impl TryFrom<&Author> for git2::Signature<'_> {
+
    type Error = git2::Error;
+

+
    fn try_from(person: &Author) -> Result<Self, Self::Error> {
+
        let time = git2::Time::new(person.time.seconds, person.time.offset);
+
        git2::Signature::new(&person.name, &person.email, &time)
+
    }
+
}
+

+
impl<'a> TryFrom<&git2::Signature<'a>> for Author {
+
    type Error = str::Utf8Error;
+

+
    fn try_from(value: &git2::Signature<'a>) -> Result<Self, Self::Error> {
+
        Ok(Self {
+
            name: str::from_utf8(value.name_bytes())?.to_string(),
+
            email: str::from_utf8(value.email_bytes())?.to_string(),
+
            time: value.when().into(),
+
        })
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
pub enum ParseError {
+
    #[error("missing '{0}' while parsing person signature")]
+
    Missing(&'static str),
+
    #[error("offset was incorrect format while parsing person signature")]
+
    Offset(#[source] ParseIntError),
+
    #[error("time was incorrect format while parsing person signature")]
+
    Time(#[source] ParseIntError),
+
    #[error("time offset is expected to be '+'/'-' for a person siganture")]
+
    UnknownOffset,
+
}
+

+
impl FromStr for Author {
+
    type Err = ParseError;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        let mut components = s.split(' ');
+
        let offset = match components.next_back() {
+
            None => return Err(ParseError::Missing("offset")),
+
            Some(offset) => offset.parse::<i32>().map_err(ParseError::Offset)?,
+
        };
+
        let time = match components.next_back() {
+
            None => return Err(ParseError::Missing("time")),
+
            Some(time) => time.parse::<i64>().map_err(ParseError::Time)?,
+
        };
+
        let time = Time::new(time, offset);
+

+
        let email = components
+
            .next_back()
+
            .ok_or(ParseError::Missing("email"))?
+
            .trim_matches(|c| c == '<' || c == '>')
+
            .to_owned();
+
        let name = components.collect::<Vec<_>>().join(" ");
+
        Ok(Self { name, email, time })
+
    }
+
}
added git-commit/src/headers.rs
@@ -0,0 +1,71 @@
+
use std::borrow::Cow;
+

+
const BEGIN_SSH: &str = "-----BEGIN SSH SIGNATURE-----\n";
+
const BEGIN_PGP: &str = "-----BEGIN PGP SIGNATURE-----\n";
+

+
/// A collection of headers stored in a [`super::Commit`].
+
///
+
/// Note: these do not include `tree`, `parent`, `author`, and `committer`.
+
#[derive(Clone, Debug, Default)]
+
pub struct Headers(pub(super) Vec<(String, String)>);
+

+
/// A `gpgsig` signature stored in a [`super::Commit`].
+
pub enum Signature<'a> {
+
    /// A PGP signature, i.e. starts with `-----BEGIN PGP SIGNATURE-----`.
+
    Pgp(Cow<'a, str>),
+
    /// A SSH signature, i.e. starts with `-----BEGIN SSH SIGNATURE-----`.
+
    Ssh(Cow<'a, str>),
+
}
+

+
impl<'a> Signature<'a> {
+
    fn from_str(s: &'a str) -> Result<Self, UnknownScheme> {
+
        if s.starts_with(BEGIN_SSH) {
+
            Ok(Signature::Ssh(Cow::Borrowed(s)))
+
        } else if s.starts_with(BEGIN_PGP) {
+
            Ok(Signature::Pgp(Cow::Borrowed(s)))
+
        } else {
+
            Err(UnknownScheme)
+
        }
+
    }
+
}
+

+
pub struct UnknownScheme;
+

+
impl<'a> ToString for Signature<'a> {
+
    fn to_string(&self) -> String {
+
        match self {
+
            Signature::Pgp(pgp) => pgp.to_string(),
+
            Signature::Ssh(ssh) => ssh.to_string(),
+
        }
+
    }
+
}
+

+
impl Headers {
+
    pub fn new() -> Self {
+
        Headers(Vec::new())
+
    }
+

+
    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
+
        self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
+
    }
+

+
    pub fn values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a str> + '_ {
+
        self.iter()
+
            .filter_map(move |(k, v)| (k == name).then_some(v))
+
    }
+

+
    pub fn signatures(&self) -> impl Iterator<Item = Signature> + '_ {
+
        self.0.iter().filter_map(|(k, v)| {
+
            if k == "gpgsig" {
+
                Signature::from_str(v).ok()
+
            } else {
+
                None
+
            }
+
        })
+
    }
+

+
    /// Push a header to the end of the headers section.
+
    pub fn push(&mut self, name: &str, value: &str) {
+
        self.0.push((name.to_owned(), value.trim().to_owned()));
+
    }
+
}
added git-commit/src/lib.rs
@@ -0,0 +1,290 @@
+
//! The `git-commit` crate provides parsing a displaying of a [git
+
//! commit][git-commit].
+
//!
+
//! The [`Commit`] data can be constructed using the `FromStr`
+
//! implementation, or by converting from a `git2::Buf`.
+
//!
+
//! The [`Headers`] can be accessed via [`Commit::headers`]. If the
+
//! signatures of the commit are of particular interest, the
+
//! [`Commit::signatures`] method can be used, which returns a series of
+
//! [`Signature`]s.
+
//!
+
//! [git-commit]: https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
+

+
use std::{
+
    borrow::Cow,
+
    fmt::Write as _,
+
    str::{self, FromStr},
+
};
+

+
use git2::{ObjectType, Oid};
+
use git_trailers::{self as trailers, OwnedTrailer};
+

+
pub mod author;
+
pub use author::Author;
+

+
pub mod headers;
+
pub use headers::{Headers, Signature};
+
use trailers::Trailer;
+

+
/// A git commit in its object description form, i.e. the output of
+
/// `git cat-file` for a commit object.
+
#[derive(Debug)]
+
pub struct Commit {
+
    tree: Oid,
+
    parents: Vec<Oid>,
+
    author: Author,
+
    committer: Author,
+
    headers: Headers,
+
    message: String,
+
    trailers: Vec<OwnedTrailer>,
+
}
+

+
impl Commit {
+
    pub fn new<T>(
+
        tree: Oid,
+
        parents: Vec<Oid>,
+
        author: Author,
+
        committer: Author,
+
        headers: Headers,
+
        message: String,
+
        trailers: T,
+
    ) -> Self
+
    where
+
        T: IntoIterator<Item = OwnedTrailer>,
+
    {
+
        Self {
+
            tree,
+
            parents,
+
            author,
+
            committer,
+
            headers,
+
            message,
+
            trailers: trailers.into_iter().collect(),
+
        }
+
    }
+

+
    /// Read the [`Commit`] from the `repo` that is expected to be found at
+
    /// `oid`.
+
    pub fn read(repo: &git2::Repository, oid: Oid) -> Result<Self, error::Read> {
+
        let odb = repo.odb()?;
+
        let object = odb.read(oid)?;
+
        Ok(Commit::try_from(object.data())?)
+
    }
+

+
    /// Write the given [`Commit`] to the `repo`. The resulting `Oid`
+
    /// is the identifier for this commit.
+
    pub fn write(&self, repo: &git2::Repository) -> Result<Oid, git2::Error> {
+
        let odb = repo.odb()?;
+
        odb.write(ObjectType::Commit, self.to_string().as_bytes())
+
    }
+

+
    /// The tree [`Oid`] this commit points to.
+
    pub fn tree(&self) -> Oid {
+
        self.tree
+
    }
+

+
    /// The parent [`Oid`]s of this commit.
+
    pub fn parents(&self) -> impl Iterator<Item = Oid> + '_ {
+
        self.parents.iter().copied()
+
    }
+

+
    /// The author of this commit, i.e. the header corresponding to `author`.
+
    pub fn author(&self) -> &Author {
+
        &self.author
+
    }
+

+
    /// The committer of this commit, i.e. the header corresponding to
+
    /// `committer`.
+
    pub fn committer(&self) -> &Author {
+
        &self.committer
+
    }
+

+
    /// The message body of this commit.
+
    pub fn message(&self) -> &str {
+
        &self.message
+
    }
+

+
    /// The [`Signature`]s found in this commit, i.e. the headers corresponding
+
    /// to `gpgsig`.
+
    pub fn signatures(&self) -> impl Iterator<Item = Signature> + '_ {
+
        self.headers.signatures()
+
    }
+

+
    /// The [`Headers`] found in this commit.
+
    ///
+
    /// Note: these do not include `tree`, `parent`, `author`, and `committer`.
+
    pub fn headers(&self) -> impl Iterator<Item = (&str, &str)> {
+
        self.headers.iter()
+
    }
+

+
    /// Iterate over the [`Headers`] values that match the provided `name`.
+
    pub fn values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a str> + '_ {
+
        self.headers.values(name)
+
    }
+

+
    /// Push a header to the end of the headers section.
+
    pub fn push_header(&mut self, name: &str, value: &str) {
+
        self.headers.push(name, value.trim());
+
    }
+

+
    pub fn trailers(&self) -> impl Iterator<Item = &OwnedTrailer> {
+
        self.trailers.iter()
+
    }
+
}
+

+
pub mod error {
+
    use std::str;
+

+
    use thiserror::Error;
+

+
    use super::author;
+

+
    #[derive(Debug, Error)]
+
    pub enum Read {
+
        #[error(transparent)]
+
        Git(#[from] git2::Error),
+
        #[error(transparent)]
+
        Parse(#[from] Parse),
+
    }
+

+
    #[derive(Debug, Error)]
+
    pub enum Parse {
+
        #[error(transparent)]
+
        Author(#[from] author::ParseError),
+
        #[error("invalid '{header}'")]
+
        InvalidHeader {
+
            header: &'static str,
+
            #[source]
+
            err: git2::Error,
+
        },
+
        #[error("invalid git commit object format")]
+
        InvalidFormat,
+
        #[error("missing '{0}' while parsing commit")]
+
        Missing(&'static str),
+
        #[error(transparent)]
+
        Token(#[from] git_trailers::InvalidToken),
+
        #[error("error occurred while checking for git-trailers: {0}")]
+
        Trailers(#[source] git2::Error),
+
        #[error(transparent)]
+
        Utf8(#[from] str::Utf8Error),
+
    }
+
}
+

+
impl TryFrom<git2::Buf> for Commit {
+
    type Error = error::Parse;
+

+
    fn try_from(value: git2::Buf) -> Result<Self, Self::Error> {
+
        value.as_str().ok_or(error::Parse::InvalidFormat)?.parse()
+
    }
+
}
+

+
impl TryFrom<&[u8]> for Commit {
+
    type Error = error::Parse;
+

+
    fn try_from(data: &[u8]) -> Result<Self, Self::Error> {
+
        Commit::from_str(str::from_utf8(data)?)
+
    }
+
}
+

+
impl FromStr for Commit {
+
    type Err = error::Parse;
+

+
    fn from_str(buffer: &str) -> Result<Self, Self::Err> {
+
        let (header, message) = buffer
+
            .split_once("\n\n")
+
            .ok_or(error::Parse::InvalidFormat)?;
+
        let mut lines = header.lines();
+

+
        let tree = match lines.next() {
+
            Some(tree) => tree
+
                .strip_prefix("tree ")
+
                .map(git2::Oid::from_str)
+
                .transpose()
+
                .map_err(|err| error::Parse::InvalidHeader {
+
                    header: "tree",
+
                    err,
+
                })?
+
                .ok_or(error::Parse::Missing("tree"))?,
+
            None => return Err(error::Parse::Missing("tree")),
+
        };
+

+
        let mut parents = Vec::new();
+
        let mut author: Option<Author> = None;
+
        let mut committer: Option<Author> = None;
+
        let mut headers = Headers::new();
+

+
        for line in lines {
+
            // Check if a signature is still being parsed
+
            if let Some(rest) = line.strip_prefix(' ') {
+
                let value: &mut String = headers
+
                    .0
+
                    .last_mut()
+
                    .map(|(_, v)| v)
+
                    .ok_or(error::Parse::InvalidFormat)?;
+
                value.push('\n');
+
                value.push_str(rest);
+
                continue;
+
            }
+

+
            if let Some((name, value)) = line.split_once(' ') {
+
                match name {
+
                    "parent" => parents.push(git2::Oid::from_str(value).map_err(|err| {
+
                        error::Parse::InvalidHeader {
+
                            header: "parent",
+
                            err,
+
                        }
+
                    })?),
+
                    "author" => author = Some(value.parse::<Author>()?),
+
                    "committer" => committer = Some(value.parse::<Author>()?),
+
                    _ => headers.push(name, value),
+
                }
+
                continue;
+
            }
+
        }
+

+
        // FIXME: would be nice if git-trailers could parse a message
+
        // looking for the trailers, instead of going through git2
+
        // here.
+
        let trailers = git2::message_trailers_strs(message).map_err(error::Parse::Trailers)?;
+
        let trailers = trailers
+
            .iter()
+
            .map(|(token, values)| {
+
                let values = values.split_whitespace().map(Cow::Borrowed).collect();
+
                trailers::Token::try_from(token).map(|token| Trailer { token, values }.to_owned())
+
            })
+
            .collect::<Result<Vec<_>, _>>()?;
+
        Ok(Self {
+
            tree,
+
            parents,
+
            author: author.ok_or(error::Parse::Missing("author"))?,
+
            committer: committer.ok_or(error::Parse::Missing("committer"))?,
+
            headers,
+
            message: message.to_owned(),
+
            trailers,
+
        })
+
    }
+
}
+

+
impl ToString for Commit {
+
    fn to_string(&self) -> String {
+
        let mut buf = String::new();
+

+
        writeln!(buf, "tree {}", self.tree).ok();
+

+
        for parent in &self.parents {
+
            writeln!(buf, "parent {}", parent).ok();
+
        }
+

+
        writeln!(buf, "author {}", self.author).ok();
+
        writeln!(buf, "committer {}", self.committer).ok();
+

+
        for (name, value) in self.headers.iter() {
+
            writeln!(buf, "{} {}", name, value.replace('\n', "\n ")).ok();
+
        }
+
        writeln!(buf).ok();
+
        write!(buf, "{}", self.message).ok();
+

+
        buf
+
    }
+
}
added git-commit/t/Cargo.toml
@@ -0,0 +1,26 @@
+
[package]
+
name = "git-commit-test"
+
version = "0.1.0"
+
license = "MIT OR Apache-2.0"
+
edition = "2021"
+

+
publish = false
+

+
[lib]
+
doctest = false
+
test = true
+
doc = false
+

+
[features]
+
test = []
+

+
[dependencies.git-commit]
+
path = ".."
+

+
[dev-dependencies.git2]
+
version = "0.15.0"
+
default-features = false
+
features = ["vendored-libgit2"]
+

+
[dev-dependencies.test-helpers]
+
path = "../../test/test-helpers"

\ No newline at end of file
added git-commit/t/src/commit.rs
@@ -0,0 +1,111 @@
+
use std::str::FromStr as _;
+

+
use git_commit::Commit;
+

+
const UNSIGNED: &str = "\
+
tree c66cc435f83ed0fba90ed4500e9b4b96e9bd001b
+
parent af06ad645133f580a87895353508053c5de60716
+
author Alexis Sellier <alexis@radicle.xyz> 1664467633 +0200
+
committer Alexis Sellier <alexis@radicle.xyz> 1664786099 -0200
+

+
Add SSH functionality with new `radicle-ssh`
+

+
We borrow code from `thrussh`, refactored to be runtime-less.
+

+
X-Signed-Off-By: Alex Sellier
+
";
+

+
const SSH_SIGNATURE: &str = "\
+
-----BEGIN SSH SIGNATURE-----
+
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgvjrQogRxxLjzzWns8+mKJAGzEX
+
4fm2ALoN7pyvD2ttQAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
+
AAAAQIQvhIewOgGfnXLgR5Qe1ZEr2vjekYXTdOfNWICi6ZiosgfZnIqV0enCPC4arVqQg+
+
GPp0HqxaB911OnSAr6bwU=
+
-----END SSH SIGNATURE-----";
+

+
const PGP_SIGNATURE: &str = "\
+
-----BEGIN PGP SIGNATURE-----
+
iQIzBAABCAAdFiEEHe7BWIo9taTY6TIiJVL7b2QGbLcFAmNcDhsACgkQJVL7b2QG
+
bLcc9Q//RgKf5N4enta9AuszGJZvdFhMPfIDUdw+WAZA6Z8zDPb/aAXZrPP/KIOM
+
zmX08FTqjP9B9YeWrEcFuAtxsRNqbDKrfpko9Y6bTsdrAJg3WIypBb9F8YDKJ6BO
+
CORJJqWOsLW129jW+mJDhcE0YTvPlcMiMI2qjVXKhU6Ag11W8IRZyTb9tvEaDjBR
+
YUnkPvgubv61K9BeUKexE2MakPBldaQtl0MF1Dk7/zo5btLd+KP0SOUKEhuMEu5b
+
LATHHdiYjt/2Xz7q8EcrFxXUaipxZe89dfTdi2ooJQw3ZDqjDHsGTHpDeBuzuSaJ
+
9fKVRwFz/78onfHPhmU4wfUhh+Fcl90p5/T+4dt2K6cr+7rq078e+aJYxkX2d0MG
+
PG0xGP0RN4g+X92K1kGuzoe4870xAnRTNh5nUB+X9snO8tVqQZTb0M2yI+sTsKrv
+
w/f+uiqL6e9DgIxlO5dgiNHCVoCs1QJ900jUGisrlzS4+n6GzMsG6s3c01X4yY9G
+
Ou/kGkMsn7tqejqC9RufygcchCFZqYwaHQwPkiYhfYGMarMpoCFvll0h8tSparpS
+
nnpAQXVdu8m3v1YdPUuTg5ksxSOe9HCIlVXGFhxy3iqCVRn+51FRnUI63rMTOm9/
+
LBqzvji02lDUPGqPgXfcCS0ty8FM2flBIXnwb8TDzCaPYhf53+U=
+
=6dw2
+
-----END PGP SIGNATURE-----";
+

+
const SIGNED: &str = "\
+
tree c66cc435f83ed0fba90ed4500e9b4b96e9bd001b
+
parent af06ad645133f580a87895353508053c5de60716
+
author Alexis Sellier <alexis@radicle.xyz> 1664467633 +0200
+
committer Alexis Sellier <alexis@radicle.xyz> 1664786099 -0200
+
other e6fe3c97619deb8ab4198620f9a7eb79d98363dd
+
gpgsig -----BEGIN SSH SIGNATURE-----
+
 U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgvjrQogRxxLjzzWns8+mKJAGzEX
+
 4fm2ALoN7pyvD2ttQAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
+
 AAAAQIQvhIewOgGfnXLgR5Qe1ZEr2vjekYXTdOfNWICi6ZiosgfZnIqV0enCPC4arVqQg+
+
 GPp0HqxaB911OnSAr6bwU=
+
 -----END SSH SIGNATURE-----
+
gpgsig -----BEGIN PGP SIGNATURE-----
+
 iQIzBAABCAAdFiEEHe7BWIo9taTY6TIiJVL7b2QGbLcFAmNcDhsACgkQJVL7b2QG
+
 bLcc9Q//RgKf5N4enta9AuszGJZvdFhMPfIDUdw+WAZA6Z8zDPb/aAXZrPP/KIOM
+
 zmX08FTqjP9B9YeWrEcFuAtxsRNqbDKrfpko9Y6bTsdrAJg3WIypBb9F8YDKJ6BO
+
 CORJJqWOsLW129jW+mJDhcE0YTvPlcMiMI2qjVXKhU6Ag11W8IRZyTb9tvEaDjBR
+
 YUnkPvgubv61K9BeUKexE2MakPBldaQtl0MF1Dk7/zo5btLd+KP0SOUKEhuMEu5b
+
 LATHHdiYjt/2Xz7q8EcrFxXUaipxZe89dfTdi2ooJQw3ZDqjDHsGTHpDeBuzuSaJ
+
 9fKVRwFz/78onfHPhmU4wfUhh+Fcl90p5/T+4dt2K6cr+7rq078e+aJYxkX2d0MG
+
 PG0xGP0RN4g+X92K1kGuzoe4870xAnRTNh5nUB+X9snO8tVqQZTb0M2yI+sTsKrv
+
 w/f+uiqL6e9DgIxlO5dgiNHCVoCs1QJ900jUGisrlzS4+n6GzMsG6s3c01X4yY9G
+
 Ou/kGkMsn7tqejqC9RufygcchCFZqYwaHQwPkiYhfYGMarMpoCFvll0h8tSparpS
+
 nnpAQXVdu8m3v1YdPUuTg5ksxSOe9HCIlVXGFhxy3iqCVRn+51FRnUI63rMTOm9/
+
 LBqzvji02lDUPGqPgXfcCS0ty8FM2flBIXnwb8TDzCaPYhf53+U=
+
 =6dw2
+
 -----END PGP SIGNATURE-----
+

+
Add SSH functionality with new `radicle-ssh`
+

+
We borrow code from `thrussh`, refactored to be runtime-less.
+

+
X-Signed-Off-By: Alex Sellier
+
";
+

+
#[test]
+
fn test_push_header() {
+
    let mut commit = Commit::from_str(UNSIGNED).unwrap();
+

+
    commit.push_header("other", "e6fe3c97619deb8ab4198620f9a7eb79d98363dd");
+
    commit.push_header("gpgsig", SSH_SIGNATURE);
+
    commit.push_header("gpgsig", PGP_SIGNATURE);
+

+
    assert_eq!(commit.to_string(), SIGNED);
+
}
+

+
#[test]
+
fn test_get_header() {
+
    let commit = Commit::from_str(SIGNED).unwrap();
+

+
    assert_eq!(
+
        commit
+
            .signatures()
+
            .map(|sig| sig.to_string())
+
            .collect::<Vec<_>>(),
+
        vec![SSH_SIGNATURE.to_owned(), PGP_SIGNATURE.to_owned()]
+
    );
+
    assert_eq!(
+
        commit.values("other").collect::<Vec<_>>(),
+
        vec![String::from("e6fe3c97619deb8ab4198620f9a7eb79d98363dd")],
+
    );
+
    assert!(commit.values("unknown").next().is_none());
+
}
+

+
#[test]
+
fn test_conversion() {
+
    assert_eq!(Commit::from_str(SIGNED).unwrap().to_string(), SIGNED);
+
    assert_eq!(Commit::from_str(UNSIGNED).unwrap().to_string(), UNSIGNED);
+
}
added git-commit/t/src/integration.rs
@@ -0,0 +1,28 @@
+
use std::io;
+

+
use git_commit::Commit;
+
use test_helpers::tempdir::WithTmpDir;
+

+
#[test]
+
fn valid_commits() {
+
    let radicle_git = format!(
+
        "file://{}",
+
        git2::Repository::discover(".").unwrap().path().display()
+
    );
+
    let repo = WithTmpDir::new(|path| {
+
        let repo = git2::Repository::clone(&radicle_git, path)
+
            .map_err(|err| io::Error::new(io::ErrorKind::Other, err))?;
+
        Ok::<_, io::Error>(repo)
+
    })
+
    .unwrap();
+

+
    let mut walk = repo.revwalk().unwrap();
+
    walk.push_head().unwrap();
+

+
    // take the first 20 commits and make sure we can parse them
+
    for oid in walk.take(20) {
+
        let oid = oid.unwrap();
+
        let commit = Commit::read(&repo, oid);
+
        assert!(commit.is_ok(), "Oid: {}, Error: {:?}", oid, commit)
+
    }
+
}
added git-commit/t/src/lib.rs
@@ -0,0 +1,5 @@
+
#[cfg(test)]
+
mod commit;
+

+
#[cfg(test)]
+
mod integration;
modified test/Cargo.toml
@@ -11,6 +11,10 @@ doctest = false
test = true
doc = false

+
[dev-dependencies.git-commit-test]
+
path = "../git-commit/t"
+
features = ["test"]
+

[dev-dependencies.git-ext-test]
path = "../radicle-git-ext/t"
features = ["test"]