1use regex::Regex;
2use std::sync::LazyLock;
3use std::{cell::OnceCell, str::FromStr};
4
5use crate::{Actor, Error, ModifiedFile, Repository};
6
7fn iter_co_authors(haystack: &str) -> impl Iterator<Item = &str> {
10 const CO_AUTHOR_REGEX: &str = r"(?m)^Co-authored-by: (.*) <(.*?)>$";
11 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
24pub 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 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 pub fn hash(&self) -> String {
43 self.inner.id().to_string()
44 }
45
46 pub fn msg(&self) -> Option<&str> {
48 self.inner.message()
49 }
50
51 pub fn author(&self) -> Actor {
53 Actor::new(self.inner.author())
54 }
55
56 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 pub fn committer(&self) -> Actor {
67 Actor::new(self.inner.committer())
68 }
69
70 pub fn branches(&self) -> Result<impl Iterator<Item = Result<String, Error>>, Error> {
76 self.branch_iterator(None)
77 }
78
79 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 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 pub fn parents(&self) -> impl Iterator<Item = String> {
101 self.inner.parent_ids().map(|id| id.to_string())
102 }
103
104 pub fn is_merge(&self) -> bool {
106 self.inner.parent_count() > 1
107 }
108
109 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 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 pub fn insertions(&self) -> Result<usize, Error> {
126 Ok(self.stats()?.insertions())
127 }
128
129 pub fn deletions(&self) -> Result<usize, Error> {
131 Ok(self.stats()?.deletions())
132 }
133
134 pub fn lines(&self) -> Result<usize, Error> {
136 Ok(self.insertions()? + self.deletions()?)
137 }
138
139 pub fn files(&self) -> Result<usize, Error> {
141 Ok(self.stats()?.files_changed())
142 }
143
144 fn stats(&self) -> Result<git2::DiffStats, Error> {
147 let diff = self.diff()?;
148 diff.stats().map_err(Error::Git)
149 }
150
151 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 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 .diff_tree_to_tree(parent_tree.as_ref(), this_tree.as_ref(), None)
169 .map_err(Error::Git)
170 }
171
172 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 _ => return Err(Error::PathError("Placeholder error".to_string())),
180 })
181 }
182
183 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 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 let oid = match branch.get().target() {
209 Some(v) => v,
210 None => return None,
211 };
212
213 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, 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 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 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}