Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
git2-metadata: Cut out a tiny part of radicle-git
Lorenz Leutgeb committed 7 months ago
commit 12b06748ea7647974e45cd9f1695a5a9f1398866
parent fd91acb244547864968010e51957e07925687c63
15 files changed +955 -35
modified Cargo.lock
@@ -2951,7 +2951,8 @@ dependencies = [
name = "radicle-git2-metadata"
version = "0.1.0"
dependencies = [
-
 "radicle-git-ext",
+
 "git2",
+
 "thiserror 1.0.69",
]

[[package]]
modified crates/radicle-cob/Cargo.toml
@@ -18,6 +18,7 @@ default = []
# Only used for testing. Ensures that commit ids are stable.
stable-commit-ids = []
test = []
+
git2 = ["radicle-git2-metadata/git2", "dep:git2"]

[dependencies]
fastrand = { workspace = true }
modified crates/radicle-cob/src/backend/git.rs
@@ -1,5 +1,7 @@
// Copyright © 2022 The Radicle Team

+
mod commit;
+

pub mod change;

/// Environment variable to set to overwrite the commit date for both the author and the committer.
modified crates/radicle-cob/src/backend/git/change.rs
@@ -6,8 +6,8 @@ use std::path::PathBuf;
use std::sync::LazyLock;

use metadata::author::Author;
+
use metadata::commit::headers::Headers;
use metadata::commit::trailers::OwnedTrailer;
-
use metadata::commit::{headers::Headers, Commit};
use nonempty::NonEmpty;
use oid::Oid;

@@ -21,6 +21,8 @@ use crate::{
    trailers, Embed,
};

+
use super::commit::Commit;
+

/// Name of the COB manifest file.
pub const MANIFEST_BLOB_NAME: &str = "manifest";
/// Path under which COB embeds are kept.
@@ -30,7 +32,6 @@ pub mod error {
    use std::str::Utf8Error;
    use std::string::FromUtf8Error;

-
    use metadata::commit;
    use oid::Oid;
    use thiserror::Error;

@@ -39,7 +40,7 @@ pub mod error {
    #[derive(Debug, Error)]
    pub enum Create {
        #[error(transparent)]
-
        WriteCommit(#[from] commit::error::Write),
+
        WriteCommit(#[from] super::super::commit::error::Write),
        #[error(transparent)]
        FromUtf8(#[from] FromUtf8Error),
        #[error(transparent)]
@@ -55,7 +56,7 @@ pub mod error {
    #[derive(Debug, Error)]
    pub enum Load {
        #[error(transparent)]
-
        Read(#[from] commit::error::Read),
+
        Read(#[from] super::super::commit::error::Read),
        #[error(transparent)]
        Signatures(#[from] Signatures),
        #[error(transparent)]
@@ -166,8 +167,8 @@ impl change::Storage for git2::Repository {
    }

    fn load(&self, id: Self::ObjectId) -> Result<Entry, Self::LoadError> {
-
        let commit = Commit::read(self, id.into())?;
-
        let timestamp = git2::Time::from(commit.committer().time).seconds() as u64;
+
        let commit = super::commit::Commit::read(self, id.into())?;
+
        let timestamp = commit.committer().time.seconds() as u64;
        let trailers = parse_trailers(commit.trailers())?;
        let (resources, related): (Vec<_>, Vec<_>) = trailers.iter().partition(|t| match t {
            CommitTrailer::Resource(_) => true,
@@ -180,7 +181,7 @@ impl change::Storage for git2::Repository {
            .map(Oid::from)
            .filter(|p| !resources.contains(p) && !related.contains(p))
            .collect();
-
        let mut signatures = Signatures::try_from(&commit)?
+
        let mut signatures = Signatures::try_from(&*commit)?
            .into_iter()
            .collect::<Vec<_>>();
        let Some((key, sig)) = signatures.pop() else {
added crates/radicle-cob/src/backend/git/commit.rs
@@ -0,0 +1,180 @@
+
mod trailers;
+

+
use std::fmt;
+
use std::str::{self, FromStr};
+

+
use git2::{ObjectType, Oid};
+

+
use metadata::author::Author;
+
use metadata::commit::headers::Headers;
+
use metadata::commit::trailers::OwnedTrailer;
+
use metadata::commit::CommitData;
+

+
use trailers::Trailers;
+

+
#[repr(transparent)]
+
pub(super) struct Commit(metadata::commit::CommitData<Oid, Oid>);
+

+
impl Commit {
+
    pub fn new<P, I, T>(
+
        tree: git2::Oid,
+
        parents: P,
+
        author: Author,
+
        committer: Author,
+
        headers: Headers,
+
        message: String,
+
        trailers: I,
+
    ) -> Self
+
    where
+
        P: IntoIterator<Item = Oid>,
+
        I: IntoIterator<Item = T>,
+
        OwnedTrailer: From<T>,
+
    {
+
        Self(CommitData::new(
+
            tree, parents, author, committer, headers, message, trailers,
+
        ))
+
    }
+
}
+

+
impl Commit {
+
    /// 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, error::Write> {
+
        let odb = repo.odb().map_err(error::Write::Odb)?;
+
        self.verify_for_write(&odb)?;
+
        Ok(odb.write(ObjectType::Commit, self.to_string().as_bytes())?)
+
    }
+

+
    fn verify_for_write(&self, odb: &git2::Odb) -> Result<(), error::Write> {
+
        for parent in self.0.parents() {
+
            verify_object(odb, &parent, ObjectType::Commit)?;
+
        }
+
        verify_object(odb, self.0.tree(), ObjectType::Tree)?;
+

+
        Ok(())
+
    }
+
}
+

+
fn verify_object(odb: &git2::Odb, oid: &Oid, expected: ObjectType) -> Result<(), error::Write> {
+
    use git2::{Error, ErrorClass, ErrorCode};
+

+
    let (_, kind) = odb
+
        .read_header(*oid)
+
        .map_err(|err| error::Write::OdbRead { oid: *oid, err })?;
+
    if kind != expected {
+
        Err(error::Write::NotCommit {
+
            oid: *oid,
+
            err: Error::new(
+
                ErrorCode::NotFound,
+
                ErrorClass::Object,
+
                format!("Object '{oid}' is not expected object type {expected}"),
+
            ),
+
        })
+
    } else {
+
        Ok(())
+
    }
+
}
+

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

+
    use thiserror::Error;
+

+
    #[derive(Debug, Error)]
+
    pub enum Write {
+
        #[error(transparent)]
+
        Git(#[from] git2::Error),
+
        #[error("the parent '{oid}' provided is not a commit object")]
+
        NotCommit {
+
            oid: git2::Oid,
+
            #[source]
+
            err: git2::Error,
+
        },
+
        #[error("failed to access git odb")]
+
        Odb(#[source] git2::Error),
+
        #[error("failed to read '{oid}' from git odb")]
+
        OdbRead {
+
            oid: git2::Oid,
+
            #[source]
+
            err: git2::Error,
+
        },
+
    }
+

+
    #[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)]
+
        Git(#[from] git2::Error),
+
        #[error(transparent)]
+
        Header(#[from] metadata::commit::headers::ParseError),
+
        #[error(transparent)]
+
        Utf8(#[from] str::Utf8Error),
+
    }
+
}
+

+
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(metadata::commit::headers::ParseError::InvalidFormat)?;
+

+
        let (tree, parents, author, committer, headers) =
+
            metadata::commit::headers::parse_commit_header(header)?;
+

+
        let trailers = Trailers::parse(message)?;
+

+
        let message = message
+
            .strip_suffix(&trailers.to_string(": "))
+
            .unwrap_or(message)
+
            .to_string();
+

+
        Ok(Self(CommitData::new(
+
            tree,
+
            parents,
+
            author,
+
            committer,
+
            headers,
+
            message,
+
            trailers.iter(),
+
        )))
+
    }
+
}
+

+
impl fmt::Display for Commit {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        self.0.fmt(f)
+
    }
+
}
+

+
impl std::ops::Deref for Commit {
+
    type Target = CommitData<git2::Oid, git2::Oid>;
+

+
    fn deref(&self) -> &Self::Target {
+
        &self.0
+
    }
+
}
added crates/radicle-cob/src/backend/git/commit/trailers.rs
@@ -0,0 +1,110 @@
+
use std::{borrow::Cow, fmt, fmt::Write, str::FromStr};
+

+
use git2::{MessageTrailersStrs, MessageTrailersStrsIterator};
+

+
use metadata::commit::trailers::Separator;
+

+
/// A Git commit's set of trailers that are left in the commit's
+
/// message.
+
///
+
/// Trailers are key/value pairs in the last paragraph of a message,
+
/// not including any patches or conflicts that may be present.
+
///
+
/// # Usage
+
///
+
/// To construct `Trailers`, you can use [`Trailers::parse`] or its
+
/// `FromStr` implementation.
+
///
+
/// To iterate over the trailers, you can use [`Trailers::iter`].
+
///
+
/// To render the trailers to a `String`, you can use
+
/// [`Trailers::to_string`] or its `Display` implementation (note that
+
/// it will default to using `": "` as the separator.
+
///
+
/// # Examples
+
///
+
/// ```text
+
/// Add new functionality
+
///
+
/// Making code better with new functionality.
+
///
+
/// X-Signed-Off-By: Alex Sellier
+
/// X-Co-Authored-By: Fintan Halpenny
+
/// ```
+
///
+
/// The trailers in the above example are:
+
///
+
/// ```text
+
/// X-Signed-Off-By: Alex Sellier
+
/// X-Co-Authored-By: Fintan Halpenny
+
/// ```
+
pub struct Trailers {
+
    inner: MessageTrailersStrs,
+
}
+

+
impl Trailers {
+
    pub fn parse(message: &str) -> Result<Self, git2::Error> {
+
        Ok(Self {
+
            inner: git2::message_trailers_strs(message)?,
+
        })
+
    }
+

+
    pub fn iter(&self) -> Iter<'_> {
+
        Iter {
+
            inner: self.inner.iter(),
+
        }
+
    }
+

+
    pub fn to_string<'a, S>(&self, sep: S) -> String
+
    where
+
        S: Separator<'a>,
+
    {
+
        let mut buf = String::new();
+
        for (i, trailer) in self.iter().enumerate() {
+
            if i > 0 {
+
                writeln!(buf).ok();
+
            }
+

+
            write!(buf, "{}", trailer.display(sep.sep_for(&trailer.token))).ok();
+
        }
+
        writeln!(buf).ok();
+
        buf
+
    }
+
}
+

+
impl fmt::Display for Trailers {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        f.write_str(&self.to_string(": "))
+
    }
+
}
+

+
impl FromStr for Trailers {
+
    type Err = git2::Error;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        Self::parse(s)
+
    }
+
}
+

+
pub struct Iter<'a> {
+
    inner: MessageTrailersStrsIterator<'a>,
+
}
+

+
impl<'a> Iterator for Iter<'a> {
+
    type Item = metadata::commit::trailers::Trailer<'a>;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        let (token, value) = self.inner.next()?;
+
        Some(metadata::commit::trailers::Trailer {
+
            token: {
+
                // This code used to live in the same module with `Token`,
+
                // but was separated because it depends on `git2`.
+
                // We have no way of directly constructing a `Token`, anymore
+
                // but `git2` still guarantees that the trailer is well-formed.
+
                metadata::commit::trailers::Token::try_from(token)
+
                    .expect("token from `git2` must be valid")
+
            },
+
            value: Cow::Borrowed(value),
+
        })
+
    }
+
}
modified crates/radicle-cob/src/signatures.rs
@@ -10,7 +10,7 @@ use std::{
use crypto::{ssh, PublicKey};
use metadata::commit::{
    headers::Signature::{Pgp, Ssh},
-
    Commit,
+
    CommitData,
};

pub use ssh::ExtendedSignature;
@@ -55,10 +55,10 @@ impl From<Signatures> for BTreeMap<PublicKey, crypto::Signature> {
    }
}

-
impl TryFrom<&Commit> for Signatures {
+
impl<Tree, Parent> TryFrom<&CommitData<Tree, Parent>> for Signatures {
    type Error = error::Signatures;

-
    fn try_from(value: &Commit) -> Result<Self, Self::Error> {
+
    fn try_from(value: &CommitData<Tree, Parent>) -> Result<Self, Self::Error> {
        value
            .signatures()
            .filter_map(|signature| {
modified crates/radicle-git2-metadata/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "radicle-git2-metadata"
-
description = "Radicle re-exports for metadata structs from `radicle-git-ext`"
+
description = "Radicle structs that carry Git commit metadata"
homepage.workspace = true
repository.workspace = true
version = "0.1.0"
@@ -9,8 +9,6 @@ license.workspace = true
keywords = ["radicle", "git", "metadata"]
rust-version.workspace = true

-
[features]
-
serde = ["radicle-git-ext/serde"]
-

[dependencies]
-
radicle-git-ext = { workspace = true }

\ No newline at end of file
+
thiserror = { workspace = true }
+
git2 = { workspace = true, optional = true, default-features = false }

\ No newline at end of file
added crates/radicle-git2-metadata/src/author.rs
@@ -0,0 +1,167 @@
+
use std::{
+
    fmt,
+
    num::ParseIntError,
+
    str::{self, FromStr},
+
};
+

+
use thiserror::Error;
+

+
/// The data for indicating authorship of an action within
+
/// [`crate::commit::CommitData`].
+
#[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 }
+
    }
+

+
    /// Return the time, in seconds, since the epoch.
+
    pub fn seconds(&self) -> i64 {
+
        self.seconds
+
    }
+

+
    /// Return the timezone offset, in minutes.
+
    pub fn offset(&self) -> i32 {
+
        self.offset
+
    }
+

+
    fn from_components<'a>(cs: &mut impl Iterator<Item = &'a str>) -> Result<Self, ParseError> {
+
        let offset = match cs.next() {
+
            None => Err(ParseError::Missing("offset")),
+
            Some(offset) => Self::parse_offset(offset).map_err(ParseError::Offset),
+
        }?;
+
        let time = match cs.next() {
+
            None => return Err(ParseError::Missing("time")),
+
            Some(time) => time.parse::<i64>().map_err(ParseError::Time)?,
+
        };
+
        Ok(Self::new(time, offset))
+
    }
+

+
    fn parse_offset(offset: &str) -> Result<i32, ParseIntError> {
+
        // The offset is in the form of timezone offset,
+
        // e.g. +0200, -0100.  This needs to be converted into
+
        // minutes. The first two digits in the offset are the
+
        // number of hours in the offset, while the latter two
+
        // digits are the number of minutes in the offset.
+
        let tz_offset = offset.parse::<i32>()?;
+
        let hours = tz_offset / 100;
+
        let minutes = tz_offset % 100;
+
        Ok(hours * 60 + minutes)
+
    }
+
}
+

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

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        Self::from_components(&mut s.split(' ').rev())
+
    }
+
}
+

+
/*
+
#[cfg(feature = "git2")]
+
impl From<Time> for git2::Time {
+
    fn from(t: Time) -> Self {
+
        Self::new(t.seconds, t.offset)
+
    }
+
}
+

+
#[cfg(feature = "git2")]
+
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 { '+' };
+
        let hours = self.offset.abs() / 60;
+
        let minutes = self.offset.abs() % 60;
+
        write!(f, "{} {}{:0>2}{:0>2}", self.seconds, sign, hours, minutes)
+
    }
+
}
+

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

+
#[cfg(feature = "git2")]
+
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)
+
    }
+
}
+

+
#[cfg(feature = "git2")]
+
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: {
+
                let when = value.when();
+
                Time::new(when.seconds(), when.offset_minutes())
+
            },
+
        })
+
    }
+
}
+

+
#[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),
+
}
+

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

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        // Splitting the string in 4 subcomponents is expected to give back the
+
        // following iterator entries: timezone offset, time, email, and name
+
        let mut components = s.rsplitn(4, ' ');
+
        let time = Time::from_components(&mut components)?;
+
        let email = components
+
            .next()
+
            .ok_or(ParseError::Missing("email"))?
+
            .trim_matches(|c| c == '<' || c == '>')
+
            .to_owned();
+
        let name = components.next().ok_or(ParseError::Missing("name"))?;
+
        Ok(Self {
+
            name: name.to_owned(),
+
            email: email.to_owned(),
+
            time,
+
        })
+
    }
+
}
added crates/radicle-git2-metadata/src/commit.rs
@@ -0,0 +1,179 @@
+
pub mod headers;
+
pub mod trailers;
+

+
use core::fmt;
+
use std::str;
+

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

+
use crate::author::Author;
+

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

+
impl<Tree, Parent> CommitData<Tree, Parent> {
+
    pub fn new<P, I, T>(
+
        tree: Tree,
+
        parents: P,
+
        author: Author,
+
        committer: Author,
+
        headers: Headers,
+
        message: String,
+
        trailers: I,
+
    ) -> Self
+
    where
+
        P: IntoIterator<Item = Parent>,
+
        I: IntoIterator<Item = T>,
+
        OwnedTrailer: From<T>,
+
    {
+
        let trailers = trailers.into_iter().map(OwnedTrailer::from).collect();
+
        let parents = parents.into_iter().collect();
+
        Self {
+
            tree,
+
            parents,
+
            author,
+
            committer,
+
            headers,
+
            message,
+
            trailers,
+
        }
+
    }
+

+
    /// The tree this commit points to.
+
    pub fn tree(&self) -> &Tree {
+
        &self.tree
+
    }
+

+
    /// The parents of this commit.
+
    pub fn parents(&self) -> impl Iterator<Item = Parent> + '_
+
    where
+
        Parent: Clone,
+
    {
+
        self.parents.iter().cloned()
+
    }
+

+
    /// 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> + 'a {
+
        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()
+
    }
+

+
    /// Convert the `CommitData::tree` into a value of type `U`. The
+
    /// conversion function `f` can be fallible.
+
    ///
+
    /// For example, `map_tree` can be used to turn raw tree data into
+
    /// an `Oid` by writing it to a repository.
+
    pub fn map_tree<U, E, F>(self, f: F) -> Result<CommitData<U, Parent>, E>
+
    where
+
        F: FnOnce(Tree) -> Result<U, E>,
+
    {
+
        Ok(CommitData {
+
            tree: f(self.tree)?,
+
            parents: self.parents,
+
            author: self.author,
+
            committer: self.committer,
+
            headers: self.headers,
+
            message: self.message,
+
            trailers: self.trailers,
+
        })
+
    }
+

+
    /// Convert the `CommitData::parents` into a vector containing
+
    /// values of type `U`. The conversion function `f` can be
+
    /// fallible.
+
    ///
+
    /// For example, `map_parents` can be used to resolve the `Oid`s
+
    /// to their respective `git2::Commit`s.
+
    pub fn map_parents<U, E, F>(self, f: F) -> Result<CommitData<Tree, U>, E>
+
    where
+
        F: FnMut(Parent) -> Result<U, E>,
+
    {
+
        Ok(CommitData {
+
            tree: self.tree,
+
            parents: self
+
                .parents
+
                .into_iter()
+
                .map(f)
+
                .collect::<Result<Vec<_>, _>>()?,
+
            author: self.author,
+
            committer: self.committer,
+
            headers: self.headers,
+
            message: self.message,
+
            trailers: self.trailers,
+
        })
+
    }
+
}
+

+
impl<Tree: fmt::Display, Parent: fmt::Display> fmt::Display for CommitData<Tree, Parent> {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        writeln!(f, "tree {}", self.tree)?;
+
        for parent in self.parents.iter() {
+
            writeln!(f, "parent {parent}")?;
+
        }
+
        writeln!(f, "author {}", self.author)?;
+
        writeln!(f, "committer {}", self.committer)?;
+

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

+
        if !self.trailers.is_empty() {
+
            writeln!(f)?;
+
        }
+
        for trailer in self.trailers.iter() {
+
            writeln!(f, "{}", Trailer::from(trailer).display(": "))?;
+
        }
+
        Ok(())
+
    }
+
}
added crates/radicle-git2-metadata/src/commit/headers.rs
@@ -0,0 +1,168 @@
+
use core::fmt;
+
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 [`crate::commit::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 [`crate::commit::Commit`].
+
#[derive(Debug)]
+
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)
+
        }
+
    }
+
}
+

+
impl fmt::Display for Signature<'_> {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            Signature::Pgp(pgp) => f.write_str(pgp.as_ref()),
+
            Signature::Ssh(ssh) => f.write_str(ssh.as_ref()),
+
        }
+
    }
+
}
+

+
pub struct UnknownScheme;
+

+
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> + 'a {
+
        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()));
+
    }
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
pub enum ParseError {
+
    #[error("missing tree")]
+
    MissingTree,
+
    #[error("invalid tree")]
+
    InvalidTree,
+
    #[error("invalid format")]
+
    InvalidFormat,
+
    #[error("invalid parent")]
+
    InvalidParent,
+
    #[error("invalid header")]
+
    InvalidHeader,
+
    #[error("invalid author")]
+
    InvalidAuthor,
+
    #[error("missing author")]
+
    MissingAuthor,
+
    #[error("invalid committer")]
+
    InvalidCommitter,
+
    #[error("missing committer")]
+
    MissingCommitter,
+
}
+

+
pub fn parse_commit_header<
+
    Tree: std::str::FromStr,
+
    Parent: std::str::FromStr,
+
    Signature: std::str::FromStr,
+
>(
+
    header: &str,
+
) -> Result<(Tree, Vec<Parent>, Signature, Signature, Headers), ParseError> {
+
    let mut lines = header.lines();
+

+
    let tree = match lines.next() {
+
        Some(tree) => tree
+
            .strip_prefix("tree ")
+
            .map(Tree::from_str)
+
            .transpose()
+
            .map_err(|_| ParseError::InvalidTree)?
+
            .ok_or(ParseError::MissingTree)?,
+
        None => return Err(ParseError::MissingTree),
+
    };
+

+
    let mut parents = Vec::new();
+
    let mut author: Option<Signature> = None;
+
    let mut committer: Option<Signature> = 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(ParseError::InvalidFormat)?;
+
            value.push('\n');
+
            value.push_str(rest);
+
            continue;
+
        }
+

+
        if let Some((name, value)) = line.split_once(' ') {
+
            match name {
+
                "parent" => parents.push(
+
                    value
+
                        .parse::<Parent>()
+
                        .map_err(|_| ParseError::InvalidParent)?,
+
                ),
+
                "author" => {
+
                    author = Some(
+
                        value
+
                            .parse::<Signature>()
+
                            .map_err(|_| ParseError::InvalidAuthor)?,
+
                    )
+
                }
+
                "committer" => {
+
                    committer = Some(
+
                        value
+
                            .parse::<Signature>()
+
                            .map_err(|_| ParseError::InvalidCommitter)?,
+
                    )
+
                }
+
                _ => headers.push(name, value),
+
            }
+
            continue;
+
        }
+
    }
+

+
    Ok((
+
        tree,
+
        parents,
+
        author.ok_or(ParseError::MissingAuthor)?,
+
        committer.ok_or(ParseError::MissingCommitter)?,
+
        headers,
+
    ))
+
}
added crates/radicle-git2-metadata/src/commit/trailers.rs
@@ -0,0 +1,127 @@
+
use std::{borrow::Cow, fmt, ops::Deref};
+

+
pub trait Separator<'a> {
+
    fn sep_for(&self, token: &Token) -> &'a str;
+
}
+

+
impl<'a> Separator<'a> for &'a str {
+
    fn sep_for(&self, _: &Token) -> &'a str {
+
        self
+
    }
+
}
+

+
impl<'a, F> Separator<'a> for F
+
where
+
    F: Fn(&Token) -> &'a str,
+
{
+
    fn sep_for(&self, token: &Token) -> &'a str {
+
        self(token)
+
    }
+
}
+

+
#[derive(Debug, Clone, Eq, PartialEq)]
+
pub struct Token<'a>(&'a str);
+

+
impl Deref for Token<'_> {
+
    type Target = str;
+

+
    fn deref(&self) -> &Self::Target {
+
        self.0
+
    }
+
}
+

+
impl<'a> TryFrom<&'a str> for Token<'a> {
+
    type Error = &'static str;
+

+
    fn try_from(s: &'a str) -> Result<Self, Self::Error> {
+
        let is_token = s.chars().all(|c| c.is_alphanumeric() || c == '-');
+
        if is_token {
+
            Ok(Token(s))
+
        } else {
+
            Err("token contains invalid characters")
+
        }
+
    }
+
}
+

+
pub struct Display<'a> {
+
    trailer: &'a Trailer<'a>,
+
    separator: &'a str,
+
}
+

+
impl fmt::Display for Display<'_> {
+
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+
        write!(
+
            f,
+
            "{}{}{}",
+
            self.trailer.token.deref(),
+
            self.separator,
+
            self.trailer.value,
+
        )
+
    }
+
}
+

+
/// A trailer is a key/value pair found in the last paragraph of a Git
+
/// commit message, not including any patches or conflicts that may be
+
/// present.
+
#[derive(Debug, Clone, Eq, PartialEq)]
+
pub struct Trailer<'a> {
+
    pub token: Token<'a>,
+
    pub value: Cow<'a, str>,
+
}
+

+
impl<'a> Trailer<'a> {
+
    pub fn display(&'a self, separator: &'a str) -> Display<'a> {
+
        Display {
+
            trailer: self,
+
            separator,
+
        }
+
    }
+

+
    pub fn to_owned(&self) -> OwnedTrailer {
+
        OwnedTrailer::from(self)
+
    }
+
}
+

+
/// A version of the [`Trailer`] which owns its token and
+
/// value. Useful for when you need to carry trailers around in a long
+
/// lived data structure.
+
#[derive(Debug)]
+
pub struct OwnedTrailer {
+
    pub token: OwnedToken,
+
    pub value: String,
+
}
+

+
#[derive(Debug)]
+
pub struct OwnedToken(String);
+

+
impl Deref for OwnedToken {
+
    type Target = str;
+

+
    fn deref(&self) -> &Self::Target {
+
        &self.0
+
    }
+
}
+

+
impl<'a> From<&Trailer<'a>> for OwnedTrailer {
+
    fn from(t: &Trailer<'a>) -> Self {
+
        OwnedTrailer {
+
            token: OwnedToken(t.token.0.to_string()),
+
            value: t.value.to_string(),
+
        }
+
    }
+
}
+

+
impl<'a> From<Trailer<'a>> for OwnedTrailer {
+
    fn from(t: Trailer<'a>) -> Self {
+
        (&t).into()
+
    }
+
}
+

+
impl<'a> From<&'a OwnedTrailer> for Trailer<'a> {
+
    fn from(t: &'a OwnedTrailer) -> Self {
+
        Trailer {
+
            token: Token(t.token.0.as_str()),
+
            value: Cow::from(&t.value),
+
        }
+
    }
+
}
modified crates/radicle-git2-metadata/src/lib.rs
@@ -1,16 +1,2 @@
-
pub mod author {
-
    pub use radicle_git_ext::author::{Author, Time};
-
}
-

-
pub mod commit {
-
    pub use radicle_git_ext::commit::Commit;
-
    pub mod headers {
-
        pub use radicle_git_ext::commit::headers::{Headers, Signature};
-
    }
-
    pub mod trailers {
-
        pub use radicle_git_ext::commit::trailers::{OwnedTrailer, Token, Trailer};
-
    }
-
    pub mod error {
-
        pub use radicle_git_ext::commit::error::{Read, Write};
-
    }
-
}
+
pub mod author;
+
pub mod commit;
modified crates/radicle/Cargo.toml
@@ -63,4 +63,4 @@ qcheck = { workspace = true }
qcheck-macros = { workspace = true }
radicle-cob = { workspace = true, features = ["stable-commit-ids", "test"] }
radicle-crypto = { workspace = true, features = ["test"] }
-
radicle-git2-metadata = { workspace = true, features = ["serde"] }

\ No newline at end of file
+
radicle-git2-metadata = { workspace = true }

\ No newline at end of file
modified crates/radicle/src/cob/test.rs
@@ -14,7 +14,7 @@ use crate::cob::{Entry, History, Manifest, Timestamp, Version};
use crate::crypto::Signer;
use crate::git2::ext::author::Author;
use crate::git2::ext::commit::headers::Headers;
-
use crate::git2::ext::commit::{trailers::OwnedTrailer, Commit};
+
use crate::git2::ext::commit::{trailers::OwnedTrailer, CommitData};
use crate::node::device::Device;
use crate::prelude::Did;
use crate::profile::env;
@@ -265,7 +265,7 @@ pub fn encoded<T: Cob, G: Signer>(
        email: signer.public_key().to_human(),
        time: crate::git2::ext::author::Time::new(timestamp.as_secs() as i64, 0),
    };
-
    let commit = Commit::new::<_, _, OwnedTrailer>(
+
    let commit = CommitData::<git2::Oid, git2::Oid>::new::<_, _, OwnedTrailer>(
        oid,
        parents,
        author.clone(),