Skip to main content

stratum/domain/
commit.rs

1use std::{
2    num::NonZeroUsize,
3    path::Path,
4    rc::Rc,
5    str::FromStr,
6    sync::{LazyLock, Mutex},
7};
8
9use lru::LruCache;
10use regex::Regex;
11
12use crate::domain::{CachedDiff, CachedStat, RcDiff, RcStat};
13use crate::{Actor, Error, ModifiedFile, Repository};
14
15const CACHE_KEY: u8 = 0;
16
17/// Iterate all co-author matches in the haystack string formatting the return
18/// string to be formatted as "Name <Email>"
19fn iter_co_authors(haystack: &str) -> impl Iterator<Item = &str> {
20    const CO_AUTHOR_REGEX: &str = r"(?m)^Co-authored-by: (.*) <(.*?)>$";
21    // Regex should always compile hence bare unwrap.
22    static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(CO_AUTHOR_REGEX).unwrap());
23
24    let prefix = "Co-authored-by:";
25    RE.find_iter(haystack).map(move |re_match| {
26        re_match
27            .as_str()
28            .strip_prefix(prefix)
29            .unwrap_or_default()
30            .trim()
31    })
32}
33
34/// A singular git commit for the repository being inspected
35pub struct Commit<'repo> {
36    inner: git2::Commit<'repo>,
37    ctx: &'repo Repository,
38    diff_cache: Mutex<CachedDiff<'repo>>,
39    stat_cache: Mutex<CachedStat>,
40}
41
42impl<'repo> Commit<'repo> {
43    /// Instantiate a new Commit object from a git2 commit
44    pub fn new(commit: git2::Commit<'repo>, repository: &'repo Repository) -> Self {
45        let diff_cache = Mutex::new(LruCache::new(NonZeroUsize::new(1).unwrap()));
46        let stat_cache = Mutex::new(LruCache::new(NonZeroUsize::new(1).unwrap()));
47
48        Self {
49            inner: commit.to_owned(),
50            ctx: repository,
51            diff_cache,
52            stat_cache,
53        }
54    }
55
56    /// Return the commit hash
57    pub fn hash(&self) -> String {
58        self.inner.id().to_string()
59    }
60
61    /// Return the commit message if it exists
62    pub fn msg(&self) -> Option<&str> {
63        self.inner.message()
64    }
65
66    /// Return the commit author
67    pub fn author(&self) -> Actor {
68        Actor::new(self.inner.author())
69    }
70
71    /// Return the co-authors as listed in the commit message
72    ///
73    /// Lazilly returning as an iterator means the co-authors, if entered more
74    /// than once, will **not** be de-duplicated.
75    pub fn co_authors(&self) -> impl Iterator<Item = Result<Actor, Error>> {
76        let commit_msg = self.msg().unwrap_or_default();
77        iter_co_authors(commit_msg).map(Actor::from_str)
78    }
79
80    /// Return the commit committer
81    pub fn committer(&self) -> Actor {
82        Actor::new(self.inner.committer())
83    }
84
85    /// Iterate all utf-8 branch names that the current commit is contained in
86    ///
87    /// ## Note
88    ///
89    /// Potentially expensive method. Take caution when using within a loop.
90    pub fn branches(&self) -> Result<impl Iterator<Item = Result<String, Error>>, Error> {
91        self.branch_iterator(None)
92    }
93
94    /// Iterate all **local** utf-8 branch names that the current commit is contained in
95    ///
96    /// ## Note
97    ///
98    /// Potentially expensive method. Take caution when using within a loop.
99    pub fn local_branches(&self) -> Result<impl Iterator<Item = Result<String, Error>>, Error> {
100        let flag = Some(git2::BranchType::Local);
101        self.branch_iterator(flag)
102    }
103
104    /// Iterate all **remote** utf-8 branch names that the current commit is contained in
105    ///
106    /// ## Note
107    ///
108    /// Potentially expensive method. Take caution when using within a loop.
109    pub fn remote_branches(&self) -> Result<impl Iterator<Item = Result<String, Error>>, Error> {
110        let flag = Some(git2::BranchType::Remote);
111        self.branch_iterator(flag)
112    }
113
114    /// Retrun the commit parents as objects
115    pub fn parent_commits(&self) -> impl Iterator<Item = Result<Commit<'_>, Error>> {
116        self.inner.parent_ids().map(|oid| {
117            self.ctx
118                .raw()
119                .find_commit(oid)
120                .map_err(Error::Git)
121                .map(|gitc| Commit::new(gitc, self.ctx))
122        })
123    }
124
125    /// Return the commit parents sha
126    pub fn parents(&self) -> impl Iterator<Item = String> {
127        self.inner.parent_ids().map(|p| p.to_string())
128    }
129
130    /// Return whether the commit is a merge commit
131    pub fn is_merge(&self) -> bool {
132        self.inner.parent_count() > 1
133    }
134
135    /// Checks if the current commit is reachable from "main" or "master"
136    pub fn in_main(&self) -> Result<bool, Error> {
137        let b = self
138            .local_branches()?
139            .collect::<Vec<Result<String, Error>>>();
140        Ok(b.contains(&Ok("main".to_string())) || b.contains(&Ok("master".to_string())))
141    }
142
143    /// Return an iterator over the modified files that belong to a commit
144    pub fn mod_files(&self) -> Result<impl Iterator<Item = ModifiedFile<'_>>, Error> {
145        let diff = self.diff()?;
146        Ok((0..diff.deltas().len()).map(move |n| ModifiedFile::new(Rc::clone(&diff), n)))
147    }
148
149    /// The number of insertions in the commit
150    pub fn insertions(&self) -> Result<usize, Error> {
151        Ok(self.stats()?.insertions())
152    }
153
154    /// The number of deletions in the commit
155    pub fn deletions(&self) -> Result<usize, Error> {
156        Ok(self.stats()?.deletions())
157    }
158
159    /// The total number of lines modified in the commit
160    pub fn lines(&self) -> Result<usize, Error> {
161        Ok(self.insertions()? + self.deletions()?)
162    }
163
164    /// The number of files modified in the commit
165    pub fn files(&self) -> Result<usize, Error> {
166        Ok(self.stats()?.files_changed())
167    }
168
169    /// Return the project path that the commit belongs to
170    pub fn project_path(&self) -> &Path {
171        let git_folder = self.ctx.raw().path();
172        // Parent dir should always be infallible
173        git_folder.parent().unwrap()
174    }
175
176    /// Return the project name based on the project path
177    pub fn project_name(&self) -> Option<&str> {
178        self.project_path().file_name().and_then(|s| s.to_str())
179    }
180
181    /// Return the git2 Stats from the commits diff
182    fn stats(&self) -> Result<RcStat, Error> {
183        let diff = self.diff()?;
184
185        let mut guard = self.stat_cache.lock().map_err(|_| Error::PoisonedCache)?;
186        let data = guard.try_get_or_insert(CACHE_KEY, || {
187            let stats = diff.stats().map_err(Error::Git)?;
188            Ok::<RcStat, Error>(Rc::new(stats))
189        })?;
190
191        Ok(Rc::clone(data))
192    }
193
194    /// Return the git diff for the current commit within the context of a
195    /// repository.
196    fn diff(&self) -> Result<RcDiff<'repo>, Error> {
197        let mut guard = self.diff_cache.lock().map_err(|_| Error::PoisonedCache)?;
198        let data = guard.try_get_or_insert(CACHE_KEY, || self.calculate_diff())?;
199
200        Ok(Rc::clone(data))
201    }
202
203    /// Diff the current commit to it's parent(s) adjusting strategy based on the
204    /// number of parents
205    fn calculate_diff(&self) -> Result<RcDiff<'repo>, Error> {
206        let this_tree = self.inner.tree().ok();
207        let parent_tree = self.resolve_parent_tree()?;
208
209        let diff = self
210            .ctx
211            .raw()
212            //TODO: Expose opts?
213            .diff_tree_to_tree(parent_tree.as_ref(), this_tree.as_ref(), None)
214            .map_err(Error::Git)?;
215
216        Ok(Rc::new(diff))
217    }
218
219    /// Resolve to the correct parent tree changing strategies based on number
220    /// of parents.
221    fn resolve_parent_tree(&self) -> Result<Option<git2::Tree<'_>>, Error> {
222        Ok(match self.inner.parent_count() {
223            0 => None,
224            1 => self.inner.parent(0).map_err(Error::Git)?.tree().ok(),
225            //TODO: Resolve merge commit process
226            _ => return Err(Error::PathError("Placeholder error".to_string())),
227        })
228    }
229
230    /// Check if a commit contains a branch
231    ///
232    /// If an error occurs returns false, this is done so any erroring branches
233    /// are filtered out of any dependant processes
234    fn commit_contains_branch(&self, branch: git2::Oid, commit: git2::Oid) -> bool {
235        self.ctx.raw().graph_descendant_of(branch, commit).is_ok()
236    }
237
238    /// Iterate over the specified branch types, None will return all branches
239    fn branch_iterator(
240        &self,
241        bt: Option<git2::BranchType>,
242    ) -> Result<impl Iterator<Item = Result<String, Error>>, Error> {
243        let commit_id = self.inner.id();
244        let branches = self.ctx.raw().branches(bt).map_err(Error::Git)?;
245
246        Ok(branches.filter_map(move |res| {
247            let branch = match res {
248                Ok(v) => v.0,
249                Err(e) => return Some(Err(Error::Git(e))),
250            };
251
252            // If a branch does not have a valid target then filter that
253            // branch out
254            // TODO: Is this excluding a subset of symbolic references
255            let oid = match branch.get().target() {
256                Some(v) => v,
257                None => return None,
258            };
259
260            // Filter out a branch if the commit does NOT contain it
261            if !self.commit_contains_branch(oid, commit_id) {
262                return None;
263            }
264
265            match branch.name() {
266                Ok(Some(name)) => Some(Ok(name.to_string())),
267                Ok(None) => None, // drop non-utf8 branches
268                Err(e) => Some(Err(Error::Git(e))),
269            }
270        }))
271    }
272}
273
274#[cfg(test)]
275mod test {
276    use super::*;
277
278    #[test]
279    fn test_iter_matches() {
280        let haystack = "Co-authored-by: John <john@example.com>";
281        assert_eq!(iter_co_authors(haystack).collect::<Vec<&str>>().len(), 1);
282
283        let haystack = "No matches expected";
284        assert_eq!(iter_co_authors(haystack).collect::<Vec<&str>>().len(), 0);
285    }
286}