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    /// Iterate all utf-8 branch names that the current commit is contained in
71    ///
72    /// ## Note
73    ///
74    /// Potentially expensive method. Take caution when using within a loop.
75    pub fn branches(&self) -> Result<impl Iterator<Item = Result<String, Error>>, Error> {
76        self.branch_iterator(None)
77    }
78
79    /// Iterate all **local** utf-8 branch names that the current commit is contained in
80    ///
81    /// ## Note
82    ///
83    /// Potentially expensive method. Take caution when using within a loop.
84    pub fn local_branches(&self) -> Result<impl Iterator<Item = Result<String, Error>>, Error> {
85        let flag = Some(git2::BranchType::Local);
86        self.branch_iterator(flag)
87    }
88
89    /// Iterate all **remote** utf-8 branch names that the current commit is contained in
90    ///
91    /// ## Note
92    ///
93    /// Potentially expensive method. Take caution when using within a loop.
94    pub fn remote_branches(&self) -> Result<impl Iterator<Item = Result<String, Error>>, Error> {
95        let flag = Some(git2::BranchType::Remote);
96        self.branch_iterator(flag)
97    }
98
99    /// Retrun the hashes of all commit parents
100    pub fn parents(&self) -> impl Iterator<Item = String> {
101        self.inner.parent_ids().map(|id| id.to_string())
102    }
103
104    /// Return whether the commit is a merge commit
105    pub fn is_merge(&self) -> bool {
106        self.inner.parent_count() > 1
107    }
108
109    /// Checks if the current commit is reachable from "main" or "master"
110    pub fn in_main(&self) -> Result<bool, Error> {
111        let b = self
112            .local_branches()?
113            .collect::<Vec<Result<String, Error>>>();
114        Ok(b.contains(&Ok("main".to_string())) || b.contains(&Ok("master".to_string())))
115    }
116
117    /// Return an iterator over the modified files that belong to a commit
118    pub fn mod_files(&self) -> Result<impl Iterator<Item = ModifiedFile<'_>>, Error> {
119        let diff = self.diff()?;
120
121        Ok((0..diff.deltas().len()).map(move |n| ModifiedFile::new(diff, n)))
122    }
123
124    /// The number of insertions in the commit
125    pub fn insertions(&self) -> Result<usize, Error> {
126        Ok(self.stats()?.insertions())
127    }
128
129    /// The number of deletions in the commit
130    pub fn deletions(&self) -> Result<usize, Error> {
131        Ok(self.stats()?.deletions())
132    }
133
134    /// The total number of lines modified in the commit
135    pub fn lines(&self) -> Result<usize, Error> {
136        Ok(self.insertions()? + self.deletions()?)
137    }
138
139    /// The number of files modified in the commit
140    pub fn files(&self) -> Result<usize, Error> {
141        Ok(self.stats()?.files_changed())
142    }
143
144    //TODO: Should stats also be cached?
145    /// Return the git2 Stats from the commits diff
146    fn stats(&self) -> Result<git2::DiffStats, Error> {
147        let diff = self.diff()?;
148        diff.stats().map_err(Error::Git)
149    }
150
151    /// Return the git diff for the current commit within the context of a
152    /// repository.
153    //TODO: https://github.com/segfault-merchant/git-stratum/issues/32
154    fn diff(&self) -> Result<&git2::Diff<'repo>, Error> {
155        let diff = self.calculate_diff()?;
156        Ok(self.cache.get_or_init(|| diff))
157    }
158
159    /// Diff the current commit to it's parent(s) adjusting strategy based on the
160    /// number of parents
161    fn calculate_diff(&self) -> Result<git2::Diff<'repo>, Error> {
162        let this_tree = self.inner.tree().ok();
163        let parent_tree = self.resolve_parent_tree()?;
164
165        self.ctx
166            .raw()
167            //TODO: Expose opts?
168            .diff_tree_to_tree(parent_tree.as_ref(), this_tree.as_ref(), None)
169            .map_err(Error::Git)
170    }
171
172    /// Resolve to the correct parent tree changing strategies based on number
173    /// of parents.
174    fn resolve_parent_tree(&self) -> Result<Option<git2::Tree<'_>>, Error> {
175        Ok(match self.inner.parent_count() {
176            0 => None,
177            1 => self.inner.parent(0).map_err(Error::Git)?.tree().ok(),
178            //TODO: Resolve merge commit process
179            _ => return Err(Error::PathError("Placeholder error".to_string())),
180        })
181    }
182
183    /// Check if a commit contains a branch
184    ///
185    /// If an error occurs returns false, this is done so any erroring branches
186    /// are filtered out of any dependant processes
187    fn commit_contains_branch(&self, branch: git2::Oid, commit: git2::Oid) -> bool {
188        self.ctx.raw().graph_descendant_of(branch, commit).is_ok()
189    }
190
191    /// Iterate over the specified branch types, None will return all branches
192    fn branch_iterator(
193        &self,
194        bt: Option<git2::BranchType>,
195    ) -> Result<impl Iterator<Item = Result<String, Error>>, Error> {
196        let commit_id = self.inner.id();
197        let branches = self.ctx.raw().branches(bt).map_err(Error::Git)?;
198
199        Ok(branches.filter_map(move |res| {
200            let branch = match res {
201                Ok(v) => v.0,
202                Err(e) => return Some(Err(Error::Git(e))),
203            };
204
205            // If a branch does not have a valid target then filter that
206            // branch out
207            // TODO: Is this excluding a subset of symbolic references
208            let oid = match branch.get().target() {
209                Some(v) => v,
210                None => return None,
211            };
212
213            // Filter out a branch if the commit does NOT contain it
214            if !self.commit_contains_branch(oid, commit_id) {
215                return None;
216            }
217
218            match branch.name() {
219                Ok(Some(name)) => Some(Ok(name.to_string())),
220                Ok(None) => None, // drop non-utf8 branches
221                Err(e) => Some(Err(Error::Git(e))),
222            }
223        }))
224    }
225}
226
227#[cfg(test)]
228mod test {
229    use super::*;
230    use crate::{
231        Local, Repository,
232        common::{EXPECTED_ACTOR_EMAIL, EXPECTED_ACTOR_NAME, EXPECTED_MSG, init_repo},
233    };
234
235    fn commit_fixture<F, R>(f: F) -> R
236    where
237        F: FnOnce(&Repository<Local>, &Commit) -> R,
238    {
239        let repo = init_repo();
240
241        let repo = Repository::<Local>::from_repository(repo);
242        let commit = repo.head().expect("Failed to get HEAD");
243
244        f(&repo, &commit)
245    }
246
247    #[test]
248    fn test_msg() {
249        commit_fixture(|_, commit| {
250            // use mfile here
251            assert_eq!(commit.msg(), Some(EXPECTED_MSG));
252        });
253    }
254
255    #[test]
256    fn test_author() {
257        commit_fixture(|_, commit| {
258            assert_eq!(
259                commit.author().name().unwrap(),
260                EXPECTED_ACTOR_NAME.to_string()
261            );
262            assert_eq!(
263                commit.author().email().unwrap(),
264                EXPECTED_ACTOR_EMAIL.to_string()
265            );
266        });
267    }
268
269    #[test]
270    fn test_co_authors() {
271        commit_fixture(|_, commit| {
272            for co_auth in commit.co_authors() {
273                assert!(co_auth.is_ok());
274            }
275        });
276    }
277
278    #[test]
279    fn test_committer() {
280        commit_fixture(|_, commit| {
281            assert_eq!(
282                commit.committer().name().unwrap(),
283                EXPECTED_ACTOR_NAME.to_string()
284            );
285            assert_eq!(
286                commit.committer().email().unwrap(),
287                EXPECTED_ACTOR_EMAIL.to_string()
288            );
289        });
290    }
291
292    #[test]
293    fn test_parents() {
294        commit_fixture(|_, commit| {
295            assert_eq!(commit.parents().collect::<Vec<String>>().len(), 1);
296        });
297    }
298
299    #[test]
300    fn test_is_merge() {
301        commit_fixture(|_, commit| {
302            assert!(!commit.is_merge());
303        });
304    }
305
306    #[test]
307    fn test_insertions() {
308        commit_fixture(|_, commit| {
309            assert_eq!(commit.insertions().unwrap(), 1);
310        });
311    }
312
313    #[test]
314    fn test_deletions() {
315        commit_fixture(|_, commit| {
316            assert_eq!(commit.deletions().unwrap(), 0);
317        });
318    }
319
320    #[test]
321    fn test_lines() {
322        commit_fixture(|_, commit| {
323            assert_eq!(commit.lines().unwrap(), 1);
324        });
325    }
326
327    #[test]
328    fn test_stat() {
329        commit_fixture(|_, commit| {
330            // Won't compile if return type is bad, stat otherwise checked in insertions
331            // and deletions test functions
332            let _: git2::DiffStats = commit
333                .stats()
334                .expect("Failed to construct git2 Stats object");
335        });
336    }
337
338    #[test]
339    fn test_iter_matches() {
340        let haystack = "Co-authored-by: John <john@example.com>";
341        assert_eq!(iter_co_authors(haystack).collect::<Vec<&str>>().len(), 1);
342
343        let haystack = "No matches expected";
344        assert_eq!(iter_co_authors(haystack).collect::<Vec<&str>>().len(), 0);
345    }
346}