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
8fn iter_co_authors(haystack: &str) -> impl Iterator<Item = &str> {
11 const CO_AUTHOR_REGEX: &str = r"(?m)^Co-authored-by: (.*) <(.*?)>$";
12 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
25pub 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 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 pub fn hash(&self) -> String {
44 self.inner.id().to_string()
45 }
46
47 pub fn msg(&self) -> Option<&str> {
49 self.inner.message()
50 }
51
52 pub fn author(&self) -> Actor {
54 Actor::new(self.inner.author())
55 }
56
57 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 pub fn committer(&self) -> Actor {
68 Actor::new(self.inner.committer())
69 }
70
71 pub fn branches(&self) -> Result<impl Iterator<Item = Result<String, Error>>, Error> {
77 self.branch_iterator(None)
78 }
79
80 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 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 pub fn parents(&self) -> impl Iterator<Item = String> {
102 self.inner.parent_ids().map(|id| id.to_string())
103 }
104
105 pub fn is_merge(&self) -> bool {
107 self.inner.parent_count() > 1
108 }
109
110 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 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 pub fn insertions(&self) -> Result<usize, Error> {
127 Ok(self.stats()?.insertions())
128 }
129
130 pub fn deletions(&self) -> Result<usize, Error> {
132 Ok(self.stats()?.deletions())
133 }
134
135 pub fn lines(&self) -> Result<usize, Error> {
137 Ok(self.insertions()? + self.deletions()?)
138 }
139
140 pub fn files(&self) -> Result<usize, Error> {
142 Ok(self.stats()?.files_changed())
143 }
144
145 pub fn project_path(&self) -> &Path {
147 let git_folder = self.ctx.raw().path();
148 git_folder.parent().unwrap()
150 }
151
152 pub fn project_name(&self) -> Option<&str> {
154 self.project_path().file_name().and_then(|s| s.to_str())
155 }
156
157 fn stats(&self) -> Result<git2::DiffStats, Error> {
160 let diff = self.diff()?;
161 diff.stats().map_err(Error::Git)
162 }
163
164 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 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 .diff_tree_to_tree(parent_tree.as_ref(), this_tree.as_ref(), None)
182 .map_err(Error::Git)
183 }
184
185 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 _ => return Err(Error::PathError("Placeholder error".to_string())),
193 })
194 }
195
196 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 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 let oid = match branch.get().target() {
222 Some(v) => v,
223 None => return None,
224 };
225
226 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, 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 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 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}