Files
QuiCommit/src/commands/tag.rs
SidneyZhang a40c88c768 feat(changelog): 添加 --no-generate 标志以支持纯模板生成
- 新增 `--no-generate` 参数,强制使用模板生成而非 AI
- 调整 `--yes` 参数仅跳过交互提示,不改变生成行为
- 统一使用工具函数替代 println 输出
2026-08-21 13:57:25 +08:00

455 lines
14 KiB
Rust

use anyhow::{Result, bail};
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::generator::ContentGenerator;
use crate::git::tag::{
ConfigVersion, TagBuilder, VersionBump, bump_version, get_latest_version,
read_project_versions, suggest_version_bump,
};
use crate::git::{GitRepo, find_repo};
use crate::i18n::Messages;
use crate::utils::{print_progress, print_success, print_warning};
/// 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,
/// Enable thinking mode for this tag (override config)
#[arg(short = 't', long)]
think: bool,
/// Skip interactive prompts only (generation behavior unchanged)
#[arg(short = 'y', long)]
yes: bool,
/// Auto-detect version from project config (Cargo.toml/pyproject.toml),
/// falling back to commit analysis with confirmation. Mutually exclusive with --bump.
#[arg(short = 'A', long, conflicts_with = "bump")]
auto: 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 self.auto {
self.auto_detect_version(&repo, &config.tag.version_prefix, &messages)
.await?
} 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) {
print_warning(&format!("{}: {}", messages.warning(), e));
if !self.yes {
let proceed = Confirm::new()
.with_prompt(messages.proceed_invalid_tag_name())
.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 {
Some(
self.generate_tag_message(&repo, &tag_name, &messages)
.await?,
)
} else if !self.yes {
Some(self.input_message_interactive(&tag_name, &messages)?)
} else {
Some(messages.release_default(&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 {
print_warning(messages.tag_cancelled());
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)?;
print_success(&format!("{} {}", messages.tag_created(), tag_name.cyan()));
// Push if requested or ask user
if self.push {
print_progress(&messages.pushing_tag(&self.remote));
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
print_success(&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 {
print_progress(&messages.pushing_tag(&self.remote));
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
print_success(&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(messages.release_default(version));
}
let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?;
let spinner = crate::utils::Spinner::start(&messages.ai_generating_tag(commits.len()));
let result = generator
.generate_tag_message(version, &commits, language)
.await;
spinner.finish_clear();
result
}
async fn auto_detect_version(
&self,
repo: &GitRepo,
prefix: &str,
messages: &Messages,
) -> Result<String> {
let project_dir = std::env::current_dir()?;
// 1. Try reading from project config files
let config_versions = read_project_versions(&project_dir);
if !config_versions.is_empty() {
if config_versions.len() == 1 {
let cv = &config_versions[0];
let tag_name = format!("{}{}", prefix, cv.version);
println!(
"{} ({}): {}",
messages.found_version_in(),
cv.source,
tag_name.cyan()
);
return Ok(tag_name);
}
// Multiple config files: let user choose
return self.select_config_version(&config_versions, prefix, messages);
}
// 2. Fall back to commit analysis
println!("{}", messages.auto_detect_bump());
let commits = repo.get_commits(50)?;
let bump = suggest_version_bump(&commits);
let latest = get_latest_version(repo, prefix)?.unwrap_or_else(|| Version::new(0, 0, 0));
let version = bump_version(&latest, bump, None);
let tag_name = format!("{}{}", prefix, version);
println!(
"{} {:?}{}",
messages.suggested_bump(),
bump,
tag_name.cyan()
);
if !self.yes {
let confirm = Confirm::new()
.with_prompt(messages.use_this_version())
.default(true)
.interact()?;
if !confirm {
// Fall through to interactive version selection
return self
.select_version_interactive(repo, prefix, messages)
.await;
}
}
Ok(tag_name)
}
fn select_config_version(
&self,
config_versions: &[ConfigVersion],
prefix: &str,
messages: &Messages,
) -> Result<String> {
println!("\n{}", messages.found_multiple_versions().bold());
let items: Vec<String> = config_versions
.iter()
.map(|cv| format!("{}{}{}", cv.source, prefix, cv.version))
.collect();
let selection = Select::new()
.with_prompt(messages.select_version_to_use())
.items(&items)
.default(0)
.interact()?;
let cv = &config_versions[selection];
Ok(format!("{}{}", prefix, cv.version))
}
fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result<String> {
let default_msg = messages.release_default(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
}
}
}