Skip to main content

lechange_core/git/
sha.rs

1//! SHA resolution with complex fallback chains for GitHub Actions
2
3use crate::error::{Error, Result};
4use crate::types::InputConfig;
5use std::path::Path;
6
7/// GitHub event type for event-aware SHA resolution
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum GitHubEvent {
10    /// Pull request event
11    PullRequest,
12    /// Pull request target event
13    PullRequestTarget,
14    /// Push event
15    Push,
16    /// Merge group event
17    MergeGroup,
18    /// Release event
19    Release,
20    /// Tag event
21    Tag,
22    /// Workflow dispatch event
23    WorkflowDispatch,
24    /// Workflow call event
25    WorkflowCall,
26    /// Scheduled event
27    Schedule,
28    /// Unknown event type
29    Unknown,
30}
31
32impl GitHubEvent {
33    /// Parse from GITHUB_EVENT_NAME environment variable
34    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
50/// SHA resolver for Git references
51pub struct ShaResolver {
52    repo_path: std::path::PathBuf,
53}
54
55impl ShaResolver {
56    /// Create a new SHA resolver for a repository
57    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    /// Resolve the current (head) SHA based on config and environment
64    pub fn resolve_current_sha(&self, config: &InputConfig<'_>) -> Result<String> {
65        let repo = git2::Repository::open(&self.repo_path)?;
66
67        // 1. If `until` date provided: Last commit before date
68        if let Some(until) = &config.until {
69            return self.commit_before_date(&repo, until.as_ref());
70        }
71
72        // 2. If `sha` explicitly provided: Validate and use
73        if let Some(sha) = &config.sha {
74            return self.validate_sha(&repo, sha.as_ref());
75        }
76
77        // 3. If PR context: Use PR head SHA from environment
78        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        // 4. Default: Use HEAD
85        self.resolve_ref(&repo, "HEAD")
86    }
87
88    /// Resolve the base SHA based on config and environment
89    pub fn resolve_base_sha(&self, config: &InputConfig<'_>) -> Result<String> {
90        let repo = git2::Repository::open(&self.repo_path)?;
91
92        // 1. If `base_sha` explicitly provided: Validate and use
93        if let Some(base_sha) = &config.base_sha {
94            return self.validate_sha(&repo, base_sha.as_ref());
95        }
96
97        // 2. If `since` date provided: Last commit before date
98        if let Some(since) = &config.since {
99            return self.commit_before_date(&repo, since.as_ref());
100        }
101
102        // 3. If PR context: Use PR base SHA from environment
103        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        // 4. Default: Use HEAD^ (fall back to empty tree for initial commit)
110        self.resolve_ref(&repo, "HEAD^")
111            .or_else(|_| Ok(Self::empty_tree_sha().to_string()))
112    }
113
114    /// Resolve a reference to a full SHA
115    fn resolve_ref(&self, repo: &git2::Repository, reference: &str) -> Result<String> {
116        // Try as direct OID first
117        if let Ok(oid) = git2::Oid::from_str(reference) {
118            // Verify it exists
119            if repo.find_object(oid, None).is_ok() {
120                return Ok(oid.to_string());
121            }
122        }
123
124        // Try as reference (branch, tag, HEAD, etc.)
125        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    /// Validate that a SHA exists in the repository
136    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        // Verify it exists
141        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    /// Find the last commit before a given date
148    fn commit_before_date(&self, repo: &git2::Repository, date_str: &str) -> Result<String> {
149        // Parse the date string
150        let target_time = self.parse_date(date_str)?;
151
152        // Walk commits from HEAD backwards
153        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    /// Parse a date string to Unix timestamp
174    fn parse_date(&self, date_str: &str) -> Result<i64> {
175        // Try parsing ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS
176        // For now, use git's date parsing via command line
177        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    /// Get merge base between two commits (for three-dot diff)
200    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    /// Check if this is an initial commit (no parent)
216    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    /// Get the empty tree SHA (for comparing against initial commits)
227    pub fn empty_tree_sha() -> &'static str {
228        // Git's well-known empty tree SHA
229        "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
230    }
231
232    /// Event-aware SHA resolution with 8+ decision paths
233    ///
234    /// Resolution priority:
235    /// 1. Explicit config (base_sha/sha) — always wins
236    /// 2. Event-specific logic (PR, Push, MergeGroup, Tag, etc.)
237    /// 3. Default fallback (HEAD^..HEAD)
238    pub fn resolve_event_aware(&self, config: &InputConfig<'_>) -> Result<(String, String)> {
239        // If both SHAs are explicitly configured, use them directly
240        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 date-based, delegate to existing methods
247        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                // PR: use GITHUB_BASE_REF..GITHUB_HEAD_REF (or PR merge commit)
258                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                // Push: use event payload's before..after if available
276                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                // Merge group: GITHUB_SHA is the merge commit
289                let head = self.resolve_current_sha(config)?;
290                let repo = git2::Repository::open(&self.repo_path)?;
291                // For merge groups, compare against the merge base
292                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                // Tag/release: compare against previous tag if configured
304                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                // Fallback: HEAD^..HEAD
311                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                // Manual/scheduled: use explicit config or default HEAD^..HEAD
318                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                // Unknown event or local development: default resolution
325                let base = self.resolve_base_sha(config)?;
326                let head = self.resolve_current_sha(config)?;
327                Ok((base, head))
328            }
329        }
330    }
331
332    /// Extract base SHA from push event payload JSON
333    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            // Check if "before" is the null SHA (new branch push)
342            if before == "0000000000000000000000000000000000000000" {
343                // New branch push: try merge-base with default branch first
344                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                // Truly initial repo — compare against empty tree
354                return Ok(Self::empty_tree_sha().to_string());
355            }
356
357            // Validate the SHA exists in the repo
358            let repo = git2::Repository::open(&self.repo_path)?;
359            match self.validate_sha(&repo, before) {
360                Ok(sha) => return Ok(sha),
361                Err(_) => {
362                    // SHA not in local repo (shallow clone), fall back to HEAD^
363                    // or empty tree for initial commit
364                    return self
365                        .resolve_ref(&repo, "HEAD^")
366                        .or_else(|_| Ok(Self::empty_tree_sha().to_string()));
367                }
368            }
369        }
370
371        // No "before" in event, fall back to HEAD^ or empty tree for initial commit
372        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    /// Find the previous tag matching the configured pattern
378    ///
379    /// Walks tags sorted by commit time, finds the most recent tag before HEAD
380    /// that matches `tags_pattern` and doesn't match `tags_ignore_pattern`.
381    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        // Build glob matchers
389        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        // Get current HEAD for comparison
406        let head_oid = repo.revparse_single("HEAD")?.id();
407
408        // Collect matching tags with their commit times
409        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            // Strip refs/tags/ prefix
414            let tag_name = name.strip_prefix("refs/tags/").unwrap_or(&name).to_string();
415
416            // Check include pattern
417            if !include_glob.is_match(&tag_name) {
418                return true; // Continue iteration
419            }
420
421            // Check exclude pattern
422            if let Some(ref exclude) = exclude_matcher {
423                if exclude.is_match(&tag_name) {
424                    return true; // Continue, this tag is excluded
425                }
426            }
427
428            // Peel to commit to get the time
429            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 // Continue iteration
442        })?;
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        // Sort by time descending, take the most recent
452        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    /// Event-aware resolution reads process-global GITHUB_* env. On a CI
466    /// runner those are REAL (e.g. GITHUB_HEAD_REF names the PR branch, which
467    /// does not exist in the fixture repos), so tests that exercise env-driven
468    /// paths must sandbox them: serialized by a lock, cleared for the test,
469    /// restored on drop.
470    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        // Initialize git repo
521        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        // Create initial commit
540        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); // SHA is 40 hex characters
563    }
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        // Get HEAD SHA
571        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        // Create second commit
591        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        // This should be the initial commit
618        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        // Create second commit
627        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        // With no event env vars, should fall back to HEAD^..HEAD
643        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        // Create second commit
658        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        // Get HEAD and HEAD^ SHAs
673        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        // Tag the initial commit
693        std::process::Command::new("git")
694            .args(["tag", "v0.1.0"])
695            .current_dir(&repo_path)
696            .output()
697            .unwrap();
698
699        // Create second commit
700        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        // Tag the second commit
713        std::process::Command::new("git")
714            .args(["tag", "v0.2.0"])
715            .current_dir(&repo_path)
716            .output()
717            .unwrap();
718
719        // Create third commit (HEAD)
720        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        // Tag initial commit
749        std::process::Command::new("git")
750            .args(["tag", "v0.1.0-rc1"])
751            .current_dir(&repo_path)
752            .output()
753            .unwrap();
754
755        // Create second commit + tag
756        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        // Create third commit (HEAD)
769        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        // Only v0.1.0-rc1 exists but it's excluded by ignore pattern
789        let result = resolver.find_previous_tag(&config);
790        assert!(result.is_err()); // No matching tags
791    }
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        // Get the current branch name instead of assuming "main"
800        let head = repo.head().unwrap();
801        let base_sha = head.target().unwrap().to_string();
802
803        // Create a feature branch
804        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); // merge base should be the initial commit
828    }
829}