Skip to main content

stratum/domain/
commit.rs

1use std::cell::OnceCell;
2
3use crate::{Actor, Error, ModifiedFile, Repository};
4
5/// A singular git commit for the repository being inspected
6pub struct Commit<'repo> {
7    inner: git2::Commit<'repo>,
8    ctx: &'repo Repository,
9    cache: OnceCell<git2::Diff<'repo>>,
10}
11
12impl<'repo> Commit<'repo> {
13    /// Instantiate a new Commit object from a git2 commit
14    pub fn new(commit: git2::Commit<'repo>, repository: &'repo Repository) -> Self {
15        Self {
16            inner: commit.to_owned(),
17            ctx: repository,
18            cache: OnceCell::new(),
19        }
20    }
21
22    /// Return the commit hash
23    pub fn hash(&self) -> String {
24        self.inner.id().to_string()
25    }
26
27    /// Return the commit message if it exists
28    pub fn msg(&self) -> Option<String> {
29        self.inner.message().map(|s| s.to_string())
30    }
31
32    /// Return the commit author
33    pub fn author(&self) -> Actor {
34        Actor::new(self.inner.author())
35    }
36
37    /// Return the commit committer
38    pub fn committer(&self) -> Actor {
39        Actor::new(self.inner.committer())
40    }
41
42    /// Retrun the hashes of all commit parents
43    pub fn parents(&self) -> impl Iterator<Item = String> {
44        self.inner.parent_ids().map(|id| id.to_string())
45    }
46
47    /// Return whether the commit is a merge commit
48    pub fn is_merge(&self) -> bool {
49        self.inner.parent_count() > 1
50    }
51
52    /// Return an iterator over the modified files that belong to a commit
53    pub fn mod_files(&self) -> Result<impl Iterator<Item = ModifiedFile<'_>>, Error> {
54        let diff = self.diff()?;
55
56        Ok((0..diff.deltas().len()).map(move |n| ModifiedFile::new(diff, n)))
57    }
58
59    /// The number of insertions in the commit
60    pub fn insertions(&self) -> Result<usize, Error> {
61        Ok(self.stats()?.insertions())
62    }
63
64    /// The number of deletions in the commit
65    pub fn deletions(&self) -> Result<usize, Error> {
66        Ok(self.stats()?.deletions())
67    }
68
69    /// The total number of lines modified in the commit
70    pub fn lines(&self) -> Result<usize, Error> {
71        Ok(self.insertions()? + self.deletions()?)
72    }
73
74    /// The number of files modified in the commit
75    pub fn files(&self) -> Result<usize, Error> {
76        Ok(self.stats()?.files_changed())
77    }
78
79    //TODO: Should stats also be cached?
80    /// Return the git2 Stats from the commits diff
81    fn stats(&self) -> Result<git2::DiffStats, Error> {
82        let diff = self.diff()?;
83        diff.stats().map_err(Error::Git)
84    }
85
86    /// Return the git diff for the current commit within the context of a
87    /// repository.
88    //TODO: https://github.com/segfault-merchant/git-stratum/issues/32
89    fn diff(&self) -> Result<&git2::Diff<'repo>, Error> {
90        let diff = self.calculate_diff()?;
91        Ok(self.cache.get_or_init(|| diff))
92    }
93
94    /// Diff the current commit to it's parent(s) adjusting strategy based on the
95    /// number of parents
96    fn calculate_diff(&self) -> Result<git2::Diff<'repo>, Error> {
97        let this_tree = self.inner.tree().ok();
98        let parent_tree = self.resolve_parent_tree()?;
99
100        self.ctx
101            .raw()
102            //TODO: Expose opts?
103            .diff_tree_to_tree(parent_tree.as_ref(), this_tree.as_ref(), None)
104            .map_err(Error::Git)
105    }
106
107    /// Resolve to the correct parent tree changing strategies based on number
108    /// of parents.
109    fn resolve_parent_tree(&self) -> Result<Option<git2::Tree<'_>>, Error> {
110        Ok(match self.inner.parent_count() {
111            0 => None,
112            1 => self.inner.parent(0).map_err(Error::Git)?.tree().ok(),
113            //TODO: Resolve merge commit process
114            _ => return Err(Error::PathError("Placeholder error".to_string())),
115        })
116    }
117}
118
119#[cfg(test)]
120mod test {
121    use super::*;
122    use crate::{
123        Local, Repository,
124        common::{EXPECTED_ACTOR_EMAIL, EXPECTED_ACTOR_NAME, EXPECTED_MSG, init_repo},
125    };
126
127    fn commit_fixture<F, R>(f: F) -> R
128    where
129        F: FnOnce(&Repository<Local>, &Commit) -> R,
130    {
131        let repo = init_repo();
132
133        let repo = Repository::<Local>::from_repository(repo);
134        let commit = repo.head().expect("Failed to get HEAD");
135
136        f(&repo, &commit)
137    }
138
139    #[test]
140    fn test_msg() {
141        commit_fixture(|_, commit| {
142            // use mfile here
143            assert_eq!(commit.msg(), Some(EXPECTED_MSG.to_owned()));
144        });
145    }
146
147    #[test]
148    fn test_author() {
149        commit_fixture(|_, commit| {
150            assert_eq!(
151                commit.author().name().unwrap(),
152                EXPECTED_ACTOR_NAME.to_string()
153            );
154            assert_eq!(
155                commit.author().email().unwrap(),
156                EXPECTED_ACTOR_EMAIL.to_string()
157            );
158        });
159    }
160
161    #[test]
162    fn test_committer() {
163        commit_fixture(|_, commit| {
164            assert_eq!(
165                commit.committer().name().unwrap(),
166                EXPECTED_ACTOR_NAME.to_string()
167            );
168            assert_eq!(
169                commit.committer().email().unwrap(),
170                EXPECTED_ACTOR_EMAIL.to_string()
171            );
172        });
173    }
174
175    #[test]
176    fn test_parents() {
177        commit_fixture(|_, commit| {
178            assert_eq!(commit.parents().collect::<Vec<String>>().len(), 1);
179        });
180    }
181
182    #[test]
183    fn test_is_merge() {
184        commit_fixture(|_, commit| {
185            assert!(!commit.is_merge());
186        });
187    }
188
189    #[test]
190    fn test_insertions() {
191        commit_fixture(|_, commit| {
192            assert_eq!(commit.insertions().unwrap(), 1);
193        });
194    }
195
196    #[test]
197    fn test_deletions() {
198        commit_fixture(|_, commit| {
199            assert_eq!(commit.deletions().unwrap(), 0);
200        });
201    }
202
203    #[test]
204    fn test_lines() {
205        commit_fixture(|_, commit| {
206            assert_eq!(commit.lines().unwrap(), 1);
207        });
208    }
209
210    #[test]
211    fn test_stat() {
212        commit_fixture(|_, commit| {
213            // Won't compile if return type is bad, stat otherwise checked in insertions
214            // and deletions test functions
215            let _: git2::DiffStats = commit
216                .stats()
217                .expect("Failed to construct git2 Stats object");
218        });
219    }
220}