323 lines
8.5 KiB
Rust
323 lines
8.5 KiB
Rust
use super::GitRepo;
|
|
use anyhow::{bail, Context, Result};
|
|
use semver::Version;
|
|
|
|
/// Tag builder for creating tags
|
|
pub struct TagBuilder {
|
|
name: Option<String>,
|
|
message: Option<String>,
|
|
annotate: bool,
|
|
sign: bool,
|
|
force: bool,
|
|
version_prefix: String,
|
|
}
|
|
|
|
impl TagBuilder {
|
|
/// Create new tag builder
|
|
pub fn new() -> Self {
|
|
Self {
|
|
name: None,
|
|
message: None,
|
|
annotate: true,
|
|
sign: false,
|
|
force: false,
|
|
version_prefix: "v".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Set tag name
|
|
pub fn name(mut self, name: impl Into<String>) -> Self {
|
|
self.name = Some(name.into());
|
|
self
|
|
}
|
|
|
|
/// Set tag message
|
|
pub fn message(mut self, message: impl Into<String>) -> Self {
|
|
self.message = Some(message.into());
|
|
self
|
|
}
|
|
|
|
/// Create annotated tag
|
|
pub fn annotate(mut self, annotate: bool) -> Self {
|
|
self.annotate = annotate;
|
|
self
|
|
}
|
|
|
|
/// Sign the tag
|
|
pub fn sign(mut self, sign: bool) -> Self {
|
|
self.sign = sign;
|
|
self
|
|
}
|
|
|
|
/// Force overwrite existing tag
|
|
pub fn force(mut self, force: bool) -> Self {
|
|
self.force = force;
|
|
self
|
|
}
|
|
|
|
/// Set version prefix
|
|
pub fn version_prefix(mut self, prefix: impl Into<String>) -> Self {
|
|
self.version_prefix = prefix.into();
|
|
self
|
|
}
|
|
|
|
/// Set semantic version
|
|
pub fn version(mut self, version: &Version) -> Self {
|
|
self.name = Some(format!("{}{}", self.version_prefix, version));
|
|
self
|
|
}
|
|
|
|
/// Build tag message
|
|
pub fn build_message(&self) -> Result<String> {
|
|
let message = self.message.as_ref()
|
|
.cloned()
|
|
.unwrap_or_else(|| {
|
|
let name = self.name.as_deref().unwrap_or("unknown");
|
|
format!("Release {}", name)
|
|
});
|
|
|
|
Ok(message)
|
|
}
|
|
|
|
/// Execute tag creation
|
|
pub fn execute(&self, repo: &GitRepo) -> Result<()> {
|
|
let name = self.name.as_ref()
|
|
.ok_or_else(|| anyhow::anyhow!("Tag name is required"))?;
|
|
|
|
if !self.force {
|
|
let existing_tags = repo.get_tags()?;
|
|
if existing_tags.iter().any(|t| t.name == *name) {
|
|
bail!("Tag '{}' already exists. Use --force to overwrite.", name);
|
|
}
|
|
}
|
|
|
|
let message = if self.annotate {
|
|
Some(self.build_message()?.as_str().to_string())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
repo.create_tag(name, message.as_deref(), self.sign)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Execute and push tag
|
|
pub fn execute_and_push(&self, repo: &GitRepo, remote: &str) -> Result<()> {
|
|
self.execute(repo)?;
|
|
|
|
let name = self.name.as_ref().unwrap();
|
|
repo.push(remote, &format!("refs/tags/{}", name))?;
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Default for TagBuilder {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Semantic version bump types
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum VersionBump {
|
|
Major,
|
|
Minor,
|
|
Patch,
|
|
Prerelease,
|
|
}
|
|
|
|
impl VersionBump {
|
|
/// Parse from string
|
|
pub fn from_str(s: &str) -> Result<Self> {
|
|
match s.to_lowercase().as_str() {
|
|
"major" => Ok(Self::Major),
|
|
"minor" => Ok(Self::Minor),
|
|
"patch" => Ok(Self::Patch),
|
|
"prerelease" | "pre" => Ok(Self::Prerelease),
|
|
_ => bail!("Invalid version bump: {}. Use: major, minor, patch, prerelease", s),
|
|
}
|
|
}
|
|
|
|
/// Get all variants
|
|
pub fn variants() -> &'static [&'static str] {
|
|
&["major", "minor", "patch", "prerelease"]
|
|
}
|
|
}
|
|
|
|
/// Get latest version tag from repository
|
|
pub fn get_latest_version(repo: &GitRepo, prefix: &str) -> Result<Option<Version>> {
|
|
let tags = repo.get_tags()?;
|
|
|
|
let mut versions: Vec<Version> = tags
|
|
.iter()
|
|
.filter_map(|t| {
|
|
let name = &t.name;
|
|
let version_str = name.strip_prefix(prefix).unwrap_or(name);
|
|
Version::parse(version_str).ok()
|
|
})
|
|
.collect();
|
|
|
|
versions.sort_by(|a, b| b.cmp(a)); // Descending order
|
|
|
|
Ok(versions.into_iter().next())
|
|
}
|
|
|
|
/// Bump version
|
|
pub fn bump_version(version: &Version, bump: VersionBump, prerelease_id: Option<&str>) -> Version {
|
|
match bump {
|
|
VersionBump::Major => Version::new(version.major + 1, 0, 0),
|
|
VersionBump::Minor => Version::new(version.major, version.minor + 1, 0),
|
|
VersionBump::Patch => Version::new(version.major, version.minor, version.patch + 1),
|
|
VersionBump::Prerelease => {
|
|
let pre = prerelease_id.unwrap_or("alpha");
|
|
let pre_id = format!("{}.0", pre);
|
|
Version::parse(&format!("{}-{}", version, pre_id)).unwrap_or_else(|_| version.clone())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Suggest next version based on commits
|
|
pub fn suggest_version_bump(commits: &[super::CommitInfo]) -> VersionBump {
|
|
let mut has_breaking = false;
|
|
let mut has_feature = false;
|
|
let mut has_fix = false;
|
|
|
|
for commit in commits {
|
|
let msg = commit.message.to_lowercase();
|
|
|
|
if msg.contains("breaking change") || msg.contains("breaking-change") || msg.contains("breaking_change") {
|
|
has_breaking = true;
|
|
}
|
|
|
|
if let Some(commit_type) = commit.commit_type() {
|
|
match commit_type.as_str() {
|
|
"feat" => has_feature = true,
|
|
"fix" => has_fix = true,
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
if has_breaking {
|
|
VersionBump::Major
|
|
} else if has_feature {
|
|
VersionBump::Minor
|
|
} else if has_fix {
|
|
VersionBump::Patch
|
|
} else {
|
|
VersionBump::Patch
|
|
}
|
|
}
|
|
|
|
/// Generate tag message from commits
|
|
pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> String {
|
|
let mut message = format!("Release {}\n\n", version);
|
|
|
|
// Group commits by type
|
|
let mut features = vec![];
|
|
let mut fixes = vec![];
|
|
let mut other = vec![];
|
|
let mut breaking = vec![];
|
|
|
|
for commit in commits {
|
|
let subject = commit.subject();
|
|
|
|
if commit.message.contains("BREAKING CHANGE") {
|
|
breaking.push(subject.to_string());
|
|
}
|
|
|
|
if let Some(commit_type) = commit.commit_type() {
|
|
match commit_type.as_str() {
|
|
"feat" => features.push(subject.to_string()),
|
|
"fix" => fixes.push(subject.to_string()),
|
|
_ => other.push(subject.to_string()),
|
|
}
|
|
} else {
|
|
other.push(subject.to_string());
|
|
}
|
|
}
|
|
|
|
// Build message
|
|
if !breaking.is_empty() {
|
|
message.push_str("## Breaking Changes\n\n");
|
|
for item in &breaking {
|
|
message.push_str(&format!("- {}\n", item));
|
|
}
|
|
message.push('\n');
|
|
}
|
|
|
|
if !features.is_empty() {
|
|
message.push_str("## Features\n\n");
|
|
for item in &features {
|
|
message.push_str(&format!("- {}\n", item));
|
|
}
|
|
message.push('\n');
|
|
}
|
|
|
|
if !fixes.is_empty() {
|
|
message.push_str("## Bug Fixes\n\n");
|
|
for item in &fixes {
|
|
message.push_str(&format!("- {}\n", item));
|
|
}
|
|
message.push('\n');
|
|
}
|
|
|
|
if !other.is_empty() {
|
|
message.push_str("## Other Changes\n\n");
|
|
for item in &other {
|
|
message.push_str(&format!("- {}\n", item));
|
|
}
|
|
}
|
|
|
|
message
|
|
}
|
|
|
|
/// Tag deletion helper
|
|
pub fn delete_tag(repo: &GitRepo, name: &str, remote: Option<&str>) -> Result<()> {
|
|
repo.delete_tag(name)?;
|
|
|
|
if let Some(remote) = remote {
|
|
use std::process::Command;
|
|
|
|
let output = Command::new("git")
|
|
.args(&["push", remote, ":refs/tags/{}"])
|
|
.current_dir(repo.path())
|
|
.output()?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
bail!("Failed to delete remote tag: {}", stderr);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// List tags with filtering
|
|
pub fn list_tags(
|
|
repo: &GitRepo,
|
|
pattern: Option<&str>,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<super::TagInfo>> {
|
|
let tags = repo.get_tags()?;
|
|
|
|
let filtered: Vec<_> = tags
|
|
.into_iter()
|
|
.filter(|t| {
|
|
if let Some(p) = pattern {
|
|
t.name.contains(p)
|
|
} else {
|
|
true
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
if let Some(limit) = limit {
|
|
Ok(filtered.into_iter().take(limit).collect())
|
|
} else {
|
|
Ok(filtered)
|
|
}
|
|
}
|