325 lines
11 KiB
Rust
325 lines
11 KiB
Rust
use anyhow::{bail, Result};
|
|
use clap::Parser;
|
|
use colored::Colorize;
|
|
use dialoguer::{Confirm, Input, Select};
|
|
use semver::Version;
|
|
use std::path::PathBuf;
|
|
|
|
use crate::config::{Language, manager::ConfigManager};
|
|
use crate::git::{find_repo, GitRepo};
|
|
use crate::generator::ContentGenerator;
|
|
use crate::git::tag::{
|
|
bump_version, get_latest_version, suggest_version_bump, TagBuilder, VersionBump,
|
|
};
|
|
use crate::i18n::Messages;
|
|
|
|
/// Generate and create Git tags
|
|
#[derive(Parser)]
|
|
pub struct TagCommand {
|
|
/// Tag name
|
|
#[arg(short, long)]
|
|
name: Option<String>,
|
|
|
|
/// Semantic version bump
|
|
#[arg(short, long, value_name = "TYPE")]
|
|
bump: Option<String>,
|
|
|
|
/// Tag message
|
|
#[arg(short, long)]
|
|
message: Option<String>,
|
|
|
|
/// Generate message with AI
|
|
#[arg(short, long)]
|
|
generate: bool,
|
|
|
|
/// Sign the tag
|
|
#[arg(short = 'S', long)]
|
|
sign: bool,
|
|
|
|
/// Create lightweight tag (no annotation)
|
|
#[arg(short, long)]
|
|
lightweight: bool,
|
|
|
|
/// Force overwrite existing tag
|
|
#[arg(short, long)]
|
|
force: bool,
|
|
|
|
/// Push tag to remote after creation
|
|
#[arg(short, long)]
|
|
push: bool,
|
|
|
|
/// Remote to push to
|
|
#[arg(short, long, default_value = "origin")]
|
|
remote: String,
|
|
|
|
/// Dry run
|
|
#[arg(long)]
|
|
dry_run: bool,
|
|
|
|
/// Skip interactive prompts
|
|
#[arg(short = 'y', long)]
|
|
yes: bool,
|
|
}
|
|
|
|
impl TagCommand {
|
|
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
|
|
let repo = find_repo(std::env::current_dir()?.as_path())?;
|
|
let manager = if let Some(ref path) = config_path {
|
|
ConfigManager::with_path(path)?
|
|
} else {
|
|
ConfigManager::new()?
|
|
};
|
|
let config = manager.config();
|
|
let language = manager.get_language().unwrap_or(Language::English);
|
|
let messages = Messages::new(language);
|
|
|
|
// Determine tag name
|
|
let tag_name = if let Some(name) = &self.name {
|
|
name.clone()
|
|
} else if let Some(bump_str) = &self.bump {
|
|
// Calculate bumped version
|
|
let prefix = &config.tag.version_prefix;
|
|
let latest = get_latest_version(&repo, prefix)?
|
|
.unwrap_or_else(|| Version::new(0, 0, 0));
|
|
|
|
let bump = VersionBump::from_str(bump_str)?;
|
|
let new_version = bump_version(&latest, bump, None);
|
|
|
|
format!("{}{}", prefix, new_version)
|
|
} else {
|
|
// Interactive mode
|
|
self.select_version_interactive(&repo, &config.tag.version_prefix, &messages).await?
|
|
};
|
|
|
|
// Validate tag name (if it looks like a version)
|
|
if tag_name.starts_with('v') || tag_name.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
|
|
let version_str = tag_name.trim_start_matches('v');
|
|
if let Err(e) = crate::utils::validators::validate_semver(version_str) {
|
|
println!("{}: {}", "Warning".yellow(), e);
|
|
|
|
if !self.yes {
|
|
let proceed = Confirm::new()
|
|
.with_prompt("Proceed with this tag name anyway?")
|
|
.default(true)
|
|
.interact()?;
|
|
|
|
if !proceed {
|
|
bail!("{}", messages.tag_cancelled());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generate or get tag message
|
|
let message = if self.lightweight {
|
|
None
|
|
} else if let Some(msg) = &self.message {
|
|
Some(msg.clone())
|
|
} else if self.generate || (config.tag.auto_generate && !self.yes) {
|
|
Some(self.generate_tag_message(&repo, &tag_name, &messages).await?)
|
|
} else if !self.yes {
|
|
Some(self.input_message_interactive(&tag_name, &messages)?)
|
|
} else {
|
|
Some(format!("Release {}", tag_name))
|
|
};
|
|
|
|
// Show preview
|
|
println!("\n{}", "─".repeat(60));
|
|
println!("{}", messages.tag_preview().bold());
|
|
println!("{}", "─".repeat(60));
|
|
println!("Name: {}", tag_name.cyan());
|
|
if let Some(ref msg) = message {
|
|
println!("Message:\n{}", msg);
|
|
} else {
|
|
println!("Type: {}", "lightweight".yellow());
|
|
}
|
|
println!("{}", "─".repeat(60));
|
|
|
|
if !self.yes {
|
|
let confirm = Confirm::new()
|
|
.with_prompt(messages.create_tag())
|
|
.default(true)
|
|
.interact()?;
|
|
|
|
if !confirm {
|
|
println!("{}", messages.tag_cancelled().yellow());
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
if self.dry_run {
|
|
println!("\n{} {}", messages.dry_run(), "- tag not created.".yellow());
|
|
return Ok(());
|
|
}
|
|
|
|
let builder = TagBuilder::new()
|
|
.name(&tag_name)
|
|
.message_opt(message)
|
|
.annotate(!self.lightweight)
|
|
.sign(self.sign)
|
|
.force(self.force);
|
|
|
|
builder.execute(&repo)?;
|
|
|
|
println!("{} {}", messages.tag_created().green(), tag_name.cyan());
|
|
|
|
// Push if requested or ask user
|
|
if self.push {
|
|
println!("{}", messages.pushing_tag(&self.remote));
|
|
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
|
|
println!("{}", messages.pushed_tag(&self.remote));
|
|
} else if !self.yes && !self.dry_run {
|
|
let should_push = Confirm::new()
|
|
.with_prompt(messages.push_after_tag())
|
|
.default(false)
|
|
.interact()?;
|
|
|
|
if should_push {
|
|
println!("{}", messages.pushing_tag(&self.remote));
|
|
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
|
|
println!("{}", messages.pushed_tag(&self.remote));
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn select_version_interactive(&self, repo: &GitRepo, prefix: &str, messages: &Messages) -> Result<String> {
|
|
loop {
|
|
let latest = get_latest_version(repo, prefix)?;
|
|
|
|
println!("\n{}", messages.version_selection().bold());
|
|
|
|
if let Some(ref version) = latest {
|
|
println!("{} {}{}", messages.latest_version(), prefix, version);
|
|
} else {
|
|
println!("{}", messages.no_existing_version_tags());
|
|
}
|
|
|
|
let options = vec![
|
|
messages.auto_detect_bump(),
|
|
messages.bump_major_version(),
|
|
messages.bump_minor_version(),
|
|
messages.bump_patch_version(),
|
|
messages.enter_custom_version(),
|
|
messages.enter_custom_tag_name(),
|
|
];
|
|
|
|
let selection = Select::new()
|
|
.with_prompt(messages.select_option())
|
|
.items(&options)
|
|
.default(0)
|
|
.interact()?;
|
|
|
|
match selection {
|
|
0 => {
|
|
// Auto-detect
|
|
let commits = repo.get_commits(50)?;
|
|
let bump = suggest_version_bump(&commits);
|
|
let version = latest.as_ref()
|
|
.map(|v| bump_version(v, bump, None))
|
|
.unwrap_or_else(|| Version::new(0, 1, 0));
|
|
|
|
println!("{} {:?} → {}{}", messages.suggested_bump(), bump, prefix, version);
|
|
|
|
let confirm = Confirm::new()
|
|
.with_prompt(messages.use_this_version())
|
|
.default(true)
|
|
.interact()?;
|
|
|
|
if confirm {
|
|
return Ok(format!("{}{}", prefix, version));
|
|
}
|
|
// User rejected, continue the loop
|
|
}
|
|
1 => {
|
|
let version = latest.as_ref()
|
|
.map(|v| bump_version(v, VersionBump::Major, None))
|
|
.unwrap_or_else(|| Version::new(1, 0, 0));
|
|
return Ok(format!("{}{}", prefix, version));
|
|
}
|
|
2 => {
|
|
let version = latest.as_ref()
|
|
.map(|v| bump_version(v, VersionBump::Minor, None))
|
|
.unwrap_or_else(|| Version::new(0, 1, 0));
|
|
return Ok(format!("{}{}", prefix, version));
|
|
}
|
|
3 => {
|
|
let version = latest.as_ref()
|
|
.map(|v| bump_version(v, VersionBump::Patch, None))
|
|
.unwrap_or_else(|| Version::new(0, 0, 1));
|
|
return Ok(format!("{}{}", prefix, version));
|
|
}
|
|
4 => {
|
|
let input: String = Input::new()
|
|
.with_prompt(messages.enter_version())
|
|
.interact_text()?;
|
|
let version = Version::parse(&input)?;
|
|
return Ok(format!("{}{}", prefix, version));
|
|
}
|
|
5 => {
|
|
let input: String = Input::new()
|
|
.with_prompt(messages.enter_tag_name())
|
|
.interact_text()?;
|
|
return Ok(input);
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn generate_tag_message(&self, repo: &GitRepo, version: &str, messages: &Messages) -> Result<String> {
|
|
let manager = ConfigManager::new()?;
|
|
let language = manager.get_language().unwrap_or(Language::English);
|
|
|
|
let tags = repo.get_tags()?;
|
|
let commits = if let Some(latest_tag) = tags.first() {
|
|
repo.get_commits_between(&latest_tag.name, "HEAD")?
|
|
} else {
|
|
repo.get_commits(50)?
|
|
};
|
|
|
|
if commits.is_empty() {
|
|
return Ok(format!("Release {}", version));
|
|
}
|
|
|
|
println!("{}", messages.ai_generating_tag(commits.len()));
|
|
|
|
let generator = ContentGenerator::new(&manager).await?;
|
|
generator.generate_tag_message(version, &commits, language).await
|
|
}
|
|
|
|
fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result<String> {
|
|
let default_msg = format!("Release {}", version);
|
|
|
|
let use_editor = Confirm::new()
|
|
.with_prompt(messages.open_editor())
|
|
.default(false)
|
|
.interact()?;
|
|
|
|
if use_editor {
|
|
crate::utils::editor::edit_content(&default_msg)
|
|
} else {
|
|
Ok(Input::new()
|
|
.with_prompt(messages.tag_message())
|
|
.default(default_msg)
|
|
.interact_text()?)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper trait
|
|
trait TagBuilderExt {
|
|
fn message_opt(self, message: Option<String>) -> Self;
|
|
}
|
|
|
|
impl TagBuilderExt for TagBuilder {
|
|
fn message_opt(self, message: Option<String>) -> Self {
|
|
if let Some(m) = message {
|
|
self.message(m)
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
}
|