git_commit/
lib.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
14use std::{
15    fmt::Write as _,
16    str::{self, FromStr},
17};
18
19use git2::{ObjectType, Oid};
20use git_trailers::{self as trailers, OwnedTrailer, Trailer};
21
22pub mod author;
23pub use author::Author;
24
25pub mod headers;
26pub use headers::{Headers, Signature};
27
28/// A git commit in its object description form, i.e. the output of
29/// `git cat-file` for a commit object.
30#[derive(Debug)]
31pub struct Commit {
32    tree: Oid,
33    parents: Vec<Oid>,
34    author: Author,
35    committer: Author,
36    headers: Headers,
37    message: String,
38    trailers: Vec<OwnedTrailer>,
39}
40
41impl Commit {
42    pub fn new<I, T>(
43        tree: Oid,
44        parents: Vec<Oid>,
45        author: Author,
46        committer: Author,
47        headers: Headers,
48        message: String,
49        trailers: I,
50    ) -> Self
51    where
52        I: IntoIterator<Item = T>,
53        OwnedTrailer: From<T>,
54    {
55        let trailers = trailers.into_iter().map(OwnedTrailer::from).collect();
56        Self {
57            tree,
58            parents,
59            author,
60            committer,
61            headers,
62            message,
63            trailers,
64        }
65    }
66
67    /// Read the [`Commit`] from the `repo` that is expected to be found at
68    /// `oid`.
69    pub fn read(repo: &git2::Repository, oid: Oid) -> Result<Self, error::Read> {
70        let odb = repo.odb()?;
71        let object = odb.read(oid)?;
72        Ok(Commit::try_from(object.data())?)
73    }
74
75    /// Write the given [`Commit`] to the `repo`. The resulting `Oid`
76    /// is the identifier for this commit.
77    pub fn write(&self, repo: &git2::Repository) -> Result<Oid, git2::Error> {
78        let odb = repo.odb()?;
79        odb.write(ObjectType::Commit, self.to_string().as_bytes())
80    }
81
82    /// The tree [`Oid`] this commit points to.
83    pub fn tree(&self) -> Oid {
84        self.tree
85    }
86
87    /// The parent [`Oid`]s of this commit.
88    pub fn parents(&self) -> impl Iterator<Item = Oid> + '_ {
89        self.parents.iter().copied()
90    }
91
92    /// The author of this commit, i.e. the header corresponding to `author`.
93    pub fn author(&self) -> &Author {
94        &self.author
95    }
96
97    /// The committer of this commit, i.e. the header corresponding to
98    /// `committer`.
99    pub fn committer(&self) -> &Author {
100        &self.committer
101    }
102
103    /// The message body of this commit.
104    pub fn message(&self) -> &str {
105        &self.message
106    }
107
108    /// The [`Signature`]s found in this commit, i.e. the headers corresponding
109    /// to `gpgsig`.
110    pub fn signatures(&self) -> impl Iterator<Item = Signature> + '_ {
111        self.headers.signatures()
112    }
113
114    /// The [`Headers`] found in this commit.
115    ///
116    /// Note: these do not include `tree`, `parent`, `author`, and `committer`.
117    pub fn headers(&self) -> impl Iterator<Item = (&str, &str)> {
118        self.headers.iter()
119    }
120
121    /// Iterate over the [`Headers`] values that match the provided `name`.
122    pub fn values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a str> + '_ {
123        self.headers.values(name)
124    }
125
126    /// Push a header to the end of the headers section.
127    pub fn push_header(&mut self, name: &str, value: &str) {
128        self.headers.push(name, value.trim());
129    }
130
131    pub fn trailers(&self) -> impl Iterator<Item = &OwnedTrailer> {
132        self.trailers.iter()
133    }
134}
135
136pub mod error {
137    use std::str;
138
139    use thiserror::Error;
140
141    use super::author;
142
143    #[derive(Debug, Error)]
144    pub enum Read {
145        #[error(transparent)]
146        Git(#[from] git2::Error),
147        #[error(transparent)]
148        Parse(#[from] Parse),
149    }
150
151    #[derive(Debug, Error)]
152    pub enum Parse {
153        #[error(transparent)]
154        Author(#[from] author::ParseError),
155        #[error("invalid '{header}'")]
156        InvalidHeader {
157            header: &'static str,
158            #[source]
159            err: git2::Error,
160        },
161        #[error("invalid git commit object format")]
162        InvalidFormat,
163        #[error("missing '{0}' while parsing commit")]
164        Missing(&'static str),
165        #[error(transparent)]
166        Token(#[from] git_trailers::InvalidToken),
167        #[error("error occurred while checking for git-trailers: {0}")]
168        Trailers(#[source] git2::Error),
169        #[error(transparent)]
170        Utf8(#[from] str::Utf8Error),
171    }
172}
173
174impl TryFrom<git2::Buf> for Commit {
175    type Error = error::Parse;
176
177    fn try_from(value: git2::Buf) -> Result<Self, Self::Error> {
178        value.as_str().ok_or(error::Parse::InvalidFormat)?.parse()
179    }
180}
181
182impl TryFrom<&[u8]> for Commit {
183    type Error = error::Parse;
184
185    fn try_from(data: &[u8]) -> Result<Self, Self::Error> {
186        Commit::from_str(str::from_utf8(data)?)
187    }
188}
189
190impl FromStr for Commit {
191    type Err = error::Parse;
192
193    fn from_str(buffer: &str) -> Result<Self, Self::Err> {
194        let (header, message) = buffer
195            .split_once("\n\n")
196            .ok_or(error::Parse::InvalidFormat)?;
197        let mut lines = header.lines();
198
199        let tree = match lines.next() {
200            Some(tree) => tree
201                .strip_prefix("tree ")
202                .map(git2::Oid::from_str)
203                .transpose()
204                .map_err(|err| error::Parse::InvalidHeader {
205                    header: "tree",
206                    err,
207                })?
208                .ok_or(error::Parse::Missing("tree"))?,
209            None => return Err(error::Parse::Missing("tree")),
210        };
211
212        let mut parents = Vec::new();
213        let mut author: Option<Author> = None;
214        let mut committer: Option<Author> = None;
215        let mut headers = Headers::new();
216
217        for line in lines {
218            // Check if a signature is still being parsed
219            if let Some(rest) = line.strip_prefix(' ') {
220                let value: &mut String = headers
221                    .0
222                    .last_mut()
223                    .map(|(_, v)| v)
224                    .ok_or(error::Parse::InvalidFormat)?;
225                value.push('\n');
226                value.push_str(rest);
227                continue;
228            }
229
230            if let Some((name, value)) = line.split_once(' ') {
231                match name {
232                    "parent" => parents.push(git2::Oid::from_str(value).map_err(|err| {
233                        error::Parse::InvalidHeader {
234                            header: "parent",
235                            err,
236                        }
237                    })?),
238                    "author" => author = Some(value.parse::<Author>()?),
239                    "committer" => committer = Some(value.parse::<Author>()?),
240                    _ => headers.push(name, value),
241                }
242                continue;
243            }
244        }
245
246        let (message, trailers) = message.lines().fold(
247            (Vec::new(), Vec::new()),
248            |(mut message, mut trailers), line| match trailers::parser::trailer(line, ": ") {
249                Ok((_, trailer)) => {
250                    trailers.push(trailer.into());
251                    (message, trailers)
252                },
253                Err(_) => {
254                    message.push(line);
255                    (message, trailers)
256                },
257            },
258        );
259
260        Ok(Self {
261            tree,
262            parents,
263            author: author.ok_or(error::Parse::Missing("author"))?,
264            committer: committer.ok_or(error::Parse::Missing("committer"))?,
265            headers,
266            message: message.join("\n"),
267            trailers,
268        })
269    }
270}
271
272impl ToString for Commit {
273    fn to_string(&self) -> String {
274        let mut buf = String::new();
275
276        writeln!(buf, "tree {}", self.tree).ok();
277
278        for parent in &self.parents {
279            writeln!(buf, "parent {parent}").ok();
280        }
281
282        writeln!(buf, "author {}", self.author).ok();
283        writeln!(buf, "committer {}", self.committer).ok();
284
285        for (name, value) in self.headers.iter() {
286            writeln!(buf, "{name} {}", value.replace('\n', "\n ")).ok();
287        }
288        writeln!(buf).ok();
289        write!(buf, "{}", self.message.trim()).ok();
290        writeln!(buf).ok();
291
292        if !self.trailers.is_empty() {
293            writeln!(buf).ok();
294        }
295        for (i, trailer) in self.trailers.iter().enumerate() {
296            if i < self.trailers.len() {
297                writeln!(buf, "{}", Trailer::from(trailer).display(": ")).ok();
298            } else {
299                write!(buf, "{}", Trailer::from(trailer).display(": ")).ok();
300            }
301        }
302        buf
303    }
304}