radicle_git_ext/
commit.rs

1//! The `git-commit` crate provides parsing a displaying of a [git
2//! commit][git-commit].
3//!
4//! The [`Commit`] data can be constructed using the `FromStr`
5//! implementation, or by converting from a `git2::Buf`.
6//!
7//! The [`Headers`] can be accessed via [`Commit::headers`]. If the
8//! signatures of the commit are of particular interest, the
9//! [`Commit::signatures`] method can be used, which returns a series of
10//! [`Signature`]s.
11//!
12//! [git-commit]: https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
13
14pub mod headers;
15pub mod trailers;
16
17use core::fmt;
18use std::str::{self, FromStr};
19
20use git2::{ObjectType, Oid};
21
22use headers::{Headers, Signature};
23use trailers::{OwnedTrailer, Trailer, Trailers};
24
25use crate::author::Author;
26
27pub type Commit = CommitData<Oid, Oid>;
28
29impl Commit {
30    /// Read the [`Commit`] from the `repo` that is expected to be found at
31    /// `oid`.
32    pub fn read(repo: &git2::Repository, oid: Oid) -> Result<Self, error::Read> {
33        let odb = repo.odb()?;
34        let object = odb.read(oid)?;
35        Ok(Commit::try_from(object.data())?)
36    }
37
38    /// Write the given [`Commit`] to the `repo`. The resulting `Oid`
39    /// is the identifier for this commit.
40    pub fn write(&self, repo: &git2::Repository) -> Result<Oid, error::Write> {
41        let odb = repo.odb().map_err(error::Write::Odb)?;
42        self.verify_for_write(&odb)?;
43        Ok(odb.write(ObjectType::Commit, self.to_string().as_bytes())?)
44    }
45
46    fn verify_for_write(&self, odb: &git2::Odb) -> Result<(), error::Write> {
47        for parent in &self.parents {
48            verify_object(odb, parent, ObjectType::Commit)?;
49        }
50        verify_object(odb, &self.tree, ObjectType::Tree)?;
51
52        Ok(())
53    }
54}
55
56/// A git commit in its object description form, i.e. the output of
57/// `git cat-file` for a commit object.
58#[derive(Debug)]
59pub struct CommitData<Tree, Parent> {
60    tree: Tree,
61    parents: Vec<Parent>,
62    author: Author,
63    committer: Author,
64    headers: Headers,
65    message: String,
66    trailers: Vec<OwnedTrailer>,
67}
68
69impl<Tree, Parent> CommitData<Tree, Parent> {
70    pub fn new<P, I, T>(
71        tree: Tree,
72        parents: P,
73        author: Author,
74        committer: Author,
75        headers: Headers,
76        message: String,
77        trailers: I,
78    ) -> Self
79    where
80        P: IntoIterator<Item = Parent>,
81        I: IntoIterator<Item = T>,
82        OwnedTrailer: From<T>,
83    {
84        let trailers = trailers.into_iter().map(OwnedTrailer::from).collect();
85        let parents = parents.into_iter().collect();
86        Self {
87            tree,
88            parents,
89            author,
90            committer,
91            headers,
92            message,
93            trailers,
94        }
95    }
96
97    /// The tree this commit points to.
98    pub fn tree(&self) -> &Tree {
99        &self.tree
100    }
101
102    /// The parents of this commit.
103    pub fn parents(&self) -> impl Iterator<Item = Parent> + '_
104    where
105        Parent: Clone,
106    {
107        self.parents.iter().cloned()
108    }
109
110    /// The author of this commit, i.e. the header corresponding to `author`.
111    pub fn author(&self) -> &Author {
112        &self.author
113    }
114
115    /// The committer of this commit, i.e. the header corresponding to
116    /// `committer`.
117    pub fn committer(&self) -> &Author {
118        &self.committer
119    }
120
121    /// The message body of this commit.
122    pub fn message(&self) -> &str {
123        &self.message
124    }
125
126    /// The [`Signature`]s found in this commit, i.e. the headers corresponding
127    /// to `gpgsig`.
128    pub fn signatures(&self) -> impl Iterator<Item = Signature> + '_ {
129        self.headers.signatures()
130    }
131
132    /// The [`Headers`] found in this commit.
133    ///
134    /// Note: these do not include `tree`, `parent`, `author`, and `committer`.
135    pub fn headers(&self) -> impl Iterator<Item = (&str, &str)> {
136        self.headers.iter()
137    }
138
139    /// Iterate over the [`Headers`] values that match the provided `name`.
140    pub fn values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a str> + 'a {
141        self.headers.values(name)
142    }
143
144    /// Push a header to the end of the headers section.
145    pub fn push_header(&mut self, name: &str, value: &str) {
146        self.headers.push(name, value.trim());
147    }
148
149    pub fn trailers(&self) -> impl Iterator<Item = &OwnedTrailer> {
150        self.trailers.iter()
151    }
152
153    /// Convert the `CommitData::tree` into a value of type `U`. The
154    /// conversion function `f` can be fallible.
155    ///
156    /// For example, `map_tree` can be used to turn raw tree data into
157    /// an `Oid` by writing it to a repository.
158    pub fn map_tree<U, E, F>(self, f: F) -> Result<CommitData<U, Parent>, E>
159    where
160        F: FnOnce(Tree) -> Result<U, E>,
161    {
162        Ok(CommitData {
163            tree: f(self.tree)?,
164            parents: self.parents,
165            author: self.author,
166            committer: self.committer,
167            headers: self.headers,
168            message: self.message,
169            trailers: self.trailers,
170        })
171    }
172
173    /// Convert the `CommitData::parents` into a vector containing
174    /// values of type `U`. The conversion function `f` can be
175    /// fallible.
176    ///
177    /// For example, `map_parents` can be used to resolve the `Oid`s
178    /// to their respective `git2::Commit`s.
179    pub fn map_parents<U, E, F>(self, f: F) -> Result<CommitData<Tree, U>, E>
180    where
181        F: FnMut(Parent) -> Result<U, E>,
182    {
183        Ok(CommitData {
184            tree: self.tree,
185            parents: self
186                .parents
187                .into_iter()
188                .map(f)
189                .collect::<Result<Vec<_>, _>>()?,
190            author: self.author,
191            committer: self.committer,
192            headers: self.headers,
193            message: self.message,
194            trailers: self.trailers,
195        })
196    }
197}
198
199fn verify_object(odb: &git2::Odb, oid: &Oid, expected: ObjectType) -> Result<(), error::Write> {
200    use git2::{Error, ErrorClass, ErrorCode};
201
202    let (_, kind) = odb
203        .read_header(*oid)
204        .map_err(|err| error::Write::OdbRead { oid: *oid, err })?;
205    if kind != expected {
206        Err(error::Write::NotCommit {
207            oid: *oid,
208            err: Error::new(
209                ErrorCode::NotFound,
210                ErrorClass::Object,
211                format!("Object '{oid}' is not expected object type {expected}"),
212            ),
213        })
214    } else {
215        Ok(())
216    }
217}
218
219pub mod error {
220    use std::str;
221
222    use thiserror::Error;
223
224    use crate::author;
225
226    #[derive(Debug, Error)]
227    pub enum Write {
228        #[error(transparent)]
229        Git(#[from] git2::Error),
230        #[error("the parent '{oid}' provided is not a commit object")]
231        NotCommit {
232            oid: git2::Oid,
233            #[source]
234            err: git2::Error,
235        },
236        #[error("failed to access git odb")]
237        Odb(#[source] git2::Error),
238        #[error("failed to read '{oid}' from git odb")]
239        OdbRead {
240            oid: git2::Oid,
241            #[source]
242            err: git2::Error,
243        },
244    }
245
246    #[derive(Debug, Error)]
247    pub enum Read {
248        #[error(transparent)]
249        Git(#[from] git2::Error),
250        #[error(transparent)]
251        Parse(#[from] Parse),
252    }
253
254    #[derive(Debug, Error)]
255    pub enum Parse {
256        #[error(transparent)]
257        Author(#[from] author::ParseError),
258        #[error("invalid '{header}'")]
259        InvalidHeader {
260            header: &'static str,
261            #[source]
262            err: git2::Error,
263        },
264        #[error("invalid git commit object format")]
265        InvalidFormat,
266        #[error("missing '{0}' while parsing commit")]
267        Missing(&'static str),
268        #[error("error occurred while checking for git-trailers: {0}")]
269        Trailers(#[source] git2::Error),
270        #[error(transparent)]
271        Utf8(#[from] str::Utf8Error),
272    }
273}
274
275impl TryFrom<git2::Buf> for Commit {
276    type Error = error::Parse;
277
278    fn try_from(value: git2::Buf) -> Result<Self, Self::Error> {
279        value.as_str().ok_or(error::Parse::InvalidFormat)?.parse()
280    }
281}
282
283impl TryFrom<&[u8]> for Commit {
284    type Error = error::Parse;
285
286    fn try_from(data: &[u8]) -> Result<Self, Self::Error> {
287        Commit::from_str(str::from_utf8(data)?)
288    }
289}
290
291impl FromStr for Commit {
292    type Err = error::Parse;
293
294    fn from_str(buffer: &str) -> Result<Self, Self::Err> {
295        let (header, message) = buffer
296            .split_once("\n\n")
297            .ok_or(error::Parse::InvalidFormat)?;
298        let mut lines = header.lines();
299
300        let tree = match lines.next() {
301            Some(tree) => tree
302                .strip_prefix("tree ")
303                .map(git2::Oid::from_str)
304                .transpose()
305                .map_err(|err| error::Parse::InvalidHeader {
306                    header: "tree",
307                    err,
308                })?
309                .ok_or(error::Parse::Missing("tree"))?,
310            None => return Err(error::Parse::Missing("tree")),
311        };
312
313        let mut parents = Vec::new();
314        let mut author: Option<Author> = None;
315        let mut committer: Option<Author> = None;
316        let mut headers = Headers::new();
317
318        for line in lines {
319            // Check if a signature is still being parsed
320            if let Some(rest) = line.strip_prefix(' ') {
321                let value: &mut String = headers
322                    .0
323                    .last_mut()
324                    .map(|(_, v)| v)
325                    .ok_or(error::Parse::InvalidFormat)?;
326                value.push('\n');
327                value.push_str(rest);
328                continue;
329            }
330
331            if let Some((name, value)) = line.split_once(' ') {
332                match name {
333                    "parent" => parents.push(git2::Oid::from_str(value).map_err(|err| {
334                        error::Parse::InvalidHeader {
335                            header: "parent",
336                            err,
337                        }
338                    })?),
339                    "author" => author = Some(value.parse::<Author>()?),
340                    "committer" => committer = Some(value.parse::<Author>()?),
341                    _ => headers.push(name, value),
342                }
343                continue;
344            }
345        }
346
347        let trailers = Trailers::parse(message).map_err(error::Parse::Trailers)?;
348
349        let message = message
350            .strip_suffix(&trailers.to_string(": "))
351            .unwrap_or(message)
352            .to_string();
353
354        let trailers = trailers.iter().map(OwnedTrailer::from).collect();
355
356        Ok(Self {
357            tree,
358            parents,
359            author: author.ok_or(error::Parse::Missing("author"))?,
360            committer: committer.ok_or(error::Parse::Missing("committer"))?,
361            headers,
362            message,
363            trailers,
364        })
365    }
366}
367
368impl fmt::Display for Commit {
369    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370        writeln!(f, "tree {}", self.tree)?;
371        for parent in self.parents() {
372            writeln!(f, "parent {parent}")?;
373        }
374        writeln!(f, "author {}", self.author)?;
375        writeln!(f, "committer {}", self.committer)?;
376
377        for (name, value) in self.headers.iter() {
378            writeln!(f, "{name} {}", value.replace('\n', "\n "))?;
379        }
380        writeln!(f)?;
381        write!(f, "{}", self.message.trim())?;
382        writeln!(f)?;
383
384        if !self.trailers.is_empty() {
385            writeln!(f)?;
386        }
387        for trailer in self.trailers.iter() {
388            writeln!(f, "{}", Trailer::from(trailer).display(": "))?;
389        }
390        Ok(())
391    }
392}