1use crate::error::{Error, Result};
4use crate::types::InputConfig;
5use std::path::Path;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum GitHubEvent {
10 PullRequest,
12 PullRequestTarget,
14 Push,
16 MergeGroup,
18 Release,
20 Tag,
22 WorkflowDispatch,
24 WorkflowCall,
26 Schedule,
28 Unknown,
30}
31
32impl GitHubEvent {
33 pub fn from_env() -> Self {
35 match std::env::var("GITHUB_EVENT_NAME").as_deref() {
36 Ok("pull_request") => Self::PullRequest,
37 Ok("pull_request_target") => Self::PullRequestTarget,
38 Ok("push") => Self::Push,
39 Ok("merge_group") => Self::MergeGroup,
40 Ok("release") => Self::Release,
41 Ok("create") | Ok("tag") => Self::Tag,
42 Ok("workflow_dispatch") => Self::WorkflowDispatch,
43 Ok("workflow_call") => Self::WorkflowCall,
44 Ok("schedule") => Self::Schedule,
45 _ => Self::Unknown,
46 }
47 }
48}
49
50pub struct ShaResolver {
52 repo_path: std::path::PathBuf,
53}
54
55impl ShaResolver {
56 pub fn new<P: AsRef<Path>>(repo_path: P) -> Self {
58 Self {
59 repo_path: repo_path.as_ref().to_path_buf(),
60 }
61 }
62
63 pub fn resolve_current_sha(&self, config: &InputConfig<'_>) -> Result<String> {
65 let repo = git2::Repository::open(&self.repo_path)?;
66
67 if let Some(until) = &config.until {
69 return self.commit_before_date(&repo, until.as_ref());
70 }
71
72 if let Some(sha) = &config.sha {
74 return self.validate_sha(&repo, sha.as_ref());
75 }
76
77 if let Ok(pr_head) = std::env::var("GITHUB_HEAD_REF") {
79 if !pr_head.is_empty() {
80 return self.resolve_ref(&repo, &pr_head);
81 }
82 }
83
84 self.resolve_ref(&repo, "HEAD")
86 }
87
88 pub fn resolve_base_sha(&self, config: &InputConfig<'_>) -> Result<String> {
90 let repo = git2::Repository::open(&self.repo_path)?;
91
92 if let Some(base_sha) = &config.base_sha {
94 return self.validate_sha(&repo, base_sha.as_ref());
95 }
96
97 if let Some(since) = &config.since {
99 return self.commit_before_date(&repo, since.as_ref());
100 }
101
102 if let Ok(pr_base) = std::env::var("GITHUB_BASE_REF") {
104 if !pr_base.is_empty() {
105 return self.resolve_ref(&repo, &pr_base);
106 }
107 }
108
109 self.resolve_ref(&repo, "HEAD^")
111 .or_else(|_| Ok(Self::empty_tree_sha().to_string()))
112 }
113
114 fn resolve_ref(&self, repo: &git2::Repository, reference: &str) -> Result<String> {
116 if let Ok(oid) = git2::Oid::from_str(reference) {
118 if repo.find_object(oid, None).is_ok() {
120 return Ok(oid.to_string());
121 }
122 }
123
124 let resolved = repo.revparse_single(reference).map_err(|e| {
126 Error::Git(format!(
127 "Failed to resolve reference '{}': {}",
128 reference, e
129 ))
130 })?;
131
132 Ok(resolved.id().to_string())
133 }
134
135 fn validate_sha(&self, repo: &git2::Repository, sha: &str) -> Result<String> {
137 let oid = git2::Oid::from_str(sha)
138 .map_err(|e| Error::Git(format!("Invalid SHA '{}': {}", sha, e)))?;
139
140 repo.find_object(oid, None)
142 .map_err(|e| Error::Git(format!("SHA '{}' not found in repository: {}", sha, e)))?;
143
144 Ok(oid.to_string())
145 }
146
147 fn commit_before_date(&self, repo: &git2::Repository, date_str: &str) -> Result<String> {
149 let target_time = self.parse_date(date_str)?;
151
152 let mut revwalk = repo.revwalk()?;
154 revwalk.push_head()?;
155 revwalk.set_sorting(git2::Sort::TIME)?;
156
157 for oid in revwalk {
158 let oid = oid?;
159 let commit = repo.find_commit(oid)?;
160 let commit_time = commit.time().seconds();
161
162 if commit_time <= target_time {
163 return Ok(oid.to_string());
164 }
165 }
166
167 Err(Error::Git(format!(
168 "No commits found before date '{}'",
169 date_str
170 )))
171 }
172
173 fn parse_date(&self, date_str: &str) -> Result<i64> {
175 let output = std::process::Command::new("git")
178 .args([
179 "log",
180 "-1",
181 "--format=%ct",
182 &format!("--before={}", date_str),
183 ])
184 .current_dir(&self.repo_path)
185 .output()
186 .map_err(|e| Error::Git(format!("Failed to parse date: {}", e)))?;
187
188 if !output.status.success() {
189 return Err(Error::Git(format!("Failed to parse date '{}'", date_str)));
190 }
191
192 let timestamp_str = String::from_utf8_lossy(&output.stdout);
193 timestamp_str
194 .trim()
195 .parse::<i64>()
196 .map_err(|e| Error::Git(format!("Failed to parse timestamp from git: {}", e)))
197 }
198
199 pub fn merge_base(&self, commit1: &str, commit2: &str) -> Result<String> {
201 let repo = git2::Repository::open(&self.repo_path)?;
202
203 let oid1 = git2::Oid::from_str(commit1)
204 .map_err(|e| Error::Git(format!("Invalid SHA '{}': {}", commit1, e)))?;
205 let oid2 = git2::Oid::from_str(commit2)
206 .map_err(|e| Error::Git(format!("Invalid SHA '{}': {}", commit2, e)))?;
207
208 let merge_base = repo
209 .merge_base(oid1, oid2)
210 .map_err(|e| Error::Git(format!("Failed to find merge base: {}", e)))?;
211
212 Ok(merge_base.to_string())
213 }
214
215 pub fn is_initial_commit(&self, sha: &str) -> Result<bool> {
217 let repo = git2::Repository::open(&self.repo_path)?;
218
219 let oid = git2::Oid::from_str(sha)
220 .map_err(|e| Error::Git(format!("Invalid SHA '{}': {}", sha, e)))?;
221
222 let commit = repo.find_commit(oid)?;
223 Ok(commit.parent_count() == 0)
224 }
225
226 pub fn empty_tree_sha() -> &'static str {
228 "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
230 }
231
232 pub fn resolve_event_aware(&self, config: &InputConfig<'_>) -> Result<(String, String)> {
239 if config.base_sha.is_some() && config.sha.is_some() {
241 let base = self.resolve_base_sha(config)?;
242 let head = self.resolve_current_sha(config)?;
243 return Ok((base, head));
244 }
245
246 if config.since.is_some() || config.until.is_some() {
248 let base = self.resolve_base_sha(config)?;
249 let head = self.resolve_current_sha(config)?;
250 return Ok((base, head));
251 }
252
253 let event = GitHubEvent::from_env();
254
255 match event {
256 GitHubEvent::PullRequest | GitHubEvent::PullRequestTarget => {
257 let base = if config.base_sha.is_some() {
259 self.resolve_base_sha(config)?
260 } else if let Ok(base_ref) = std::env::var("GITHUB_BASE_REF") {
261 if !base_ref.is_empty() {
262 let repo = git2::Repository::open(&self.repo_path)?;
263 self.resolve_ref(&repo, &format!("origin/{}", base_ref))?
264 } else {
265 self.resolve_base_sha(config)?
266 }
267 } else {
268 self.resolve_base_sha(config)?
269 };
270 let head = self.resolve_current_sha(config)?;
271 Ok((base, head))
272 }
273
274 GitHubEvent::Push => {
275 let base = if config.base_sha.is_some() {
277 self.resolve_base_sha(config)?
278 } else if let Ok(event_path) = std::env::var("GITHUB_EVENT_PATH") {
279 self.resolve_push_base_from_event(&event_path)?
280 } else {
281 self.resolve_base_sha(config)?
282 };
283 let head = self.resolve_current_sha(config)?;
284 Ok((base, head))
285 }
286
287 GitHubEvent::MergeGroup => {
288 let head = self.resolve_current_sha(config)?;
290 let repo = git2::Repository::open(&self.repo_path)?;
291 let oid = git2::Oid::from_str(&head)?;
293 let commit = repo.find_commit(oid)?;
294 if commit.parent_count() > 0 {
295 let parent = commit.parent(0)?;
296 Ok((parent.id().to_string(), head))
297 } else {
298 Ok((Self::empty_tree_sha().to_string(), head))
299 }
300 }
301
302 GitHubEvent::Release | GitHubEvent::Tag => {
303 if config.tags_pattern.is_some() {
305 if let Ok(prev_tag) = self.find_previous_tag(config) {
306 let head = self.resolve_current_sha(config)?;
307 return Ok((prev_tag, head));
308 }
309 }
310 let base = self.resolve_base_sha(config)?;
312 let head = self.resolve_current_sha(config)?;
313 Ok((base, head))
314 }
315
316 GitHubEvent::WorkflowDispatch | GitHubEvent::WorkflowCall | GitHubEvent::Schedule => {
317 let base = self.resolve_base_sha(config)?;
319 let head = self.resolve_current_sha(config)?;
320 Ok((base, head))
321 }
322
323 GitHubEvent::Unknown => {
324 let base = self.resolve_base_sha(config)?;
326 let head = self.resolve_current_sha(config)?;
327 Ok((base, head))
328 }
329 }
330 }
331
332 fn resolve_push_base_from_event(&self, event_path: &str) -> Result<String> {
334 let content = std::fs::read_to_string(event_path)
335 .map_err(|e| Error::EventParse(format!("Failed to read event file: {}", e)))?;
336
337 let event: serde_json::Value = serde_json::from_str(&content)
338 .map_err(|e| Error::EventParse(format!("Failed to parse event JSON: {}", e)))?;
339
340 if let Some(before) = event.get("before").and_then(|v| v.as_str()) {
341 if before == "0000000000000000000000000000000000000000" {
343 let repo = git2::Repository::open(&self.repo_path)?;
345 for default_ref in &["origin/main", "origin/master"] {
346 if let Ok(target) = self.resolve_ref(&repo, default_ref) {
347 let head = self.resolve_ref(&repo, "HEAD")?;
348 if let Ok(merge_base) = self.merge_base(&head, &target) {
349 return Ok(merge_base);
350 }
351 }
352 }
353 return Ok(Self::empty_tree_sha().to_string());
355 }
356
357 let repo = git2::Repository::open(&self.repo_path)?;
359 match self.validate_sha(&repo, before) {
360 Ok(sha) => return Ok(sha),
361 Err(_) => {
362 return self
365 .resolve_ref(&repo, "HEAD^")
366 .or_else(|_| Ok(Self::empty_tree_sha().to_string()));
367 }
368 }
369 }
370
371 let repo = git2::Repository::open(&self.repo_path)?;
373 self.resolve_ref(&repo, "HEAD^")
374 .or_else(|_| Ok(Self::empty_tree_sha().to_string()))
375 }
376
377 pub fn find_previous_tag(&self, config: &InputConfig<'_>) -> Result<String> {
382 let repo = git2::Repository::open(&self.repo_path)?;
383
384 let pattern = config.tags_pattern.as_ref().ok_or_else(|| {
385 Error::Config("tags_pattern is required for tag comparison".to_string())
386 })?;
387
388 let include_glob = globset::Glob::new(pattern)
390 .map_err(|e| Error::Pattern(format!("Invalid tags_pattern '{}': {}", pattern, e)))?
391 .compile_matcher();
392
393 let exclude_matcher = config
394 .tags_ignore_pattern
395 .as_ref()
396 .map(|p| {
397 globset::Glob::new(p)
398 .map_err(|e| {
399 Error::Pattern(format!("Invalid tags_ignore_pattern '{}': {}", p, e))
400 })
401 .map(|g| g.compile_matcher())
402 })
403 .transpose()?;
404
405 let head_oid = repo.revparse_single("HEAD")?.id();
407
408 let mut matching_tags: Vec<(String, i64)> = Vec::new();
410
411 repo.tag_foreach(|oid, name_bytes| {
412 let name = String::from_utf8_lossy(name_bytes);
413 let tag_name = name.strip_prefix("refs/tags/").unwrap_or(&name).to_string();
415
416 if !include_glob.is_match(&tag_name) {
418 return true; }
420
421 if let Some(ref exclude) = exclude_matcher {
423 if exclude.is_match(&tag_name) {
424 return true; }
426 }
427
428 if let Ok(obj) = repo.find_object(oid, None) {
430 let peeled = obj.peel(git2::ObjectType::Commit).ok();
431 if let Some(commit_obj) = peeled {
432 if commit_obj.id() != head_oid {
433 if let Ok(commit) = commit_obj.into_commit() {
434 let time = commit.time().seconds();
435 matching_tags.push((tag_name, time));
436 }
437 }
438 }
439 }
440
441 true })?;
443
444 if matching_tags.is_empty() {
445 return Err(Error::Git(format!(
446 "No previous tags found matching pattern '{}'",
447 pattern
448 )));
449 }
450
451 matching_tags.sort_by_key(|t| std::cmp::Reverse(t.1));
453
454 let tag_name = &matching_tags[0].0;
455 self.resolve_ref(&repo, tag_name)
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use std::fs;
463 use tempfile::TempDir;
464
465 struct EnvSandbox {
471 _guard: std::sync::MutexGuard<'static, ()>,
472 saved: Vec<(&'static str, Option<String>)>,
473 }
474
475 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
476
477 const GITHUB_VARS: &[&str] = &[
478 "GITHUB_EVENT_NAME",
479 "GITHUB_HEAD_REF",
480 "GITHUB_BASE_REF",
481 "GITHUB_REF",
482 "GITHUB_REF_NAME",
483 "GITHUB_SHA",
484 "GITHUB_EVENT_PATH",
485 "GITHUB_EVENT_BEFORE",
486 ];
487
488 impl EnvSandbox {
489 fn new() -> Self {
490 let guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
491 let saved = GITHUB_VARS
492 .iter()
493 .map(|k| (*k, std::env::var(k).ok()))
494 .collect();
495 for k in GITHUB_VARS {
496 std::env::remove_var(k);
497 }
498 Self {
499 _guard: guard,
500 saved,
501 }
502 }
503 }
504
505 impl Drop for EnvSandbox {
506 fn drop(&mut self) {
507 for (k, v) in &self.saved {
508 match v {
509 Some(v) => std::env::set_var(k, v),
510 None => std::env::remove_var(k),
511 }
512 }
513 }
514 }
515
516 fn create_test_repo() -> (TempDir, std::path::PathBuf) {
517 let dir = TempDir::new().unwrap();
518 let repo_path = dir.path().to_path_buf();
519
520 std::process::Command::new("git")
522 .args(["init"])
523 .current_dir(&repo_path)
524 .output()
525 .unwrap();
526
527 std::process::Command::new("git")
528 .args(["config", "user.name", "Test User"])
529 .current_dir(&repo_path)
530 .output()
531 .unwrap();
532
533 std::process::Command::new("git")
534 .args(["config", "user.email", "test@example.com"])
535 .current_dir(&repo_path)
536 .output()
537 .unwrap();
538
539 fs::write(repo_path.join("file1.txt"), "content1").unwrap();
541 std::process::Command::new("git")
542 .args(["add", "."])
543 .current_dir(&repo_path)
544 .output()
545 .unwrap();
546 std::process::Command::new("git")
547 .args(["commit", "-m", "Initial commit"])
548 .current_dir(&repo_path)
549 .output()
550 .unwrap();
551
552 (dir, repo_path)
553 }
554
555 #[test]
556 fn test_resolve_head() {
557 let (_dir, repo_path) = create_test_repo();
558 let resolver = ShaResolver::new(&repo_path);
559
560 let config = InputConfig::default();
561 let sha = resolver.resolve_current_sha(&config).unwrap();
562 assert_eq!(sha.len(), 40); }
564
565 #[test]
566 fn test_validate_sha() {
567 let (_dir, repo_path) = create_test_repo();
568 let resolver = ShaResolver::new(&repo_path);
569
570 let repo = git2::Repository::open(&repo_path).unwrap();
572 let head = repo.head().unwrap();
573 let head_sha = head.target().unwrap().to_string();
574
575 let config = InputConfig {
576 sha: Some(std::borrow::Cow::Owned(head_sha.clone())),
577 ..Default::default()
578 };
579
580 let resolved = resolver.resolve_current_sha(&config).unwrap();
581 assert_eq!(resolved, head_sha);
582 }
583
584 #[test]
585 fn test_resolve_base_sha() {
586 let _env = EnvSandbox::new();
587 let (_dir, repo_path) = create_test_repo();
588 let resolver = ShaResolver::new(&repo_path);
589
590 fs::write(repo_path.join("file2.txt"), "content2").unwrap();
592 std::process::Command::new("git")
593 .args(["add", "."])
594 .current_dir(&repo_path)
595 .output()
596 .unwrap();
597 std::process::Command::new("git")
598 .args(["commit", "-m", "Second commit"])
599 .current_dir(&repo_path)
600 .output()
601 .unwrap();
602
603 let config = InputConfig::default();
604 let base_sha = resolver.resolve_base_sha(&config).unwrap();
605 assert_eq!(base_sha.len(), 40);
606 }
607
608 #[test]
609 fn test_is_initial_commit() {
610 let _env = EnvSandbox::new();
611 let (_dir, repo_path) = create_test_repo();
612 let resolver = ShaResolver::new(&repo_path);
613
614 let config = InputConfig::default();
615 let sha = resolver.resolve_current_sha(&config).unwrap();
616
617 assert!(resolver.is_initial_commit(&sha).unwrap());
619 }
620
621 #[test]
622 fn test_resolve_event_aware_default() {
623 let _env = EnvSandbox::new();
624 let (_dir, repo_path) = create_test_repo();
625
626 fs::write(repo_path.join("file2.txt"), "content2").unwrap();
628 std::process::Command::new("git")
629 .args(["add", "."])
630 .current_dir(&repo_path)
631 .output()
632 .unwrap();
633 std::process::Command::new("git")
634 .args(["commit", "-m", "Second commit"])
635 .current_dir(&repo_path)
636 .output()
637 .unwrap();
638
639 let resolver = ShaResolver::new(&repo_path);
640 let config = InputConfig::default();
641
642 let result = resolver.resolve_event_aware(&config);
644 assert!(result.is_ok());
645
646 let (base, head) = result.unwrap();
647 assert_eq!(base.len(), 40);
648 assert_eq!(head.len(), 40);
649 assert_ne!(base, head);
650 }
651
652 #[test]
653 fn test_resolve_event_aware_explicit_shas() {
654 let _env = EnvSandbox::new();
655 let (_dir, repo_path) = create_test_repo();
656
657 fs::write(repo_path.join("file2.txt"), "content2").unwrap();
659 std::process::Command::new("git")
660 .args(["add", "."])
661 .current_dir(&repo_path)
662 .output()
663 .unwrap();
664 std::process::Command::new("git")
665 .args(["commit", "-m", "Second commit"])
666 .current_dir(&repo_path)
667 .output()
668 .unwrap();
669
670 let resolver = ShaResolver::new(&repo_path);
671
672 let repo = git2::Repository::open(&repo_path).unwrap();
674 let head = repo.revparse_single("HEAD").unwrap().id().to_string();
675 let base = repo.revparse_single("HEAD^").unwrap().id().to_string();
676
677 let config = InputConfig {
678 base_sha: Some(std::borrow::Cow::Owned(base.clone())),
679 sha: Some(std::borrow::Cow::Owned(head.clone())),
680 ..Default::default()
681 };
682
683 let result = resolver.resolve_event_aware(&config).unwrap();
684 assert_eq!(result.0, base);
685 assert_eq!(result.1, head);
686 }
687
688 #[test]
689 fn test_find_previous_tag() {
690 let (_dir, repo_path) = create_test_repo();
691
692 std::process::Command::new("git")
694 .args(["tag", "v0.1.0"])
695 .current_dir(&repo_path)
696 .output()
697 .unwrap();
698
699 fs::write(repo_path.join("file2.txt"), "content2").unwrap();
701 std::process::Command::new("git")
702 .args(["add", "."])
703 .current_dir(&repo_path)
704 .output()
705 .unwrap();
706 std::process::Command::new("git")
707 .args(["commit", "-m", "Second commit"])
708 .current_dir(&repo_path)
709 .output()
710 .unwrap();
711
712 std::process::Command::new("git")
714 .args(["tag", "v0.2.0"])
715 .current_dir(&repo_path)
716 .output()
717 .unwrap();
718
719 fs::write(repo_path.join("file3.txt"), "content3").unwrap();
721 std::process::Command::new("git")
722 .args(["add", "."])
723 .current_dir(&repo_path)
724 .output()
725 .unwrap();
726 std::process::Command::new("git")
727 .args(["commit", "-m", "Third commit"])
728 .current_dir(&repo_path)
729 .output()
730 .unwrap();
731
732 let resolver = ShaResolver::new(&repo_path);
733 let config = InputConfig {
734 tags_pattern: Some(std::borrow::Cow::Borrowed("v*")),
735 ..Default::default()
736 };
737
738 let result = resolver.find_previous_tag(&config);
739 assert!(result.is_ok());
740 let tag_sha = result.unwrap();
741 assert_eq!(tag_sha.len(), 40);
742 }
743
744 #[test]
745 fn test_find_previous_tag_with_ignore() {
746 let (_dir, repo_path) = create_test_repo();
747
748 std::process::Command::new("git")
750 .args(["tag", "v0.1.0-rc1"])
751 .current_dir(&repo_path)
752 .output()
753 .unwrap();
754
755 fs::write(repo_path.join("file2.txt"), "content2").unwrap();
757 std::process::Command::new("git")
758 .args(["add", "."])
759 .current_dir(&repo_path)
760 .output()
761 .unwrap();
762 std::process::Command::new("git")
763 .args(["commit", "-m", "Second commit"])
764 .current_dir(&repo_path)
765 .output()
766 .unwrap();
767
768 fs::write(repo_path.join("file3.txt"), "content3").unwrap();
770 std::process::Command::new("git")
771 .args(["add", "."])
772 .current_dir(&repo_path)
773 .output()
774 .unwrap();
775 std::process::Command::new("git")
776 .args(["commit", "-m", "Third commit"])
777 .current_dir(&repo_path)
778 .output()
779 .unwrap();
780
781 let resolver = ShaResolver::new(&repo_path);
782 let config = InputConfig {
783 tags_pattern: Some(std::borrow::Cow::Borrowed("v*")),
784 tags_ignore_pattern: Some(std::borrow::Cow::Borrowed("*-rc*")),
785 ..Default::default()
786 };
787
788 let result = resolver.find_previous_tag(&config);
790 assert!(result.is_err()); }
792
793 #[test]
794 fn test_merge_base() {
795 let (_dir, repo_path) = create_test_repo();
796
797 let repo = git2::Repository::open(&repo_path).unwrap();
798
799 let head = repo.head().unwrap();
801 let base_sha = head.target().unwrap().to_string();
802
803 std::process::Command::new("git")
805 .args(["checkout", "-b", "feature"])
806 .current_dir(&repo_path)
807 .output()
808 .unwrap();
809
810 fs::write(repo_path.join("feature.txt"), "feature").unwrap();
811 std::process::Command::new("git")
812 .args(["add", "."])
813 .current_dir(&repo_path)
814 .output()
815 .unwrap();
816 std::process::Command::new("git")
817 .args(["commit", "-m", "Feature commit"])
818 .current_dir(&repo_path)
819 .output()
820 .unwrap();
821
822 let resolver = ShaResolver::new(&repo_path);
823 let feature_sha = repo.revparse_single("feature").unwrap().id().to_string();
824
825 let merge_base = resolver.merge_base(&base_sha, &feature_sha).unwrap();
826 assert_eq!(merge_base.len(), 40);
827 assert_eq!(merge_base, base_sha); }
829}