Skip to main content

stratum/domain/
commit.rs

1use regex::Regex;
2use std::path::Path;
3use std::sync::LazyLock;
4use std::{cell::OnceCell, str::FromStr};
5
6use crate::{Actor, Error, ModifiedFile, Repository};
7
8/// Iterate all co-author matches in the haystack string formatting the return
9/// string to be formatted as "Name <Email>"
10fn iter_co_authors(haystack: &str) -> impl Iterator<Item = &str> {
11    const CO_AUTHOR_REGEX: &str = r"(?m)^Co-authored-by: (.*) <(.*?)>$";
12    // Regex should always compile hence bare unwrap.
13    static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(CO_AUTHOR_REGEX).unwrap());
14
15    let prefix = "Co-authored-by:";
16    RE.find_iter(haystack).map(move |re_match| {
17        re_match
18            .as_str()
19            .strip_prefix(prefix)
20            .unwrap_or_default()
21            .trim()
22    })
23}
24
25/// A singular git commit for the repository being inspected
26pub struct Commit<'repo> {
27    inner: git2::Commit<'repo>,
28    ctx: &'repo Repository,
29    cache: OnceCell<git2::Diff<'repo>>,
30}
31
32impl<'repo> Commit<'repo> {
33    /// Instantiate a new Commit object from a git2 commit
34    pub fn new(commit: git2::Commit<'repo>, repository: &'repo Repository) -> Self {
35        Self {
36            inner: commit.to_owned(),
37            ctx: repository,
38            cache: OnceCell::new(),
39        }
40    }
41
42    /// Return the commit hash
43    pub fn hash(&self) -> String {
44        self.inner.id().to_string()
45    }
46
47    /// Return the commit message if it exists
48    pub fn msg(&self) -> Option<&str> {
49        self.inner.message()
50    }
51
52    /// Return the commit author
53    pub fn author(&self) -> Actor {
54        Actor::new(self.inner.author())
55    }
56
57    /// Return the co-authors as listed in the commit message
58    ///
59    /// Lazilly returning as an iterator means the co-authors, if entered more
60    /// than once, will **not** be de-duplicated.
61    pub fn co_authors(&self) -> impl Iterator<Item = Result<Actor, Error>> {
62        let commit_msg = self.msg().unwrap_or_default();
63        iter_co_authors(commit_msg).map(Actor::from_str)
64    }
65
66    /// Return the commit committer
67    pub fn committer(&self) -> Actor {
68        Actor::new(self.inner.committer())
69    }
70
71    /// Iterate all utf-8 branch names that the current commit is contained in
72    ///
73    /// ## Note
74    ///
75    /// Potentially expensive method. Take caution when using within a loop.
76    pub fn branches(&self) -> Result<impl Iterator<Item = Result<String, Error>>, Error> {
77        self.branch_iterator(None)
78    }
79
80    /// Iterate all **local** utf-8 branch names that the current commit is contained in
81    ///
82    /// ## Note
83    ///
84    /// Potentially expensive method. Take caution when using within a loop.
85    pub fn local_branches(&self) -> Result<impl Iterator<Item = Result<String, Error>>, Error> {
86        let flag = Some(git2::BranchType::Local);
87        self.branch_iterator(flag)
88    }
89
90    /// Iterate all **remote** utf-8 branch names that the current commit is contained in
91    ///
92    /// ## Note
93    ///
94    /// Potentially expensive method. Take caution when using within a loop.
95    pub fn remote_branches(&self) -> Result<impl Iterator<Item = Result<String, Error>>, Error> {
96        let flag = Some(git2::BranchType::Remote);
97        self.branch_iterator(flag)
98    }
99
100    /// Retrun the hashes of all commit parents
101    pub fn parents(&self) -> impl Iterator<Item = String> {
102        self.inner.parent_ids().map(|id| id.to_string())
103    }
104
105    /// Return whether the commit is a merge commit
106    pub fn is_merge(&self) -> bool {
107        self.inner.parent_count() > 1
108    }
109
110    /// Checks if the current commit is reachable from "main" or "master"
111    pub fn in_main(&self) -> Result<bool, Error> {
112        let b = self
113            .local_branches()?
114            .collect::<Vec<Result<String, Error>>>();
115        Ok(b.contains(&Ok("main".to_string())) || b.contains(&Ok("master".to_string())))
116    }
117
118    /// Return an iterator over the modified files that belong to a commit
119    pub fn mod_files(&self) -> Result<impl Iterator<Item = ModifiedFile<'_>>, Error> {
120        let diff = self.diff()?;
121
122        Ok((0..diff.deltas().len()).map(move |n| ModifiedFile::new(diff, n)))
123    }
124
125    /// The number of insertions in the commit
126    pub fn insertions(&self) -> Result<usize, Error> {
127        Ok(self.stats()?.insertions())
128    }
129
130    /// The number of deletions in the commit
131    pub fn deletions(&self) -> Result<usize, Error> {
132        Ok(self.stats()?.deletions())
133    }
134
135    /// The total number of lines modified in the commit
136    pub fn lines(&self) -> Result<usize, Error> {
137        Ok(self.insertions()? + self.deletions()?)
138    }
139
140    /// The number of files modified in the commit
141    pub fn files(&self) -> Result<usize, Error> {
142        Ok(self.stats()?.files_changed())
143    }
144
145    /// Return the project path that the commit belongs to
146    pub fn project_path(&self) -> &Path {
147        let git_folder = self.ctx.raw().path();
148        // Parent dir should always be infallible
149        git_folder.parent().unwrap()
150    }
151
152    /// Return the project name based on the project path
153    pub fn project_name(&self) -> Option<&str> {
154        self.project_path().file_name().and_then(|s| s.to_str())
155    }
156
157    //TODO: Should stats also be cached?
158    /// Return the git2 Stats from the commits diff
159    fn stats(&self) -> Result<git2::DiffStats, Error> {
160        let diff = self.diff()?;
161        diff.stats().map_err(Error::Git)
162    }
163
164    /// Return the git diff for the current commit within the context of a
165    /// repository.
166    //TODO: https://github.com/segfault-merchant/git-stratum/issues/32
167    fn diff(&self) -> Result<&git2::Diff<'repo>, Error> {
168        let diff = self.calculate_diff()?;
169        Ok(self.cache.get_or_init(|| diff))
170    }
171
172    /// Diff the current commit to it's parent(s) adjusting strategy based on the
173    /// number of parents
174    fn calculate_diff(&self) -> Result<git2::Diff<'repo>, Error> {
175        let this_tree = self.inner.tree().ok();
176        let parent_tree = self.resolve_parent_tree()?;
177
178        self.ctx
179            .raw()
180            //TODO: Expose opts?
181            .diff_tree_to_tree(parent_tree.as_ref(), this_tree.as_ref(), None)
182            .map_err(Error::Git)
183    }
184
185    /// Resolve to the correct parent tree changing strategies based on number
186    /// of parents.
187    fn resolve_parent_tree(&self) -> Result<Option<git2::Tree<'_>>, Error> {
188        Ok(match self.inner.parent_count() {
189            0 => None,
190            1 => self.inner.parent(0).map_err(Error::Git)?.tree().ok(),
191            //TODO: Resolve merge commit process
192            _ => return Err(Error::PathError("Placeholder error".to_string())),
193        })
194    }
195
196    /// Check if a commit contains a branch
197    ///
198    /// If an error occurs returns false, this is done so any erroring branches
199    /// are filtered out of any dependant processes
200    fn commit_contains_branch(&self, branch: git2::Oid, commit: git2::Oid) -> bool {
201        self.ctx.raw().graph_descendant_of(branch, commit).is_ok()
202    }
203
204    /// Iterate over the specified branch types, None will return all branches
205    fn branch_iterator(
206        &self,
207        bt: Option<git2::BranchType>,
208    ) -> Result<impl Iterator<Item = Result<String, Error>>, Error> {
209        let commit_id = self.inner.id();
210        let branches = self.ctx.raw().branches(bt).map_err(Error::Git)?;
211
212        Ok(branches.filter_map(move |res| {
213            let branch = match res {
214                Ok(v) => v.0,
215                Err(e) => return Some(Err(Error::Git(e))),
216            };
217
218            // If a branch does not have a valid target then filter that
219            // branch out
220            // TODO: Is this excluding a subset of symbolic references
221            let oid = match branch.get().target() {
222                Some(v) => v,
223                None => return None,
224            };
225
226            // Filter out a branch if the commit does NOT contain it
227            if !self.commit_contains_branch(oid, commit_id) {
228                return None;
229            }
230
231            match branch.name() {
232                Ok(Some(name)) => Some(Ok(name.to_string())),
233                Ok(None) => None, // drop non-utf8 branches
234                Err(e) => Some(Err(Error::Git(e))),
235            }
236        }))
237    }
238}
239
240#[cfg(test)]
241mod test {
242    use super::*;
243    use crate::{
244        Local, Repository,
245        common::{EXPECTED_ACTOR_EMAIL, EXPECTED_ACTOR_NAME, EXPECTED_MSG, init_repo},
246    };
247
248    fn commit_fixture<F, R>(f: F) -> R
249    where
250        F: FnOnce(&Repository<Local>, &Commit) -> R,
251    {
252        let repo = init_repo();
253
254        let repo = Repository::<Local>::from_repository(repo);
255        let commit = repo.head().expect("Failed to get HEAD");
256
257        f(&repo, &commit)
258    }
259
260    #[test]
261    fn test_msg() {
262        commit_fixture(|_, commit| {
263            // use mfile here
264            assert_eq!(commit.msg(), Some(EXPECTED_MSG));
265        });
266    }
267
268    #[test]
269    fn test_author() {
270        commit_fixture(|_, commit| {
271            assert_eq!(
272                commit.author().name().unwrap(),
273                EXPECTED_ACTOR_NAME.to_string()
274            );
275            assert_eq!(
276                commit.author().email().unwrap(),
277                EXPECTED_ACTOR_EMAIL.to_string()
278            );
279        });
280    }
281
282    #[test]
283    fn test_co_authors() {
284        commit_fixture(|_, commit| {
285            for co_auth in commit.co_authors() {
286                assert!(co_auth.is_ok());
287            }
288        });
289    }
290
291    #[test]
292    fn test_committer() {
293        commit_fixture(|_, commit| {
294            assert_eq!(
295                commit.committer().name().unwrap(),
296                EXPECTED_ACTOR_NAME.to_string()
297            );
298            assert_eq!(
299                commit.committer().email().unwrap(),
300                EXPECTED_ACTOR_EMAIL.to_string()
301            );
302        });
303    }
304
305    #[test]
306    fn test_parents() {
307        commit_fixture(|_, commit| {
308            assert_eq!(commit.parents().collect::<Vec<String>>().len(), 1);
309        });
310    }
311
312    #[test]
313    fn test_is_merge() {
314        commit_fixture(|_, commit| {
315            assert!(!commit.is_merge());
316        });
317    }
318
319    #[test]
320    fn test_insertions() {
321        commit_fixture(|_, commit| {
322            assert_eq!(commit.insertions().unwrap(), 1);
323        });
324    }
325
326    #[test]
327    fn test_deletions() {
328        commit_fixture(|_, commit| {
329            assert_eq!(commit.deletions().unwrap(), 0);
330        });
331    }
332
333    #[test]
334    fn test_lines() {
335        commit_fixture(|_, commit| {
336            assert_eq!(commit.lines().unwrap(), 1);
337        });
338    }
339
340    #[test]
341    fn test_stat() {
342        commit_fixture(|_, commit| {
343            // Won't compile if return type is bad, stat otherwise checked in insertions
344            // and deletions test functions
345            let _: git2::DiffStats = commit
346                .stats()
347                .expect("Failed to construct git2 Stats object");
348        });
349    }
350
351    #[test]
352    fn test_iter_matches() {
353        let haystack = "Co-authored-by: John <john@example.com>";
354        assert_eq!(iter_co_authors(haystack).collect::<Vec<&str>>().len(), 1);
355
356        let haystack = "No matches expected";
357        assert_eq!(iter_co_authors(haystack).collect::<Vec<&str>>().len(), 0);
358    }
359}