git-metadata: Add parsing of `CommitData`
This change adds parsing of `CommitData` from raw bytes, i.e. `&[u8]`. The intended use is to allow the `radicle-*` crates to be able to parse raw commit data from any underlying Git implementation, such as the `git2` and `gix` crates. The tests are broken down into `success` cases, `error` cases, and `unit` tests.
This commit is contained in:
parent
58624148d4
commit
01c60388db
|
|
@ -1,8 +1,11 @@
|
|||
pub mod headers;
|
||||
pub mod trailers;
|
||||
|
||||
mod parse;
|
||||
pub use parse::ParseError;
|
||||
|
||||
use core::fmt;
|
||||
use std::str;
|
||||
use std::str::{self, FromStr};
|
||||
|
||||
use headers::{Headers, Signature};
|
||||
use trailers::{OwnedTrailer, Trailer};
|
||||
|
|
@ -157,6 +160,44 @@ impl<Tree, Parent> CommitData<Tree, Parent> {
|
|||
}
|
||||
}
|
||||
|
||||
impl<Tree, Parent> CommitData<Tree, Parent>
|
||||
where
|
||||
Tree: str::FromStr,
|
||||
Parent: str::FromStr,
|
||||
Tree::Err: std::error::Error + Send + Sync + 'static,
|
||||
Parent::Err: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
/// Parse a [`CommitData`] from its raw git object bytes.
|
||||
///
|
||||
/// This is the inverse of the [`fmt::Display`] implementation. The bytes
|
||||
/// are expected to be valid UTF-8 and in the standard git commit object
|
||||
/// format produced by `git cat-file -p <commit>`.
|
||||
///
|
||||
/// Trailers are detected by scanning the last paragraph of the message
|
||||
/// body (the section after the final blank line). If every non-empty line
|
||||
/// in that paragraph is a valid `Token: value` pair, those lines are
|
||||
/// parsed as trailers and stored separately; otherwise the whole body is
|
||||
/// kept as the message with no trailers.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, ParseError> {
|
||||
let s = str::from_utf8(bytes).map_err(ParseError::Utf8)?;
|
||||
parse::parse(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Tree, Parent> FromStr for CommitData<Tree, Parent>
|
||||
where
|
||||
Tree: str::FromStr,
|
||||
Parent: str::FromStr,
|
||||
Tree::Err: std::error::Error + Send + Sync + 'static,
|
||||
Parent::Err: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
type Err = ParseError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
parse::parse(s)
|
||||
}
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
use super::Headers;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ParseError {
|
||||
#[error("invalid utf-8")]
|
||||
Utf8(#[source] std::str::Utf8Error),
|
||||
#[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,
|
||||
))
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
#[cfg(test)]
|
||||
mod test;
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::author::Author;
|
||||
|
||||
use super::{
|
||||
headers::Headers,
|
||||
trailers::{OwnedTrailer, Token, Trailer},
|
||||
CommitData,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ParseError {
|
||||
#[error("the provided commit data contained invalid UTF-8")]
|
||||
Utf8(#[source] std::str::Utf8Error),
|
||||
#[error("the commit header is missing the 'tree' entry")]
|
||||
MissingTree,
|
||||
#[error("failed to parse 'tree' value: {0}")]
|
||||
InvalidTree(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
|
||||
#[error("invalid format: {reason}")]
|
||||
InvalidFormat { reason: &'static str },
|
||||
#[error("failed to parse 'parent' value: {0}")]
|
||||
InvalidParent(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
|
||||
#[error("invalid header")]
|
||||
InvalidHeader,
|
||||
#[error("failed to parse 'author' value: {0}")]
|
||||
InvalidAuthor(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
|
||||
#[error("the commit header is missing the 'author' entry")]
|
||||
MissingAuthor,
|
||||
#[error("failed to parse 'committer' value: {0}")]
|
||||
InvalidCommitter(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
|
||||
#[error("the commit header is missing the 'committer' entry")]
|
||||
MissingCommitter,
|
||||
}
|
||||
|
||||
pub(super) fn parse<Tree: std::str::FromStr, Parent: std::str::FromStr>(
|
||||
commit: &str,
|
||||
) -> Result<CommitData<Tree, Parent>, ParseError>
|
||||
where
|
||||
Tree::Err: std::error::Error + Send + Sync + 'static,
|
||||
Parent::Err: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
// The header and body are separated by the first blank line.
|
||||
let (header, body) = commit.split_once("\n\n").ok_or(ParseError::InvalidFormat {
|
||||
reason: "commit headers and body must be separated by a blank line",
|
||||
})?;
|
||||
|
||||
let (tree, parents, author, committer, headers) =
|
||||
parse_headers::<Tree, Parent, Author>(header)?;
|
||||
|
||||
let (message, trailers) = parse_body(body);
|
||||
|
||||
Ok(CommitData {
|
||||
tree,
|
||||
parents,
|
||||
author,
|
||||
committer,
|
||||
headers,
|
||||
message,
|
||||
trailers,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_headers<Tree: std::str::FromStr, Parent: std::str::FromStr, Signature: std::str::FromStr>(
|
||||
header: &str,
|
||||
) -> Result<(Tree, Vec<Parent>, Signature, Signature, Headers), ParseError>
|
||||
where
|
||||
Tree::Err: std::error::Error + Send + Sync + 'static,
|
||||
Parent::Err: std::error::Error + Send + Sync + 'static,
|
||||
Signature::Err: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
let mut lines = header.lines();
|
||||
|
||||
let tree = lines
|
||||
.next()
|
||||
.ok_or(ParseError::MissingTree)?
|
||||
.strip_prefix("tree ")
|
||||
.map(Tree::from_str)
|
||||
.transpose()
|
||||
.map_err(|err| ParseError::InvalidTree(Box::new(err)))?
|
||||
.ok_or(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 {
|
||||
reason: "failed to parse extra header",
|
||||
})?;
|
||||
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(|err| ParseError::InvalidParent(Box::new(err)))?,
|
||||
),
|
||||
"author" => {
|
||||
author = Some(
|
||||
value
|
||||
.parse::<Signature>()
|
||||
.map_err(|err| ParseError::InvalidAuthor(Box::new(err)))?,
|
||||
)
|
||||
}
|
||||
"committer" => {
|
||||
committer = Some(
|
||||
value
|
||||
.parse::<Signature>()
|
||||
.map_err(|err| ParseError::InvalidCommitter(Box::new(err)))?,
|
||||
)
|
||||
}
|
||||
_ => headers.push(name, value),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((
|
||||
tree,
|
||||
parents,
|
||||
author.ok_or(ParseError::MissingAuthor)?,
|
||||
committer.ok_or(ParseError::MissingCommitter)?,
|
||||
headers,
|
||||
))
|
||||
}
|
||||
|
||||
/// Split the commit body (the portion after the first `\n\n` in the object)
|
||||
/// into a message string and a list of trailers.
|
||||
///
|
||||
/// Trailers are only separated out when the last paragraph of the body
|
||||
/// consists entirely of valid `Token: value` lines. If parsing the last
|
||||
/// paragraph as trailers fails for any line, the whole body is returned as
|
||||
/// the message with an empty trailer list.
|
||||
fn parse_body(body: &str) -> (String, Vec<OwnedTrailer>) {
|
||||
// Strip the single trailing newline that Display always writes after the
|
||||
// message, so that rfind("\n\n") reliably finds the trailer separator
|
||||
// rather than a spurious match at the very end.
|
||||
let body = body.trim_end_matches('\n');
|
||||
|
||||
if let Some(split) = body.rfind("\n\n") {
|
||||
let candidate = &body[split + 2..];
|
||||
// Only treat non-empty paragraphs as trailers.
|
||||
if !candidate.trim().is_empty() {
|
||||
if let Some(trailers) = try_parse_trailers(candidate) {
|
||||
return (body[..split].to_string(), trailers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(body.to_string(), Vec::new())
|
||||
}
|
||||
|
||||
/// Attempt to parse every non-empty line in `s` as a `Token: value` trailer.
|
||||
///
|
||||
/// Returns `None` if any line is not a valid trailer, so that the caller can
|
||||
/// fall back to treating the whole paragraph as part of the message.
|
||||
fn try_parse_trailers(s: &str) -> Option<Vec<OwnedTrailer>> {
|
||||
s.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|line| {
|
||||
let (token_str, value) = line.split_once(": ")?;
|
||||
let token = Token::try_from(token_str).ok()?;
|
||||
// Round-trip through Trailer so that OwnedToken construction
|
||||
// stays inside the trailers module where the private field lives.
|
||||
Some(
|
||||
Trailer {
|
||||
token,
|
||||
value: Cow::Borrowed(value),
|
||||
}
|
||||
.to_owned(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
mod error;
|
||||
mod success;
|
||||
mod unit;
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
use crate::commit::parse::{parse, ParseError};
|
||||
|
||||
/// Helper type whose FromStr always fails.
|
||||
///
|
||||
/// Used to exercise ParseError::InvalidTree and ParseError::InvalidParent
|
||||
/// without relying on a specific OID type; String::from_str is infallible so
|
||||
/// it cannot trigger those variants on its own.
|
||||
#[derive(Debug)]
|
||||
struct AlwaysFails;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AlwaysFailsErr;
|
||||
|
||||
impl std::fmt::Display for AlwaysFailsErr {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "always fails to parse")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AlwaysFailsErr {}
|
||||
|
||||
impl std::str::FromStr for AlwaysFails {
|
||||
type Err = AlwaysFailsErr;
|
||||
|
||||
fn from_str(_s: &str) -> Result<Self, Self::Err> {
|
||||
Err(AlwaysFailsErr)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_header_body_separator() {
|
||||
// No blank line separating the headers from the body at all.
|
||||
let raw = "tree abc123\nauthor Alice Liddell <alice@example.com> 1700000000 +0000\ncommitter Alice Liddell <alice@example.com> 1700000000 +0000\nno blank line follows";
|
||||
|
||||
let err = parse::<String, String>(raw).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ParseError::InvalidFormat { .. }),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_tree_empty_header() {
|
||||
// Header section is empty (just "\n\n").
|
||||
let raw = "\n\nMessage";
|
||||
|
||||
let err = parse::<String, String>(raw).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ParseError::MissingTree),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_tree_wrong_first_line() {
|
||||
// Header section exists but does not open with "tree <oid>".
|
||||
let raw = "author Alice Liddell <alice@example.com> 1700000000 +0000\ncommitter Alice Liddell <alice@example.com> 1700000000 +0000\n\nMessage";
|
||||
|
||||
let err = parse::<String, String>(raw).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ParseError::MissingTree),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_tree() {
|
||||
// Tree value present but cannot be parsed into the target Tree type.
|
||||
let raw = "tree abc123\nauthor Alice Liddell <alice@example.com> 1700000000 +0000\ncommitter Alice Liddell <alice@example.com> 1700000000 +0000\n\nMessage";
|
||||
|
||||
let err = parse::<AlwaysFails, String>(raw).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ParseError::InvalidTree(_)),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_parent() {
|
||||
// Parent value present but cannot be parsed into the target Parent type.
|
||||
let raw = "tree abc123\nparent bad-oid\nauthor Alice Liddell <alice@example.com> 1700000000 +0000\ncommitter Alice Liddell <alice@example.com> 1700000000 +0000\n\nMessage";
|
||||
|
||||
let err = parse::<String, AlwaysFails>(raw).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ParseError::InvalidParent(_)),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_format_continuation_without_preceding_header() {
|
||||
// A continuation line (leading space) appearing before any extra header
|
||||
// has been pushed to the headers vec triggers InvalidFormat, because
|
||||
// there is no preceding header value to fold it into.
|
||||
//
|
||||
// Note: author and committer do not push to the headers vec, so a
|
||||
// continuation line immediately after them (with an otherwise empty
|
||||
// headers vec) exercises this branch.
|
||||
let raw = "tree abc123\nauthor Alice Liddell <alice@example.com> 1700000000 +0000\ncommitter Alice Liddell <alice@example.com> 1700000000 +0000\n spurious continuation\n\nMessage";
|
||||
|
||||
let err = parse::<String, String>(raw).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ParseError::InvalidFormat { .. }),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_author() {
|
||||
let raw =
|
||||
"tree abc123\ncommitter Alice Liddell <alice@example.com> 1700000000 +0000\n\nMessage";
|
||||
|
||||
let err = parse::<String, String>(raw).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ParseError::MissingAuthor),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_author() {
|
||||
// Author line is present but its value is not a valid Author string
|
||||
// (no email bracket, no timestamp, no timezone offset).
|
||||
let raw = "tree abc123\nauthor not-a-valid-author\ncommitter Alice Liddell <alice@example.com> 1700000000 +0000\n\nMessage";
|
||||
|
||||
let err = parse::<String, String>(raw).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ParseError::InvalidAuthor(_)),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_committer() {
|
||||
let raw = "tree abc123\nauthor Alice Liddell <alice@example.com> 1700000000 +0000\n\nMessage";
|
||||
|
||||
let err = parse::<String, String>(raw).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ParseError::MissingCommitter),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_committer() {
|
||||
// Committer line is present but its value is not a valid Author string.
|
||||
let raw = "tree abc123\nauthor Alice Liddell <alice@example.com> 1700000000 +0000\ncommitter not-a-valid-committer\n\nMessage";
|
||||
|
||||
let err = parse::<String, String>(raw).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ParseError::InvalidCommitter(_)),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
use crate::commit::parse::parse;
|
||||
|
||||
#[test]
|
||||
fn root_commit() {
|
||||
let raw = r#"tree abc123
|
||||
author Alice Liddell <alice@example.com> 1700000000 +0000
|
||||
committer Alice Liddell <alice@example.com> 1700000000 +0000
|
||||
|
||||
Initial commit"#;
|
||||
|
||||
let commit = parse::<String, String>(raw).unwrap();
|
||||
|
||||
assert_eq!(commit.tree(), "abc123");
|
||||
assert_eq!(commit.parents().count(), 0);
|
||||
assert_eq!(commit.author().name, "Alice Liddell");
|
||||
assert_eq!(commit.author().email, "alice@example.com");
|
||||
assert_eq!(commit.author().time.seconds(), 1700000000);
|
||||
assert_eq!(commit.author().time.offset(), 0);
|
||||
assert_eq!(commit.committer().name, "Alice Liddell");
|
||||
assert_eq!(commit.message(), "Initial commit");
|
||||
assert_eq!(commit.trailers().count(), 0);
|
||||
assert_eq!(commit.headers().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_with_single_parent() {
|
||||
let raw = r#"tree def456
|
||||
parent abc123
|
||||
author Alice Liddell <alice@example.com> 1700000000 +0000
|
||||
committer Alice Liddell <alice@example.com> 1700000000 +0000
|
||||
|
||||
Second commit"#;
|
||||
|
||||
let commit = parse::<String, String>(raw).unwrap();
|
||||
|
||||
assert_eq!(commit.tree(), "def456");
|
||||
assert_eq!(commit.parents().collect::<Vec<_>>(), ["abc123"]);
|
||||
assert_eq!(commit.message(), "Second commit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_commit() {
|
||||
let raw = r#"tree ghi789
|
||||
parent abc123
|
||||
parent def456
|
||||
author Alice Liddell <alice@example.com> 1700000000 +0000
|
||||
committer Alice Liddell <alice@example.com> 1700000000 +0000
|
||||
|
||||
Merge branch 'feature'"#;
|
||||
|
||||
let commit = parse::<String, String>(raw).unwrap();
|
||||
|
||||
assert_eq!(commit.parents().collect::<Vec<_>>(), ["abc123", "def456"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_with_multiline_gpgsig() {
|
||||
// gpgsig continuation lines are indented by one space in the raw object.
|
||||
// The parser folds them back into the header value with embedded newlines.
|
||||
let raw = r#"tree abc123
|
||||
author Alice Liddell <alice@example.com> 1700000000 +0000
|
||||
committer Alice Liddell <alice@example.com> 1700000000 +0000
|
||||
gpgsig -----BEGIN SSH SIGNATURE-----
|
||||
AAAAB3NzaC1yc2EAAAADAQAB
|
||||
AAAA==
|
||||
-----END SSH SIGNATURE-----
|
||||
|
||||
Signed commit"#;
|
||||
|
||||
let commit = parse::<String, String>(raw).unwrap();
|
||||
|
||||
assert_eq!(commit.signatures().count(), 1);
|
||||
// gpgsig is stored in headers; it is the only extra header here.
|
||||
assert_eq!(commit.headers().count(), 1);
|
||||
assert_eq!(commit.message(), "Signed commit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_gpgsig_is_preserved_and_strip_removes_it() {
|
||||
// Parsing preserves gpgsig so callers can extract it before stripping.
|
||||
let raw = r#"tree abc123
|
||||
author Alice Liddell <alice@example.com> 1700000000 +0000
|
||||
committer Alice Liddell <alice@example.com> 1700000000 +0000
|
||||
gpgsig -----BEGIN SSH SIGNATURE-----
|
||||
AAAA==
|
||||
-----END SSH SIGNATURE-----
|
||||
|
||||
Signed commit"#;
|
||||
|
||||
let commit = parse::<String, String>(raw).unwrap();
|
||||
assert_eq!(commit.signatures().count(), 1);
|
||||
|
||||
let stripped = commit.strip_signatures();
|
||||
assert_eq!(stripped.signatures().count(), 0);
|
||||
assert_eq!(stripped.headers().count(), 0);
|
||||
assert_eq!(stripped.message(), "Signed commit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_with_trailers() {
|
||||
// The last paragraph contains only valid Token: value lines, so they
|
||||
// are split out into the trailers vec and excluded from the message.
|
||||
let raw = r#"tree abc123
|
||||
author Alice Liddell <alice@example.com> 1700000000 +0000
|
||||
committer Bob Bobson <bob@example.com> 1700000001 +0100
|
||||
|
||||
Add a new feature
|
||||
|
||||
This commit adds a new feature to the library.
|
||||
|
||||
Signed-off-by: Alice Liddell <alice@example.com>
|
||||
Co-authored-by: Bob Bobson <bob@example.com>"#;
|
||||
|
||||
let commit = parse::<String, String>(raw).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
commit.message(),
|
||||
"Add a new feature\n\nThis commit adds a new feature to the library."
|
||||
);
|
||||
let trailers: Vec<_> = commit.trailers().collect();
|
||||
assert_eq!(trailers.len(), 2);
|
||||
assert_eq!(&*trailers[0].token, "Signed-off-by");
|
||||
assert_eq!(trailers[0].value, "Alice Liddell <alice@example.com>");
|
||||
assert_eq!(&*trailers[1].token, "Co-authored-by");
|
||||
assert_eq!(trailers[1].value, "Bob Bobson <bob@example.com>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_last_paragraph_kept_in_message_when_not_all_trailers() {
|
||||
// If any line in the last paragraph is not a valid Token: value pair,
|
||||
// the entire paragraph stays in the message and no trailers are extracted.
|
||||
let raw = r#"tree abc123
|
||||
author Alice Liddell <alice@example.com> 1700000000 +0000
|
||||
committer Alice Liddell <alice@example.com> 1700000000 +0000
|
||||
|
||||
Add feature
|
||||
|
||||
Signed-off-by: Alice Liddell <alice@example.com>
|
||||
This line is not a valid trailer."#;
|
||||
|
||||
let commit = parse::<String, String>(raw).unwrap();
|
||||
|
||||
assert_eq!(commit.trailers().count(), 0);
|
||||
assert!(commit.message().contains("Signed-off-by"));
|
||||
assert!(commit
|
||||
.message()
|
||||
.contains("This line is not a valid trailer."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_with_extra_headers() {
|
||||
let raw = r#"tree abc123
|
||||
author Alice Liddell <alice@example.com> 1700000000 +0000
|
||||
committer Alice Liddell <alice@example.com> 1700000000 +0000
|
||||
encoding UTF-8
|
||||
mergetag some-value
|
||||
|
||||
Commit with extra headers"#;
|
||||
|
||||
let commit = parse::<String, String>(raw).unwrap();
|
||||
|
||||
let headers: Vec<_> = commit.headers().collect();
|
||||
assert_eq!(headers.len(), 2);
|
||||
assert_eq!(headers[0], ("encoding", "UTF-8"));
|
||||
assert_eq!(headers[1], ("mergetag", "some-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
// Parsing and then re-displaying a commit must produce output that parses
|
||||
// back to a CommitData equal in every field, exercising the Display /
|
||||
// parse_body symmetry in particular.
|
||||
let raw = r#"tree abc123
|
||||
parent def456
|
||||
author Alice Liddell <alice@example.com> 1700000000 +0000
|
||||
committer Bob Bobson <bob@example.com> 1700000001 +0100
|
||||
|
||||
Add something useful
|
||||
|
||||
Signed-off-by: Alice Liddell <alice@example.com>"#;
|
||||
|
||||
let commit = parse::<String, String>(raw).unwrap();
|
||||
let displayed = commit.to_string();
|
||||
let reparsed = parse::<String, String>(&displayed).unwrap();
|
||||
|
||||
assert_eq!(commit.tree(), reparsed.tree());
|
||||
assert_eq!(
|
||||
commit.parents().collect::<Vec<_>>(),
|
||||
reparsed.parents().collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(commit.author(), reparsed.author());
|
||||
assert_eq!(commit.committer(), reparsed.committer());
|
||||
assert_eq!(commit.message(), reparsed.message());
|
||||
assert_eq!(
|
||||
commit.trailers().collect::<Vec<_>>(),
|
||||
reparsed.trailers().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
use crate::commit::parse::{parse_body, try_parse_trailers};
|
||||
|
||||
#[test]
|
||||
fn body_no_paragraph_separator_means_no_trailers() {
|
||||
// A body with no blank line cannot have a trailing trailer paragraph.
|
||||
let (message, trailers) = parse_body("Just a message with no blank line");
|
||||
assert_eq!(message, "Just a message with no blank line");
|
||||
assert!(trailers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_last_paragraph_not_trailers_stays_in_message() {
|
||||
let body = "Short description\n\nThis paragraph has no Token: value lines.";
|
||||
let (message, trailers) = parse_body(body);
|
||||
assert_eq!(message, body);
|
||||
assert!(trailers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailers_rejects_line_without_separator() {
|
||||
// A line that contains no ": " cannot be a trailer.
|
||||
assert!(try_parse_trailers("NotATrailerLine").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailers_rejects_invalid_token_chars() {
|
||||
// Token characters must be alphanumeric or '-'; spaces are not allowed.
|
||||
assert!(try_parse_trailers("Invalid Token: value").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailers_accepts_empty_input() {
|
||||
// An empty paragraph produces an empty trailer list rather than None.
|
||||
// (parse_body guards against this with the is_empty() check, but the
|
||||
// helper itself is defined to return Some([]) for an empty iterator.)
|
||||
let result = try_parse_trailers("");
|
||||
assert_eq!(result, Some(vec![]));
|
||||
}
|
||||
Loading…
Reference in New Issue