Skip to main content

stratum/domain/
commit.rs

1use regex::Regex;
2use std::sync::LazyLock;
3use std::{cell::OnceCell, str::FromStr};
4
5use crate::{Actor, Error, ModifiedFile, Repository};
6
7/// Iterate all co-author matches in the haystack string formatting the return
8/// string to be formatted as "Name <Email>"
9fn iter_co_authors(haystack: &str) -> impl Iterator<Item = &str> {
10    const CO_AUTHOR_REGEX: &str = r"(?m)^Co-authored-by: (.*) <(.*?)>$";
11    // Regex should always compile hence bare unwrap.
12    static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(CO_AUTHOR_REGEX).unwrap());
13
14    let prefix = "Co-authored-by:";
15    RE.find_iter(haystack).map(move |re_match| {
16        re_match
17            .as_str()
18            .strip_prefix(prefix)
19            .unwrap_or_default()
20            .trim()
21    })
22}
23
24/// A singular git commit for the repository being inspected
25pub struct Commit<'repo> {
26    inner: git2::Commit<'repo>,
27    ctx: &'repo Repository,
28    cache: OnceCell<git2::Diff<'repo>>,
29}
30
31impl<'repo> Commit<'repo> {
32    /// Instantiate a new Commit object from a git2 commit
33    pub fn new(commit: git2::Commit<'repo>, repository: &'repo Repository) -> Self {
34        Self {
35            inner: commit.to_owned(),
36            ctx: repository,
37            cache: OnceCell::new(),
38        }
39    }
40
41    /// Return the commit hash
42    pub fn hash(&self) -> String {
43        self.inner.id().to_string()
44    }
45
46    /// Return the commit message if it exists
47    pub fn msg(&self) -> Option<&str> {
48        self.inner.message()
49    }
50
51    /// Return the commit author
52    pub fn author(&self) -> Actor {
53        Actor::new(self.inner.author())
54    }
55
56    /// Return the co-authors as listed in the commit message
57    ///
58    /// Lazilly returning as an iterator means the co-authors, if entered more
59    /// than once, will **not** be de-duplicated.
60    pub fn co_authors(&self) -> impl Iterator<Item = Result<Actor, Error>> {
61        let commit_msg = self.msg().unwrap_or_default();
62        iter_co_authors(commit_msg).map(Actor::from_str)
63    }
64
65    /// Return the commit committer
66    pub fn committer(&self) -> Actor {
67        Actor::new(self.inner.committer())
68    }
69
70    /// Retrun the hashes of all commit parents
71    pub fn parents(&self) -> impl Iterator<Item = String> {
72        self.inner.parent_ids().map(|id| id.to_string())
73    }
74
75    /// Return whether the commit is a merge commit
76    pub fn is_merge(&self) -> bool {
77        self.inner.parent_count() > 1
78    }
79
80    /// Return an iterator over the modified files that belong to a commit
81    pub fn mod_files(&self) -> Result<impl Iterator<Item = ModifiedFile<'_>>, Error> {
82        let diff = self.diff()?;
83
84        Ok((0..diff.deltas().len()).map(move |n| ModifiedFile::new(diff, n)))
85    }
86
87    /// The number of insertions in the commit
88    pub fn insertions(&self) -> Result<usize, Error> {
89        Ok(self.stats()?.insertions())
90    }
91
92    /// The number of deletions in the commit
93    pub fn deletions(&self) -> Result<usize, Error> {
94        Ok(self.stats()?.deletions())
95    }
96
97    /// The total number of lines modified in the commit
98    pub fn lines(&self) -> Result<usize, Error> {
99        Ok(self.insertions()? + self.deletions()?)
100    }
101
102    /// The number of files modified in the commit
103    pub fn files(&self) -> Result<usize, Error> {
104        Ok(self.stats()?.files_changed())
105    }
106
107    //TODO: Should stats also be cached?
108    /// Return the git2 Stats from the commits diff
109    fn stats(&self) -> Result<git2::DiffStats, Error> {
110        let diff = self.diff()?;
111        diff.stats().map_err(Error::Git)
112    }
113
114    /// Return the git diff for the current commit within the context of a
115    /// repository.
116    //TODO: https://github.com/segfault-merchant/git-stratum/issues/32
117    fn diff(&self) -> Result<&git2::Diff<'repo>, Error> {
118        let diff = self.calculate_diff()?;
119        Ok(self.cache.get_or_init(|| diff))
120    }
121
122    /// Diff the current commit to it's parent(s) adjusting strategy based on the
123    /// number of parents
124    fn calculate_diff(&self) -> Result<git2::Diff<'repo>, Error> {
125        let this_tree = self.inner.tree().ok();
126        let parent_tree = self.resolve_parent_tree()?;
127
128        self.ctx
129            .raw()
130            //TODO: Expose opts?
131            .diff_tree_to_tree(parent_tree.as_ref(), this_tree.as_ref(), None)
132            .map_err(Error::Git)
133    }
134
135    /// Resolve to the correct parent tree changing strategies based on number
136    /// of parents.
137    fn resolve_parent_tree(&self) -> Result<Option<git2::Tree<'_>>, Error> {
138        Ok(match self.inner.parent_count() {
139            0 => None,
140            1 => self.inner.parent(0).map_err(Error::Git)?.tree().ok(),
141            //TODO: Resolve merge commit process
142            _ => return Err(Error::PathError("Placeholder error".to_string())),
143        })
144    }
145}
146
147#[cfg(test)]
148mod test {
149    use super::*;
150    use crate::{
151        Local, Repository,
152        common::{EXPECTED_ACTOR_EMAIL, EXPECTED_ACTOR_NAME, EXPECTED_MSG, init_repo},
153    };
154
155    fn commit_fixture<F, R>(f: F) -> R
156    where
157        F: FnOnce(&Repository<Local>, &Commit) -> R,
158    {
159        let repo = init_repo();
160
161        let repo = Repository::<Local>::from_repository(repo);
162        let commit = repo.head().expect("Failed to get HEAD");
163
164        f(&repo, &commit)
165    }
166
167    #[test]
168    fn test_msg() {
169        commit_fixture(|_, commit| {
170            // use mfile here
171            assert_eq!(commit.msg(), Some(EXPECTED_MSG));
172        });
173    }
174
175    #[test]
176    fn test_author() {
177        commit_fixture(|_, commit| {
178            assert_eq!(
179                commit.author().name().unwrap(),
180                EXPECTED_ACTOR_NAME.to_string()
181            );
182            assert_eq!(
183                commit.author().email().unwrap(),
184                EXPECTED_ACTOR_EMAIL.to_string()
185            );
186        });
187    }
188
189    #[test]
190    fn test_co_authors() {
191        commit_fixture(|_, commit| {
192            for co_auth in commit.co_authors() {
193                assert!(co_auth.is_ok());
194            }
195        });
196    }
197
198    #[test]
199    fn test_committer() {
200        commit_fixture(|_, commit| {
201            assert_eq!(
202                commit.committer().name().unwrap(),
203                EXPECTED_ACTOR_NAME.to_string()
204            );
205            assert_eq!(
206                commit.committer().email().unwrap(),
207                EXPECTED_ACTOR_EMAIL.to_string()
208            );
209        });
210    }
211
212    #[test]
213    fn test_parents() {
214        commit_fixture(|_, commit| {
215            assert_eq!(commit.parents().collect::<Vec<String>>().len(), 1);
216        });
217    }
218
219    #[test]
220    fn test_is_merge() {
221        commit_fixture(|_, commit| {
222            assert!(!commit.is_merge());
223        });
224    }
225
226    #[test]
227    fn test_insertions() {
228        commit_fixture(|_, commit| {
229            assert_eq!(commit.insertions().unwrap(), 1);
230        });
231    }
232
233    #[test]
234    fn test_deletions() {
235        commit_fixture(|_, commit| {
236            assert_eq!(commit.deletions().unwrap(), 0);
237        });
238    }
239
240    #[test]
241    fn test_lines() {
242        commit_fixture(|_, commit| {
243            assert_eq!(commit.lines().unwrap(), 1);
244        });
245    }
246
247    #[test]
248    fn test_stat() {
249        commit_fixture(|_, commit| {
250            // Won't compile if return type is bad, stat otherwise checked in insertions
251            // and deletions test functions
252            let _: git2::DiffStats = commit
253                .stats()
254                .expect("Failed to construct git2 Stats object");
255        });
256    }
257
258    #[test]
259    fn test_iter_matches() {
260        let haystack = "Co-authored-by: John <john@example.com>";
261        assert_eq!(iter_co_authors(haystack).collect::<Vec<&str>>().len(), 1);
262
263        let haystack = "No matches expected";
264        assert_eq!(iter_co_authors(haystack).collect::<Vec<&str>>().len(), 0);
265    }
266}