use crate::error::{Error, Result};
use crate::types::InputConfig;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitHubEvent {
PullRequest,
PullRequestTarget,
Push,
MergeGroup,
Release,
Tag,
WorkflowDispatch,
WorkflowCall,
Schedule,
Unknown,
}
impl GitHubEvent {
pub fn from_env() -> Self {
match std::env::var("GITHUB_EVENT_NAME").as_deref() {
Ok("pull_request") => Self::PullRequest,
Ok("pull_request_target") => Self::PullRequestTarget,
Ok("push") => Self::Push,
Ok("merge_group") => Self::MergeGroup,
Ok("release") => Self::Release,
Ok("create") | Ok("tag") => Self::Tag,
Ok("workflow_dispatch") => Self::WorkflowDispatch,
Ok("workflow_call") => Self::WorkflowCall,
Ok("schedule") => Self::Schedule,
_ => Self::Unknown,
}
}
}
pub struct ShaResolver {
repo_path: std::path::PathBuf,
}
impl ShaResolver {
pub fn new<P: AsRef<Path>>(repo_path: P) -> Self {
Self {
repo_path: repo_path.as_ref().to_path_buf(),
}
}
pub fn resolve_current_sha(&self, config: &InputConfig<'_>) -> Result<String> {
let repo = git2::Repository::open(&self.repo_path)?;
if let Some(until) = &config.until {
return self.commit_before_date(&repo, until.as_ref());
}
if let Some(sha) = &config.sha {
return self.validate_sha(&repo, sha.as_ref());
}
if let Ok(pr_head) = std::env::var("GITHUB_HEAD_REF") {
if !pr_head.is_empty() {
return self.resolve_ref(&repo, &pr_head);
}
}
self.resolve_ref(&repo, "HEAD")
}
pub fn resolve_base_sha(&self, config: &InputConfig<'_>) -> Result<String> {
let repo = git2::Repository::open(&self.repo_path)?;
if let Some(base_sha) = &config.base_sha {
return self.validate_sha(&repo, base_sha.as_ref());
}
if let Some(since) = &config.since {
return self.commit_before_date(&repo, since.as_ref());
}
if let Ok(pr_base) = std::env::var("GITHUB_BASE_REF") {
if !pr_base.is_empty() {
return self.resolve_ref(&repo, &pr_base);
}
}
self.resolve_ref(&repo, "HEAD^")
.or_else(|_| Ok(Self::empty_tree_sha().to_string()))
}
fn resolve_ref(&self, repo: &git2::Repository, reference: &str) -> Result<String> {
if let Ok(oid) = git2::Oid::from_str(reference) {
if repo.find_object(oid, None).is_ok() {
return Ok(oid.to_string());
}
}
let resolved = repo.revparse_single(reference).map_err(|e| {
Error::Git(format!(
"Failed to resolve reference '{}': {}",
reference, e
))
})?;
Ok(resolved.id().to_string())
}
fn validate_sha(&self, repo: &git2::Repository, sha: &str) -> Result<String> {
let oid = git2::Oid::from_str(sha)
.map_err(|e| Error::Git(format!("Invalid SHA '{}': {}", sha, e)))?;
repo.find_object(oid, None)
.map_err(|e| Error::Git(format!("SHA '{}' not found in repository: {}", sha, e)))?;
Ok(oid.to_string())
}
fn commit_before_date(&self, repo: &git2::Repository, date_str: &str) -> Result<String> {
let target_time = self.parse_date(date_str)?;
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
revwalk.set_sorting(git2::Sort::TIME)?;
for oid in revwalk {
let oid = oid?;
let commit = repo.find_commit(oid)?;
let commit_time = commit.time().seconds();
if commit_time <= target_time {
return Ok(oid.to_string());
}
}
Err(Error::Git(format!(
"No commits found before date '{}'",
date_str
)))
}
fn parse_date(&self, date_str: &str) -> Result<i64> {
let output = std::process::Command::new("git")
.args([
"log",
"-1",
"--format=%ct",
&format!("--before={}", date_str),
])
.current_dir(&self.repo_path)
.output()
.map_err(|e| Error::Git(format!("Failed to parse date: {}", e)))?;
if !output.status.success() {
return Err(Error::Git(format!("Failed to parse date '{}'", date_str)));
}
let timestamp_str = String::from_utf8_lossy(&output.stdout);
timestamp_str
.trim()
.parse::<i64>()
.map_err(|e| Error::Git(format!("Failed to parse timestamp from git: {}", e)))
}
pub fn merge_base(&self, commit1: &str, commit2: &str) -> Result<String> {
let repo = git2::Repository::open(&self.repo_path)?;
let oid1 = git2::Oid::from_str(commit1)
.map_err(|e| Error::Git(format!("Invalid SHA '{}': {}", commit1, e)))?;
let oid2 = git2::Oid::from_str(commit2)
.map_err(|e| Error::Git(format!("Invalid SHA '{}': {}", commit2, e)))?;
let merge_base = repo
.merge_base(oid1, oid2)
.map_err(|e| Error::Git(format!("Failed to find merge base: {}", e)))?;
Ok(merge_base.to_string())
}
pub fn is_initial_commit(&self, sha: &str) -> Result<bool> {
let repo = git2::Repository::open(&self.repo_path)?;
let oid = git2::Oid::from_str(sha)
.map_err(|e| Error::Git(format!("Invalid SHA '{}': {}", sha, e)))?;
let commit = repo.find_commit(oid)?;
Ok(commit.parent_count() == 0)
}
pub fn empty_tree_sha() -> &'static str {
"4b825dc642cb6eb9a060e54bf8d69288fbee4904"
}
pub fn resolve_event_aware(&self, config: &InputConfig<'_>) -> Result<(String, String)> {
if config.base_sha.is_some() && config.sha.is_some() {
let base = self.resolve_base_sha(config)?;
let head = self.resolve_current_sha(config)?;
return Ok((base, head));
}
if config.since.is_some() || config.until.is_some() {
let base = self.resolve_base_sha(config)?;
let head = self.resolve_current_sha(config)?;
return Ok((base, head));
}
let event = GitHubEvent::from_env();
match event {
GitHubEvent::PullRequest | GitHubEvent::PullRequestTarget => {
let base = if config.base_sha.is_some() {
self.resolve_base_sha(config)?
} else if let Ok(base_ref) = std::env::var("GITHUB_BASE_REF") {
if !base_ref.is_empty() {
let repo = git2::Repository::open(&self.repo_path)?;
self.resolve_ref(&repo, &format!("origin/{}", base_ref))?
} else {
self.resolve_base_sha(config)?
}
} else {
self.resolve_base_sha(config)?
};
let head = self.resolve_current_sha(config)?;
Ok((base, head))
}
GitHubEvent::Push => {
let base = if config.base_sha.is_some() {
self.resolve_base_sha(config)?
} else if let Ok(event_path) = std::env::var("GITHUB_EVENT_PATH") {
self.resolve_push_base_from_event(&event_path)?
} else {
self.resolve_base_sha(config)?
};
let head = self.resolve_current_sha(config)?;
Ok((base, head))
}
GitHubEvent::MergeGroup => {
let head = self.resolve_current_sha(config)?;
let repo = git2::Repository::open(&self.repo_path)?;
let oid = git2::Oid::from_str(&head)?;
let commit = repo.find_commit(oid)?;
if commit.parent_count() > 0 {
let parent = commit.parent(0)?;
Ok((parent.id().to_string(), head))
} else {
Ok((Self::empty_tree_sha().to_string(), head))
}
}
GitHubEvent::Release | GitHubEvent::Tag => {
if config.tags_pattern.is_some() {
if let Ok(prev_tag) = self.find_previous_tag(config) {
let head = self.resolve_current_sha(config)?;
return Ok((prev_tag, head));
}
}
let base = self.resolve_base_sha(config)?;
let head = self.resolve_current_sha(config)?;
Ok((base, head))
}
GitHubEvent::WorkflowDispatch | GitHubEvent::WorkflowCall | GitHubEvent::Schedule => {
let base = self.resolve_base_sha(config)?;
let head = self.resolve_current_sha(config)?;
Ok((base, head))
}
GitHubEvent::Unknown => {
let base = self.resolve_base_sha(config)?;
let head = self.resolve_current_sha(config)?;
Ok((base, head))
}
}
}
fn resolve_push_base_from_event(&self, event_path: &str) -> Result<String> {
let content = std::fs::read_to_string(event_path)
.map_err(|e| Error::EventParse(format!("Failed to read event file: {}", e)))?;
let event: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| Error::EventParse(format!("Failed to parse event JSON: {}", e)))?;
if let Some(before) = event.get("before").and_then(|v| v.as_str()) {
if before == "0000000000000000000000000000000000000000" {
let repo = git2::Repository::open(&self.repo_path)?;
for default_ref in &["origin/main", "origin/master"] {
if let Ok(target) = self.resolve_ref(&repo, default_ref) {
let head = self.resolve_ref(&repo, "HEAD")?;
if let Ok(merge_base) = self.merge_base(&head, &target) {
return Ok(merge_base);
}
}
}
return Ok(Self::empty_tree_sha().to_string());
}
let repo = git2::Repository::open(&self.repo_path)?;
match self.validate_sha(&repo, before) {
Ok(sha) => return Ok(sha),
Err(_) => {
return self
.resolve_ref(&repo, "HEAD^")
.or_else(|_| Ok(Self::empty_tree_sha().to_string()));
}
}
}
let repo = git2::Repository::open(&self.repo_path)?;
self.resolve_ref(&repo, "HEAD^")
.or_else(|_| Ok(Self::empty_tree_sha().to_string()))
}
pub fn find_previous_tag(&self, config: &InputConfig<'_>) -> Result<String> {
let repo = git2::Repository::open(&self.repo_path)?;
let pattern = config.tags_pattern.as_ref().ok_or_else(|| {
Error::Config("tags_pattern is required for tag comparison".to_string())
})?;
let include_glob = globset::Glob::new(pattern)
.map_err(|e| Error::Pattern(format!("Invalid tags_pattern '{}': {}", pattern, e)))?
.compile_matcher();
let exclude_matcher = config
.tags_ignore_pattern
.as_ref()
.map(|p| {
globset::Glob::new(p)
.map_err(|e| {
Error::Pattern(format!("Invalid tags_ignore_pattern '{}': {}", p, e))
})
.map(|g| g.compile_matcher())
})
.transpose()?;
let head_oid = repo.revparse_single("HEAD")?.id();
let mut matching_tags: Vec<(String, i64)> = Vec::new();
repo.tag_foreach(|oid, name_bytes| {
let name = String::from_utf8_lossy(name_bytes);
let tag_name = name.strip_prefix("refs/tags/").unwrap_or(&name).to_string();
if !include_glob.is_match(&tag_name) {
return true; }
if let Some(ref exclude) = exclude_matcher {
if exclude.is_match(&tag_name) {
return true; }
}
if let Ok(obj) = repo.find_object(oid, None) {
let peeled = obj.peel(git2::ObjectType::Commit).ok();
if let Some(commit_obj) = peeled {
if commit_obj.id() != head_oid {
if let Ok(commit) = commit_obj.into_commit() {
let time = commit.time().seconds();
matching_tags.push((tag_name, time));
}
}
}
}
true })?;
if matching_tags.is_empty() {
return Err(Error::Git(format!(
"No previous tags found matching pattern '{}'",
pattern
)));
}
matching_tags.sort_by_key(|t| std::cmp::Reverse(t.1));
let tag_name = &matching_tags[0].0;
self.resolve_ref(&repo, tag_name)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
struct EnvSandbox {
_guard: std::sync::MutexGuard<'static, ()>,
saved: Vec<(&'static str, Option<String>)>,
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
const GITHUB_VARS: &[&str] = &[
"GITHUB_EVENT_NAME",
"GITHUB_HEAD_REF",
"GITHUB_BASE_REF",
"GITHUB_REF",
"GITHUB_REF_NAME",
"GITHUB_SHA",
"GITHUB_EVENT_PATH",
"GITHUB_EVENT_BEFORE",
];
impl EnvSandbox {
fn new() -> Self {
let guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let saved = GITHUB_VARS
.iter()
.map(|k| (*k, std::env::var(k).ok()))
.collect();
for k in GITHUB_VARS {
std::env::remove_var(k);
}
Self {
_guard: guard,
saved,
}
}
}
impl Drop for EnvSandbox {
fn drop(&mut self) {
for (k, v) in &self.saved {
match v {
Some(v) => std::env::set_var(k, v),
None => std::env::remove_var(k),
}
}
}
}
fn create_test_repo() -> (TempDir, std::path::PathBuf) {
let dir = TempDir::new().unwrap();
let repo_path = dir.path().to_path_buf();
std::process::Command::new("git")
.args(["init"])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&repo_path)
.output()
.unwrap();
fs::write(repo_path.join("file1.txt"), "content1").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(&repo_path)
.output()
.unwrap();
(dir, repo_path)
}
#[test]
fn test_resolve_head() {
let (_dir, repo_path) = create_test_repo();
let resolver = ShaResolver::new(&repo_path);
let config = InputConfig::default();
let sha = resolver.resolve_current_sha(&config).unwrap();
assert_eq!(sha.len(), 40); }
#[test]
fn test_validate_sha() {
let (_dir, repo_path) = create_test_repo();
let resolver = ShaResolver::new(&repo_path);
let repo = git2::Repository::open(&repo_path).unwrap();
let head = repo.head().unwrap();
let head_sha = head.target().unwrap().to_string();
let config = InputConfig {
sha: Some(std::borrow::Cow::Owned(head_sha.clone())),
..Default::default()
};
let resolved = resolver.resolve_current_sha(&config).unwrap();
assert_eq!(resolved, head_sha);
}
#[test]
fn test_resolve_base_sha() {
let _env = EnvSandbox::new();
let (_dir, repo_path) = create_test_repo();
let resolver = ShaResolver::new(&repo_path);
fs::write(repo_path.join("file2.txt"), "content2").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Second commit"])
.current_dir(&repo_path)
.output()
.unwrap();
let config = InputConfig::default();
let base_sha = resolver.resolve_base_sha(&config).unwrap();
assert_eq!(base_sha.len(), 40);
}
#[test]
fn test_is_initial_commit() {
let _env = EnvSandbox::new();
let (_dir, repo_path) = create_test_repo();
let resolver = ShaResolver::new(&repo_path);
let config = InputConfig::default();
let sha = resolver.resolve_current_sha(&config).unwrap();
assert!(resolver.is_initial_commit(&sha).unwrap());
}
#[test]
fn test_resolve_event_aware_default() {
let _env = EnvSandbox::new();
let (_dir, repo_path) = create_test_repo();
fs::write(repo_path.join("file2.txt"), "content2").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Second commit"])
.current_dir(&repo_path)
.output()
.unwrap();
let resolver = ShaResolver::new(&repo_path);
let config = InputConfig::default();
let result = resolver.resolve_event_aware(&config);
assert!(result.is_ok());
let (base, head) = result.unwrap();
assert_eq!(base.len(), 40);
assert_eq!(head.len(), 40);
assert_ne!(base, head);
}
#[test]
fn test_resolve_event_aware_explicit_shas() {
let _env = EnvSandbox::new();
let (_dir, repo_path) = create_test_repo();
fs::write(repo_path.join("file2.txt"), "content2").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Second commit"])
.current_dir(&repo_path)
.output()
.unwrap();
let resolver = ShaResolver::new(&repo_path);
let repo = git2::Repository::open(&repo_path).unwrap();
let head = repo.revparse_single("HEAD").unwrap().id().to_string();
let base = repo.revparse_single("HEAD^").unwrap().id().to_string();
let config = InputConfig {
base_sha: Some(std::borrow::Cow::Owned(base.clone())),
sha: Some(std::borrow::Cow::Owned(head.clone())),
..Default::default()
};
let result = resolver.resolve_event_aware(&config).unwrap();
assert_eq!(result.0, base);
assert_eq!(result.1, head);
}
#[test]
fn test_find_previous_tag() {
let (_dir, repo_path) = create_test_repo();
std::process::Command::new("git")
.args(["tag", "v0.1.0"])
.current_dir(&repo_path)
.output()
.unwrap();
fs::write(repo_path.join("file2.txt"), "content2").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Second commit"])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["tag", "v0.2.0"])
.current_dir(&repo_path)
.output()
.unwrap();
fs::write(repo_path.join("file3.txt"), "content3").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Third commit"])
.current_dir(&repo_path)
.output()
.unwrap();
let resolver = ShaResolver::new(&repo_path);
let config = InputConfig {
tags_pattern: Some(std::borrow::Cow::Borrowed("v*")),
..Default::default()
};
let result = resolver.find_previous_tag(&config);
assert!(result.is_ok());
let tag_sha = result.unwrap();
assert_eq!(tag_sha.len(), 40);
}
#[test]
fn test_find_previous_tag_with_ignore() {
let (_dir, repo_path) = create_test_repo();
std::process::Command::new("git")
.args(["tag", "v0.1.0-rc1"])
.current_dir(&repo_path)
.output()
.unwrap();
fs::write(repo_path.join("file2.txt"), "content2").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Second commit"])
.current_dir(&repo_path)
.output()
.unwrap();
fs::write(repo_path.join("file3.txt"), "content3").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Third commit"])
.current_dir(&repo_path)
.output()
.unwrap();
let resolver = ShaResolver::new(&repo_path);
let config = InputConfig {
tags_pattern: Some(std::borrow::Cow::Borrowed("v*")),
tags_ignore_pattern: Some(std::borrow::Cow::Borrowed("*-rc*")),
..Default::default()
};
let result = resolver.find_previous_tag(&config);
assert!(result.is_err()); }
#[test]
fn test_merge_base() {
let (_dir, repo_path) = create_test_repo();
let repo = git2::Repository::open(&repo_path).unwrap();
let head = repo.head().unwrap();
let base_sha = head.target().unwrap().to_string();
std::process::Command::new("git")
.args(["checkout", "-b", "feature"])
.current_dir(&repo_path)
.output()
.unwrap();
fs::write(repo_path.join("feature.txt"), "feature").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Feature commit"])
.current_dir(&repo_path)
.output()
.unwrap();
let resolver = ShaResolver::new(&repo_path);
let feature_sha = repo.revparse_single("feature").unwrap().id().to_string();
let merge_base = resolver.merge_base(&base_sha, &feature_sha).unwrap();
assert_eq!(merge_base.len(), 40);
assert_eq!(merge_base, base_sha); }
}