feat(commit): 添加提交消息模板支持

- 移除 config 命令中未使用的 List 子命令及相关显示字段
- 统一 ChangelogCommand 和 CommitCommand 的 ContentGenerator 初始化方式
This commit is contained in:
2026-06-03 15:20:50 +08:00
parent 459670f363
commit 14ebb6857a
17 changed files with 494 additions and 653 deletions

View File

@@ -217,7 +217,7 @@ impl ChangelogCommand {
println!("{}", messages.ai_generating_changelog());
let generator = ContentGenerator::new_with_think(&manager, self.think).await?;
let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?;
generator
.generate_changelog_entry(version, commits, language)
.await

View File

@@ -280,7 +280,11 @@ impl CommitCommand {
) -> Result<String> {
let manager = ConfigManager::new()?;
let generator = ContentGenerator::new_with_think(&manager, self.think)
let template = manager
.default_profile()
.and_then(|p| p.commit_template().map(|t| t.to_string()));
let generator = ContentGenerator::new_with_think(&manager, self.think, template)
.await
.context("Failed to initialize LLM. Use --manual for manual commit.")?;

View File

@@ -37,9 +37,6 @@ enum ConfigSubcommand {
/// Show current configuration
Show,
/// List all configuration information (with masked API keys)
List,
/// Edit configuration file
Edit,
@@ -167,7 +164,6 @@ impl ConfigCommand {
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
match &self.command {
Some(ConfigSubcommand::Show) => self.show_config(&config_path).await,
Some(ConfigSubcommand::List) => self.list_config(&config_path).await,
Some(ConfigSubcommand::Edit) => self.edit_config(&config_path).await,
Some(ConfigSubcommand::Set { key, value }) => {
self.set_value(key, value, &config_path).await
@@ -311,15 +307,6 @@ impl ConfigCommand {
"no".red()
}
);
println!(
" GPG sign: {}",
if config.commit.gpg_sign {
"yes".green()
} else {
"no".red()
}
);
println!(" Max subject length: {}", config.commit.max_subject_length);
println!("\n{}", "Tag Configuration:".bold());
println!(" Version prefix: '{}'", config.tag.version_prefix);
@@ -331,22 +318,6 @@ impl ConfigCommand {
"no".red()
}
);
println!(
" GPG sign: {}",
if config.tag.gpg_sign {
"yes".green()
} else {
"no".red()
}
);
println!(
" Include changelog: {}",
if config.tag.include_changelog {
"yes".green()
} else {
"no".red()
}
);
println!("\n{}", "Language Configuration:".bold());
let language = manager.get_language().unwrap_or(Language::English);
@@ -378,243 +349,17 @@ impl ConfigCommand {
"no".red()
}
);
println!(
" Include hashes: {}",
if config.changelog.include_hashes {
"yes".green()
} else {
"no".red()
}
);
println!(
" Include authors: {}",
if config.changelog.include_authors {
"yes".green()
} else {
"no".red()
}
);
println!(
" Group by type: {}",
if config.changelog.group_by_type {
"yes".green()
} else {
"no".red()
}
);
Ok(())
}
async fn list_config(&self, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
let config = manager.config();
println!("{}", "\nQuiCommit Configuration".bold());
println!("{}", "".repeat(80));
println!("\n{}", "📁 General Configuration:".bold().blue());
println!(" Config file: {}", manager.path().display());
println!("\n{}", "Security:".bold());
println!(" Repository mappings: {} mapping(s)", config.repo_profiles.len());
println!(
" Default profile: {}",
config.default_profile.as_deref().unwrap_or("(none)").cyan()
);
println!(" Profiles: {} profile(s)", config.profiles.len());
println!(
" Repository mappings: {} mapping(s)",
config.repo_profiles.len()
);
println!("\n{}", "🤖 LLM Configuration:".bold().blue());
println!(" Provider: {}", config.llm.provider.cyan());
println!(" Model: {}", config.llm.model.cyan());
println!(" Base URL: {}", manager.llm_base_url());
println!(
" API Key: {}",
mask_api_key(manager.get_api_key().as_deref())
);
println!(" Max tokens: {}", config.llm.max_tokens);
println!(" Temperature: {}", config.llm.temperature);
println!(" Timeout: {}s", config.llm.timeout);
println!("\n{}", "📝 Commit Configuration:".bold().blue());
println!(" Format: {}", config.commit.format.to_string().cyan());
println!(
" Auto-generate: {}",
if config.commit.auto_generate {
"✓ yes".green()
" Keyring: {}",
if manager.keyring().is_available() {
"available".green()
} else {
"✗ no".red()
"unavailable".red()
}
);
println!(
" Allow empty: {}",
if config.commit.allow_empty {
"✓ yes".green()
} else {
"✗ no".red()
}
);
println!(
" GPG sign: {}",
if config.commit.gpg_sign {
"✓ yes".green()
} else {
"✗ no".red()
}
);
println!(
" Default scope: {}",
config
.commit
.default_scope
.as_deref()
.unwrap_or("(none)")
.cyan()
);
println!(" Max subject length: {}", config.commit.max_subject_length);
println!(
" Require scope: {}",
if config.commit.require_scope {
"✓ yes".green()
} else {
"✗ no".red()
}
);
println!(
" Require body: {}",
if config.commit.require_body {
"✓ yes".green()
} else {
"✗ no".red()
}
);
if !config.commit.body_required_types.is_empty() {
println!(
" Body required types: {}",
config.commit.body_required_types.join(", ").cyan()
);
}
println!("\n{}", "🏷️ Tag Configuration:".bold().blue());
println!(" Version prefix: '{}'", config.tag.version_prefix.cyan());
println!(
" Auto-generate: {}",
if config.tag.auto_generate {
"✓ yes".green()
} else {
"✗ no".red()
}
);
println!(
" GPG sign: {}",
if config.tag.gpg_sign {
"✓ yes".green()
} else {
"✗ no".red()
}
);
println!(
" Include changelog: {}",
if config.tag.include_changelog {
"✓ yes".green()
} else {
"✗ no".red()
}
);
println!(
" Annotation template: {}",
config
.tag
.annotation_template
.as_deref()
.unwrap_or("(none)")
.cyan()
);
println!("\n{}", "📋 Changelog Configuration:".bold().blue());
println!(" Path: {}", config.changelog.path);
println!(
" Auto-generate: {}",
if config.changelog.auto_generate {
"✓ yes".green()
} else {
"✗ no".red()
}
);
println!(
" Format: {}",
format!("{:?}", config.changelog.format).cyan()
);
println!(
" Include hashes: {}",
if config.changelog.include_hashes {
"✓ yes".green()
} else {
"✗ no".red()
}
);
println!(
" Include authors: {}",
if config.changelog.include_authors {
"✓ yes".green()
} else {
"✗ no".red()
}
);
println!(
" Group by type: {}",
if config.changelog.group_by_type {
"✓ yes".green()
} else {
"✗ no".red()
}
);
if !config.changelog.custom_categories.is_empty() {
println!(
" Custom categories: {} category(ies)",
config.changelog.custom_categories.len()
);
}
println!("\n{}", "🎨 Theme Configuration:".bold().blue());
println!(
" Colors: {}",
if config.theme.colors {
"✓ enabled".green()
} else {
"✗ disabled".red()
}
);
println!(
" Icons: {}",
if config.theme.icons {
"✓ enabled".green()
} else {
"✗ disabled".red()
}
);
println!(" Date format: {}", config.theme.date_format.cyan());
println!("\n{}", "🔒 Security:".bold().blue());
println!(
" Encrypt sensitive: {}",
if config.encrypt_sensitive {
"✓ yes".green()
} else {
"✗ no".red()
}
);
println!("\n{}", "🔑 Keyring:".bold().blue());
let keyring = manager.keyring();
if keyring.is_available() {
println!(" Status: {}", "✓ available".green());
println!(" Backend: {}", keyring.get_status_message());
} else {
println!(" Status: {}", "✗ unavailable".red());
println!(" Note: {}", keyring.get_status_message());
}
Ok(())
}
@@ -671,7 +416,22 @@ impl ConfigCommand {
manager.set_auto_generate_commits(value == "true");
}
"tag.version_prefix" => manager.set_version_prefix(value.to_string()),
"tag.auto_generate" => {
manager.config_mut().tag.auto_generate = value == "true";
}
"changelog.path" => manager.set_changelog_path(value.to_string()),
"changelog.auto_generate" => {
manager.config_mut().changelog.auto_generate = value == "true";
}
"language.output_language" => {
manager.set_output_language(value.to_string());
}
"language.keep_types_english" => {
manager.set_keep_types_english(value == "true");
}
"language.keep_changelog_types_english" => {
manager.set_keep_changelog_types_english(value == "true");
}
_ => bail!("Unknown configuration key: {}", key),
}
@@ -702,7 +462,14 @@ impl ConfigCommand {
"commit.format" => config.commit.format.to_string(),
"commit.auto_generate" => config.commit.auto_generate.to_string(),
"tag.version_prefix" => config.tag.version_prefix.clone(),
"tag.auto_generate" => config.tag.auto_generate.to_string(),
"changelog.path" => config.changelog.path.clone(),
"changelog.auto_generate" => config.changelog.auto_generate.to_string(),
"language.output_language" => config.language.output_language.clone(),
"language.keep_types_english" => config.language.keep_types_english.to_string(),
"language.keep_changelog_types_english" => {
config.language.keep_changelog_types_english.to_string()
}
_ => bail!("Unknown configuration key: {}", key),
};

View File

@@ -331,6 +331,17 @@ impl InitCommand {
.default(ssh_dir.join("id_rsa").display().to_string())
.interact_text()?;
let pub_key_path: String = Input::new()
.with_prompt("SSH public key path (optional, leave empty to auto-detect)")
.default(ssh_dir.join("id_rsa.pub").display().to_string())
.allow_empty(true)
.interact_text()?;
let public_key_path = if pub_key_path.is_empty() {
None
} else {
Some(PathBuf::from(pub_key_path))
};
let has_passphrase = Confirm::new()
.with_prompt(messages.has_passphrase())
.default(false)
@@ -342,13 +353,38 @@ impl InitCommand {
None
};
let agent_forwarding = Confirm::new()
.with_prompt("Enable SSH agent forwarding (-A)?")
.default(false)
.interact()?;
let known_hosts: String = Input::new()
.with_prompt("Custom known_hosts file path (optional)")
.allow_empty(true)
.interact_text()?;
let known_hosts_file = if known_hosts.is_empty() {
None
} else {
Some(PathBuf::from(known_hosts))
};
let custom_cmd: String = Input::new()
.with_prompt("Custom SSH command (optional, overrides all other SSH settings)")
.allow_empty(true)
.interact_text()?;
let ssh_command = if custom_cmd.is_empty() {
None
} else {
Some(custom_cmd)
};
Ok(SshConfig {
private_key_path: Some(PathBuf::from(key_path)),
public_key_path: None,
public_key_path,
passphrase,
agent_forwarding: false,
ssh_command: None,
known_hosts_file: None,
agent_forwarding,
ssh_command,
known_hosts_file,
})
}

View File

@@ -345,6 +345,13 @@ impl ProfileCommand {
if profile.has_gpg() {
println!(" {} GPG configured", "🔒".to_string().dimmed());
}
if profile.signing_key().is_some() {
println!(
" {} Signing key: {}",
"🔏".to_string().dimmed(),
profile.signing_key().unwrap().dimmed()
);
}
if profile.has_tokens() {
println!(
" {} {} token(s)",
@@ -596,11 +603,64 @@ impl ProfileCommand {
println!("Organization: {}", org);
}
if let Some(ref key) = profile.signing_key {
println!("Signing key: {}", key);
}
// Profile settings
println!("\n{}", "Settings:".bold());
println!(
" Auto-sign commits: {}",
if profile.settings.auto_sign_commits {
"yes"
} else {
"no"
}
);
println!(
" Auto-sign tags: {}",
if profile.settings.auto_sign_tags {
"yes"
} else {
"no"
}
);
if let Some(ref fmt) = profile.settings.default_commit_format {
println!(" Default commit format: {}", fmt.to_string());
}
if !profile.settings.repo_patterns.is_empty() {
println!(" Repo patterns: {:?}", profile.settings.repo_patterns);
}
if let Some(ref provider) = profile.settings.llm_provider {
println!(" Preferred LLM: {}", provider);
}
if let Some(ref template) = profile.settings.commit_template {
println!(" Commit template: {}", template);
}
if let Some(ref ssh) = profile.ssh {
println!("\n{}", "SSH Configuration:".bold());
if let Some(ref path) = ssh.private_key_path {
println!(" Private key: {:?}", path);
}
if let Some(ref path) = ssh.public_key_path {
println!(" Public key: {:?}", path);
} else if let Some(ref path) = ssh.effective_public_key_path() {
println!(" Public key: {:?} (auto-detected)", path);
}
println!(
" Agent forwarding: {}",
if ssh.agent_forwarding { "yes" } else { "no" }
);
if let Some(ref cmd) = ssh.ssh_command {
println!(" Custom command: {}", cmd);
}
if let Some(ref kh) = ssh.known_hosts_file {
println!(" Known hosts file: {:?}", kh);
}
if ssh.passphrase.is_some() {
println!(" Passphrase: [set]");
}
}
if let Some(ref gpg) = profile.gpg {
@@ -749,9 +809,9 @@ impl ProfileCommand {
}
async fn edit_profile(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
let manager = self.get_manager(config_path)?;
let profile = manager
let mut profile = manager
.get_profile(name)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", name))?
.clone();
@@ -772,16 +832,54 @@ impl ProfileCommand {
})
.interact_text()?;
let mut new_profile = GitProfile::new(name.to_string(), user_name, user_email);
new_profile.description = profile.description;
new_profile.is_work = profile.is_work;
new_profile.organization = profile.organization;
new_profile.ssh = profile.ssh;
new_profile.gpg = profile.gpg;
new_profile.tokens = profile.tokens;
new_profile.usage = profile.usage;
profile.user_name = user_name;
profile.user_email = user_email;
manager.update_profile(name, new_profile)?;
// Sub-menu loop for optional configuration
loop {
println!();
let options = vec![
"Done / Save changes",
"Edit SSH configuration",
"Edit GPG configuration",
"Edit signing preferences",
"Manage tokens",
];
let selection = Select::new()
.with_prompt("What would you like to edit?")
.items(&options)
.default(0)
.interact()?;
match selection {
0 => break,
1 => {
profile.ssh = Some(self.setup_ssh_interactive().await?);
}
2 => {
profile.gpg = Some(self.setup_gpg_interactive().await?);
}
3 => {
profile.settings.auto_sign_commits = Confirm::new()
.with_prompt("Auto-sign commits?")
.default(profile.settings.auto_sign_commits)
.interact()?;
profile.settings.auto_sign_tags = Confirm::new()
.with_prompt("Auto-sign tags?")
.default(profile.settings.auto_sign_tags)
.interact()?;
}
4 => {
self.edit_tokens_interactive(&mut profile, &manager).await?;
}
_ => unreachable!(),
}
}
// Reload manager as mut to save
let config_path = manager.path().to_path_buf();
let mut manager = ConfigManager::with_path(&config_path)?;
manager.update_profile(name, profile)?;
manager.save()?;
println!("{} Profile '{}' updated", "".green(), name);
@@ -789,6 +887,50 @@ impl ProfileCommand {
Ok(())
}
async fn edit_tokens_interactive(
&self,
profile: &mut GitProfile,
manager: &ConfigManager,
) -> Result<()> {
loop {
println!();
let mut options: Vec<String> = profile
.tokens
.keys()
.map(|s| format!("Remove token: {}", s))
.collect();
options.push("Add new token".to_string());
options.push("Back".to_string());
let selection = Select::new()
.with_prompt(format!("Manage tokens for '{}'", profile.name))
.items(&options)
.default(options.len() - 1)
.interact()?;
if selection == options.len() - 1 {
break;
}
if selection < profile.tokens.len() {
let service: String = profile
.tokens
.keys()
.nth(selection)
.unwrap()
.clone();
profile.remove_token(&service);
println!(
"{} Token '{}' removed",
"".green(),
service.cyan()
);
} else {
self.setup_token_interactive(profile, manager).await?;
}
}
Ok(())
}
async fn set_default(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
@@ -1231,18 +1373,56 @@ impl ProfileCommand {
.map(|h| h.join(".ssh"))
.unwrap_or_else(|| PathBuf::from("~/.ssh"));
println!("\n{}", "SSH Configuration".bold());
let key_path: String = Input::new()
.with_prompt("SSH private key path")
.default(ssh_dir.join("id_rsa").display().to_string())
.interact_text()?;
let pub_key_path: String = Input::new()
.with_prompt("SSH public key path (optional, leave empty to auto-detect)")
.default(ssh_dir.join("id_rsa.pub").display().to_string())
.allow_empty(true)
.interact_text()?;
let public_key_path = if pub_key_path.is_empty() {
None
} else {
Some(PathBuf::from(pub_key_path))
};
let agent_forwarding = Confirm::new()
.with_prompt("Enable SSH agent forwarding (-A)?")
.default(false)
.interact()?;
let known_hosts: String = Input::new()
.with_prompt("Custom known_hosts file path (optional)")
.allow_empty(true)
.interact_text()?;
let known_hosts_file = if known_hosts.is_empty() {
None
} else {
Some(PathBuf::from(known_hosts))
};
let custom_cmd: String = Input::new()
.with_prompt("Custom SSH command (optional, overrides all other SSH settings)")
.allow_empty(true)
.interact_text()?;
let ssh_command = if custom_cmd.is_empty() {
None
} else {
Some(custom_cmd)
};
Ok(SshConfig {
private_key_path: Some(PathBuf::from(key_path)),
public_key_path: None,
public_key_path,
passphrase: None,
agent_forwarding: false,
ssh_command: None,
known_hosts_file: None,
agent_forwarding,
ssh_command,
known_hosts_file,
})
}

View File

@@ -319,7 +319,7 @@ impl TagCommand {
println!("{}", messages.ai_generating_tag(commits.len()));
let generator = ContentGenerator::new_with_think(&manager, self.think).await?;
let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?;
generator
.generate_tag_message(version, &commits, language)
.await