1use crate::error::{Result, WtgError};
2use crate::github::ReleaseInfo;
3use git2::{Commit, Oid, Repository, Time};
4use regex::Regex;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, LazyLock, Mutex};
7
8#[derive(Clone)]
9pub struct GitRepo {
10 repo: Arc<Mutex<Repository>>,
11 path: PathBuf,
12}
13
14#[derive(Debug, Clone)]
15pub struct CommitInfo {
16 pub hash: String,
17 pub short_hash: String,
18 pub message: String,
19 pub message_lines: usize,
20 pub author_name: String,
21 pub author_email: String,
22 pub date: String,
23 pub timestamp: i64, }
25
26impl CommitInfo {
27 #[must_use]
29 pub fn date_rfc3339(&self) -> String {
30 use chrono::{DateTime, TimeZone, Utc};
31 let datetime: DateTime<Utc> = Utc.timestamp_opt(self.timestamp, 0).unwrap();
32 datetime.to_rfc3339()
33 }
34}
35
36#[derive(Debug, Clone)]
37pub struct FileInfo {
38 pub path: String,
39 pub last_commit: CommitInfo,
40 pub previous_authors: Vec<(String, String, String)>, }
42
43#[derive(Debug, Clone)]
44pub struct TagInfo {
45 pub name: String,
46 pub commit_hash: String,
47 pub is_semver: bool,
48 pub semver_info: Option<SemverInfo>,
49 pub is_release: bool, pub release_name: Option<String>, pub release_url: Option<String>, pub published_at: Option<String>, }
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct SemverInfo {
57 pub major: u32,
58 pub minor: u32,
59 pub patch: Option<u32>,
60 pub build: Option<u32>,
61 pub pre_release: Option<String>,
62 pub build_metadata: Option<String>,
63}
64
65impl GitRepo {
66 pub fn open() -> Result<Self> {
68 let repo = Repository::discover(".").map_err(|_| WtgError::NotInGitRepo)?;
69 let path = repo.path().to_path_buf();
70 Ok(Self {
71 repo: Arc::new(Mutex::new(repo)),
72 path,
73 })
74 }
75
76 pub fn from_path(path: &Path) -> Result<Self> {
78 let repo = Repository::open(path).map_err(|_| WtgError::NotInGitRepo)?;
79 let repo_path = repo.path().to_path_buf();
80 Ok(Self {
81 repo: Arc::new(Mutex::new(repo)),
82 path: repo_path,
83 })
84 }
85
86 #[must_use]
88 pub fn path(&self) -> &Path {
89 &self.path
90 }
91
92 fn with_repo<T>(&self, f: impl FnOnce(&Repository) -> T) -> T {
93 let repo = self.repo.lock().expect("git repository mutex poisoned");
94 f(&repo)
95 }
96
97 #[must_use]
99 pub fn find_commit(&self, hash_str: &str) -> Option<CommitInfo> {
100 self.with_repo(|repo| {
101 if let Ok(oid) = Oid::from_str(hash_str)
102 && let Ok(commit) = repo.find_commit(oid)
103 {
104 return Some(Self::commit_to_info(&commit));
105 }
106
107 if hash_str.len() >= 7
108 && let Ok(obj) = repo.revparse_single(hash_str)
109 && let Ok(commit) = obj.peel_to_commit()
110 {
111 return Some(Self::commit_to_info(&commit));
112 }
113
114 None
115 })
116 }
117
118 #[must_use]
120 pub fn find_file(&self, path: &str) -> Option<FileInfo> {
121 self.with_repo(|repo| {
122 let mut revwalk = repo.revwalk().ok()?;
123 revwalk.push_head().ok()?;
124
125 for oid in revwalk {
126 let oid = oid.ok()?;
127 let commit = repo.find_commit(oid).ok()?;
128
129 if commit_touches_file(&commit, path) {
130 let commit_info = Self::commit_to_info(&commit);
131 let previous_authors = Self::get_previous_authors(repo, path, &commit, 4);
132
133 return Some(FileInfo {
134 path: path.to_string(),
135 last_commit: commit_info,
136 previous_authors,
137 });
138 }
139 }
140
141 None
142 })
143 }
144
145 fn get_previous_authors(
147 repo: &Repository,
148 path: &str,
149 last_commit: &Commit,
150 limit: usize,
151 ) -> Vec<(String, String, String)> {
152 let mut authors = Vec::new();
153 let Ok(mut revwalk) = repo.revwalk() else {
154 return authors;
155 };
156
157 if revwalk.push_head().is_err() {
158 return authors;
159 }
160
161 let mut found_last = false;
162
163 for oid in revwalk {
164 if authors.len() >= limit {
165 break;
166 }
167
168 let Ok(oid) = oid else { continue };
169
170 let Ok(commit) = repo.find_commit(oid) else {
171 continue;
172 };
173
174 if commit.id() == last_commit.id() {
175 found_last = true;
176 continue;
177 }
178
179 if !found_last {
180 continue;
181 }
182
183 if commit_touches_file(&commit, path) {
184 authors.push((
185 commit.id().to_string()[..7].to_string(),
186 commit.author().name().unwrap_or("Unknown").to_string(),
187 commit.author().email().unwrap_or("").to_string(),
188 ));
189 }
190 }
191
192 authors
193 }
194
195 #[must_use]
197 pub fn get_tags(&self) -> Vec<TagInfo> {
198 self.get_tags_with_releases(&[])
199 }
200
201 #[must_use]
203 pub fn get_tags_with_releases(&self, github_releases: &[ReleaseInfo]) -> Vec<TagInfo> {
204 let release_map: std::collections::HashMap<String, &ReleaseInfo> = github_releases
205 .iter()
206 .map(|r| (r.tag_name.clone(), r))
207 .collect();
208
209 self.with_repo(|repo| {
210 let mut tags = Vec::new();
211
212 if let Ok(tag_names) = repo.tag_names(None) {
213 for tag_name in tag_names.iter().flatten() {
214 if let Ok(obj) = repo.revparse_single(tag_name)
215 && let Ok(commit) = obj.peel_to_commit()
216 {
217 let semver_info = parse_semver(tag_name);
218 let is_semver = semver_info.is_some();
219
220 let (is_release, release_name, release_url, published_at) = release_map
221 .get(tag_name)
222 .map_or((false, None, None, None), |release| {
223 (
224 true,
225 release.name.clone(),
226 Some(release.url.clone()),
227 release.published_at.clone(),
228 )
229 });
230
231 tags.push(TagInfo {
232 name: tag_name.to_string(),
233 commit_hash: commit.id().to_string(),
234 is_semver,
235 semver_info,
236 is_release,
237 release_name,
238 release_url,
239 published_at,
240 });
241 }
242 }
243 }
244
245 tags
246 })
247 }
248
249 #[must_use]
251 pub fn tags_containing_commit(&self, commit_hash: &str) -> Vec<TagInfo> {
252 let Ok(commit_oid) = Oid::from_str(commit_hash) else {
253 return Vec::new();
254 };
255
256 self.find_tags_containing_commit(commit_oid)
257 .unwrap_or_default()
258 }
259
260 #[must_use]
262 pub fn tag_from_release(&self, release: &ReleaseInfo) -> Option<TagInfo> {
263 self.with_repo(|repo| {
264 let obj = repo.revparse_single(&release.tag_name).ok()?;
265 let commit = obj.peel_to_commit().ok()?;
266 let semver_info = parse_semver(&release.tag_name);
267
268 Some(TagInfo {
269 name: release.tag_name.clone(),
270 commit_hash: commit.id().to_string(),
271 is_semver: semver_info.is_some(),
272 semver_info,
273 is_release: true,
274 release_name: release.name.clone(),
275 release_url: Some(release.url.clone()),
276 published_at: release.published_at.clone(),
277 })
278 })
279 }
280
281 #[must_use]
283 pub fn tag_contains_commit(&self, tag_commit_hash: &str, commit_hash: &str) -> bool {
284 let Ok(tag_oid) = Oid::from_str(tag_commit_hash) else {
285 return false;
286 };
287 let Ok(commit_oid) = Oid::from_str(commit_hash) else {
288 return false;
289 };
290
291 self.is_ancestor(commit_oid, tag_oid)
292 }
293
294 fn find_tags_containing_commit(&self, commit_oid: Oid) -> Option<Vec<TagInfo>> {
298 self.with_repo(|repo| {
299 let target_commit = repo.find_commit(commit_oid).ok()?;
300 let target_timestamp = target_commit.time().seconds();
301
302 let mut containing_tags = Vec::new();
303 let tag_names = repo.tag_names(None).ok()?;
304
305 for tag_name in tag_names.iter().flatten() {
306 if let Ok(obj) = repo.revparse_single(tag_name)
307 && let Ok(commit) = obj.peel_to_commit()
308 {
309 let tag_oid = commit.id();
310
311 if commit.time().seconds() < target_timestamp {
314 continue;
315 }
316
317 if tag_oid == commit_oid
319 || repo
320 .graph_descendant_of(tag_oid, commit_oid)
321 .unwrap_or(false)
322 {
323 let semver_info = parse_semver(tag_name);
324
325 containing_tags.push(TagInfo {
326 name: tag_name.to_string(),
327 commit_hash: tag_oid.to_string(),
328 is_semver: semver_info.is_some(),
329 semver_info,
330 is_release: false,
331 release_name: None,
332 release_url: None,
333 published_at: None,
334 });
335 }
336 }
337 }
338
339 if containing_tags.is_empty() {
340 None
341 } else {
342 Some(containing_tags)
343 }
344 })
345 }
346
347 pub(crate) fn get_commit_timestamp(&self, commit_hash: &str) -> i64 {
349 self.with_repo(|repo| {
350 Oid::from_str(commit_hash)
351 .and_then(|oid| repo.find_commit(oid))
352 .map(|c| c.time().seconds())
353 .unwrap_or(0)
354 })
355 }
356
357 fn is_ancestor(&self, ancestor: Oid, descendant: Oid) -> bool {
359 self.with_repo(|repo| {
360 repo.graph_descendant_of(descendant, ancestor)
361 .unwrap_or(false)
362 })
363 }
364
365 #[must_use]
367 pub fn github_remote(&self) -> Option<(String, String)> {
368 self.with_repo(|repo| {
369 for remote_name in ["origin", "upstream"] {
370 if let Ok(remote) = repo.find_remote(remote_name)
371 && let Some(url) = remote.url()
372 && let Some(github_info) = parse_github_url(url)
373 {
374 return Some(github_info);
375 }
376 }
377
378 if let Ok(remotes) = repo.remotes() {
379 for remote_name in remotes.iter().flatten() {
380 if let Ok(remote) = repo.find_remote(remote_name)
381 && let Some(url) = remote.url()
382 && let Some(github_info) = parse_github_url(url)
383 {
384 return Some(github_info);
385 }
386 }
387 }
388
389 None
390 })
391 }
392
393 fn commit_to_info(commit: &Commit) -> CommitInfo {
395 let message = commit.message().unwrap_or("").to_string();
396 let lines: Vec<&str> = message.lines().collect();
397 let message_lines = lines.len();
398 let time = commit.time();
399
400 CommitInfo {
401 hash: commit.id().to_string(),
402 short_hash: commit.id().to_string()[..7].to_string(),
403 message: (*lines.first().unwrap_or(&"")).to_string(),
404 message_lines,
405 author_name: commit.author().name().unwrap_or("Unknown").to_string(),
406 author_email: commit.author().email().unwrap_or("").to_string(),
407 date: format_git_time(&time),
408 timestamp: time.seconds(),
409 }
410 }
411}
412
413fn commit_touches_file(commit: &Commit, path: &str) -> bool {
415 let Ok(tree) = commit.tree() else {
416 return false;
417 };
418
419 tree.get_path(Path::new(path)).is_ok()
421}
422
423static SEMVER_REGEX: LazyLock<Regex> = LazyLock::new(|| {
431 Regex::new(
432 r"^(?:[a-z]+-)?v?(\d+)\.(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:(?:-([a-zA-Z0-9.-]+))|(?:([a-z]+)(\d+)))?(?:\+(.+))?$"
433 )
434 .expect("Invalid semver regex")
435});
436
437fn parse_semver(tag: &str) -> Option<SemverInfo> {
448 let caps = SEMVER_REGEX.captures(tag)?;
449
450 let major = caps.get(1)?.as_str().parse::<u32>().ok()?;
451 let minor = caps.get(2)?.as_str().parse::<u32>().ok()?;
452 let patch = caps.get(3).and_then(|m| m.as_str().parse::<u32>().ok());
453 let build = caps.get(4).and_then(|m| m.as_str().parse::<u32>().ok());
454
455 let pre_release = caps.get(5).map_or_else(
459 || {
460 caps.get(6).map(|py_pre| {
461 let py_num = caps
462 .get(7)
463 .map_or(String::new(), |m| m.as_str().to_string());
464 format!("{}{}", py_pre.as_str(), py_num)
465 })
466 },
467 |dash_pre| Some(dash_pre.as_str().to_string()),
468 );
469
470 let build_metadata = caps.get(8).map(|m| m.as_str().to_string());
471
472 Some(SemverInfo {
473 major,
474 minor,
475 patch,
476 build,
477 pre_release,
478 build_metadata,
479 })
480}
481
482#[cfg(test)]
484fn is_semver_tag(tag: &str) -> bool {
485 parse_semver(tag).is_some()
486}
487
488fn parse_github_url(url: &str) -> Option<(String, String)> {
490 if url.contains("github.com") {
495 let parts: Vec<&str> = if url.starts_with("git@") {
496 url.split(':').collect()
497 } else {
498 url.split("github.com/").collect()
499 };
500
501 if let Some(path) = parts.last() {
502 let path = path.trim_end_matches(".git");
503 let repo_parts: Vec<&str> = path.split('/').collect();
504 if repo_parts.len() >= 2 {
505 return Some((repo_parts[0].to_string(), repo_parts[1].to_string()));
506 }
507 }
508 }
509
510 None
511}
512
513fn format_git_time(time: &Time) -> String {
515 use chrono::{DateTime, TimeZone, Utc};
516
517 let datetime: DateTime<Utc> = Utc.timestamp_opt(time.seconds(), 0).unwrap();
518 datetime.format("%Y-%m-%d %H:%M:%S").to_string()
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
526 fn test_parse_semver_2_part() {
527 let result = parse_semver("1.0");
528 assert!(result.is_some());
529 let semver = result.unwrap();
530 assert_eq!(semver.major, 1);
531 assert_eq!(semver.minor, 0);
532 assert_eq!(semver.patch, None);
533 assert_eq!(semver.build, None);
534 }
535
536 #[test]
537 fn test_parse_semver_2_part_with_v_prefix() {
538 let result = parse_semver("v2.1");
539 assert!(result.is_some());
540 let semver = result.unwrap();
541 assert_eq!(semver.major, 2);
542 assert_eq!(semver.minor, 1);
543 }
544
545 #[test]
546 fn test_parse_semver_3_part() {
547 let result = parse_semver("1.2.3");
548 assert!(result.is_some());
549 let semver = result.unwrap();
550 assert_eq!(semver.major, 1);
551 assert_eq!(semver.minor, 2);
552 assert_eq!(semver.patch, Some(3));
553 assert_eq!(semver.build, None);
554 }
555
556 #[test]
557 fn test_parse_semver_3_part_with_v_prefix() {
558 let result = parse_semver("v1.2.3");
559 assert!(result.is_some());
560 let semver = result.unwrap();
561 assert_eq!(semver.major, 1);
562 assert_eq!(semver.minor, 2);
563 assert_eq!(semver.patch, Some(3));
564 }
565
566 #[test]
567 fn test_parse_semver_4_part() {
568 let result = parse_semver("1.2.3.4");
569 assert!(result.is_some());
570 let semver = result.unwrap();
571 assert_eq!(semver.major, 1);
572 assert_eq!(semver.minor, 2);
573 assert_eq!(semver.patch, Some(3));
574 assert_eq!(semver.build, Some(4));
575 }
576
577 #[test]
578 fn test_parse_semver_with_pre_release() {
579 let result = parse_semver("1.0.0-alpha");
580 assert!(result.is_some());
581 let semver = result.unwrap();
582 assert_eq!(semver.major, 1);
583 assert_eq!(semver.minor, 0);
584 assert_eq!(semver.patch, Some(0));
585 assert_eq!(semver.pre_release, Some("alpha".to_string()));
586 }
587
588 #[test]
589 fn test_parse_semver_with_pre_release_numeric() {
590 let result = parse_semver("v2.0.0-rc.1");
591 assert!(result.is_some());
592 let semver = result.unwrap();
593 assert_eq!(semver.major, 2);
594 assert_eq!(semver.minor, 0);
595 assert_eq!(semver.patch, Some(0));
596 assert_eq!(semver.pre_release, Some("rc.1".to_string()));
597 }
598
599 #[test]
600 fn test_parse_semver_with_build_metadata() {
601 let result = parse_semver("1.0.0+build.123");
602 assert!(result.is_some());
603 let semver = result.unwrap();
604 assert_eq!(semver.major, 1);
605 assert_eq!(semver.minor, 0);
606 assert_eq!(semver.patch, Some(0));
607 assert_eq!(semver.build_metadata, Some("build.123".to_string()));
608 }
609
610 #[test]
611 fn test_parse_semver_with_pre_release_and_build() {
612 let result = parse_semver("v1.0.0-beta.2+20130313144700");
613 assert!(result.is_some());
614 let semver = result.unwrap();
615 assert_eq!(semver.major, 1);
616 assert_eq!(semver.minor, 0);
617 assert_eq!(semver.patch, Some(0));
618 assert_eq!(semver.pre_release, Some("beta.2".to_string()));
619 assert_eq!(semver.build_metadata, Some("20130313144700".to_string()));
620 }
621
622 #[test]
623 fn test_parse_semver_2_part_with_pre_release() {
624 let result = parse_semver("2.0-alpha");
625 assert!(result.is_some());
626 let semver = result.unwrap();
627 assert_eq!(semver.major, 2);
628 assert_eq!(semver.minor, 0);
629 assert_eq!(semver.patch, None);
630 assert_eq!(semver.pre_release, Some("alpha".to_string()));
631 }
632
633 #[test]
634 fn test_parse_semver_invalid_single_part() {
635 assert!(parse_semver("1").is_none());
636 }
637
638 #[test]
639 fn test_parse_semver_invalid_non_numeric() {
640 assert!(parse_semver("abc.def").is_none());
641 assert!(parse_semver("1.x.3").is_none());
642 }
643
644 #[test]
645 fn test_parse_semver_invalid_too_many_parts() {
646 assert!(parse_semver("1.2.3.4.5").is_none());
647 }
648
649 #[test]
650 fn test_is_semver_tag() {
651 assert!(is_semver_tag("1.0"));
653 assert!(is_semver_tag("v1.0"));
654 assert!(is_semver_tag("1.2.3"));
655 assert!(is_semver_tag("v1.2.3"));
656 assert!(is_semver_tag("1.2.3.4"));
657
658 assert!(is_semver_tag("1.0.0-alpha"));
660 assert!(is_semver_tag("v2.0.0-rc.1"));
661 assert!(is_semver_tag("1.2.3-beta.2"));
662
663 assert!(is_semver_tag("1.2.3a1"));
665 assert!(is_semver_tag("1.2.3b1"));
666 assert!(is_semver_tag("1.2.3rc1"));
667
668 assert!(is_semver_tag("1.0.0+build"));
670
671 assert!(is_semver_tag("py-v1.0.0"));
673 assert!(is_semver_tag("rust-v1.2.3-beta.1"));
674 assert!(is_semver_tag("python-1.2.3b1"));
675
676 assert!(!is_semver_tag("v1"));
678 assert!(!is_semver_tag("abc"));
679 assert!(!is_semver_tag("1.2.3.4.5"));
680 assert!(!is_semver_tag("server-v-1.0.0")); }
682
683 #[test]
684 fn test_parse_semver_with_custom_prefix() {
685 let result = parse_semver("py-v1.0.0-beta.1");
687 assert!(result.is_some());
688 let semver = result.unwrap();
689 assert_eq!(semver.major, 1);
690 assert_eq!(semver.minor, 0);
691 assert_eq!(semver.patch, Some(0));
692 assert_eq!(semver.pre_release, Some("beta.1".to_string()));
693
694 let result = parse_semver("rust-v1.0.0-beta.2");
696 assert!(result.is_some());
697 let semver = result.unwrap();
698 assert_eq!(semver.major, 1);
699 assert_eq!(semver.minor, 0);
700 assert_eq!(semver.patch, Some(0));
701 assert_eq!(semver.pre_release, Some("beta.2".to_string()));
702
703 let result = parse_semver("python-2.1.0");
705 assert!(result.is_some());
706 let semver = result.unwrap();
707 assert_eq!(semver.major, 2);
708 assert_eq!(semver.minor, 1);
709 assert_eq!(semver.patch, Some(0));
710 }
711
712 #[test]
713 fn test_parse_semver_python_style() {
714 let result = parse_semver("1.2.3a1");
716 assert!(result.is_some());
717 let semver = result.unwrap();
718 assert_eq!(semver.major, 1);
719 assert_eq!(semver.minor, 2);
720 assert_eq!(semver.patch, Some(3));
721 assert_eq!(semver.pre_release, Some("a1".to_string()));
722
723 let result = parse_semver("v1.2.3b2");
725 assert!(result.is_some());
726 let semver = result.unwrap();
727 assert_eq!(semver.major, 1);
728 assert_eq!(semver.minor, 2);
729 assert_eq!(semver.patch, Some(3));
730 assert_eq!(semver.pre_release, Some("b2".to_string()));
731
732 let result = parse_semver("2.0.0rc1");
734 assert!(result.is_some());
735 let semver = result.unwrap();
736 assert_eq!(semver.major, 2);
737 assert_eq!(semver.minor, 0);
738 assert_eq!(semver.patch, Some(0));
739 assert_eq!(semver.pre_release, Some("rc1".to_string()));
740
741 let result = parse_semver("py-v1.0.0b1");
743 assert!(result.is_some());
744 let semver = result.unwrap();
745 assert_eq!(semver.major, 1);
746 assert_eq!(semver.minor, 0);
747 assert_eq!(semver.patch, Some(0));
748 assert_eq!(semver.pre_release, Some("b1".to_string()));
749 }
750
751 #[test]
752 fn test_parse_semver_rejects_garbage() {
753 assert!(parse_semver("server-v-config").is_none());
755 assert!(parse_semver("whatever-v-something").is_none());
756
757 assert!(parse_semver("v1").is_none());
759 assert!(parse_semver("1").is_none());
760 assert!(parse_semver("1.2.3.4.5").is_none());
761 assert!(parse_semver("abc.def").is_none());
762 }
763}