From 14ebb6857a3259df90ae97bfb43633a54f3a54bc Mon Sep 17 00:00:00 2001 From: SidneyZhang Date: Wed, 3 Jun 2026 15:20:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(commit):=20=E6=B7=BB=E5=8A=A0=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E6=B6=88=E6=81=AF=E6=A8=A1=E6=9D=BF=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 config 命令中未使用的 List 子命令及相关显示字段 - 统一 ChangelogCommand 和 CommitCommand 的 ContentGenerator 初始化方式 --- Cargo.toml | 2 +- RAODMAP.md | 8 +- README.md | 44 +---- examples/config.example.toml | 44 +---- readme_zh.md | 44 +---- src/commands/changelog.rs | 2 +- src/commands/commit.rs | 6 +- src/commands/config.rs | 289 +++------------------------- src/commands/init.rs | 44 ++++- src/commands/profile.rs | 210 ++++++++++++++++++-- src/commands/tag.rs | 2 +- src/config/manager.rs | 16 ++ src/config/mod.rs | 175 +---------------- src/config/profile.rs | 214 ++++++++++++++------ src/generator/mod.rs | 18 +- src/llm/mod.rs | 12 +- tests/config_export_import_tests.rs | 17 -- 17 files changed, 494 insertions(+), 653 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 60c1612..4fc41d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "quicommit" -version = "0.3.1" +version = "0.3.2" edition = "2024" authors = ["Sidney Zhang "] description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation(alpha version)" diff --git a/RAODMAP.md b/RAODMAP.md index e34bc80..ef36a55 100644 --- a/RAODMAP.md +++ b/RAODMAP.md @@ -88,10 +88,10 @@ 提升 AI 生成提交信息、标签说明和变更日志时的用户体验。 -- [x] **流式输出与实时反馈** - - 支持 SSE(Server-Sent Events)流式生成 - - 终端打字机效果实时显示生成内容 - - 流式生成过程中支持 `Ctrl+C` 中断 +- [ ] **流式输出与实时反馈** + - [x] 支持 SSE(Server-Sent Events)流式生成 + - [ ]终端打字机效果实时显示生成内容 + - [ ]流式生成过程中支持 `Ctrl+C` 中断 - [ ] **生成质量提升** - 基于 commitlint 规则的后校验与自动修正 diff --git a/README.md b/README.md index 57a766f..54763eb 100644 --- a/README.md +++ b/README.md @@ -332,58 +332,25 @@ use_agent = true [llm] provider = "ollama" +model = "llama2" +# base_url = "http://localhost:11434" max_tokens = 500 temperature = 0.7 timeout = 30 - -[llm.ollama] -url = "http://localhost:11434" -model = "llama2" - -[llm.openai] -model = "gpt-4" -base_url = "https://api.openai.com/v1" - -[llm.anthropic] -model = "claude-3-sonnet-20240229" - -[llm.kimi] -model = "moonshot-v1-8k" - -[llm.deepseek] -model = "deepseek-chat" - -[llm.openrouter] -model = "openai/gpt-4" +api_key_storage = "keyring" +thinking_enabled = false [commit] format = "conventional" auto_generate = true -allow_empty = false -gpg_sign = false -max_subject_length = 100 -require_scope = false -require_body = false -body_required_types = ["feat", "fix"] [tag] version_prefix = "v" auto_generate = true -gpg_sign = false -include_changelog = true [changelog] path = "CHANGELOG.md" auto_generate = true -format = "keep-a-changelog" -include_hashes = false -include_authors = false -group_by_type = true - -[theme] -colors = true -icons = true -date_format = "%Y-%m-%d" [repo_profiles] "/path/to/work/project" = "work" @@ -402,9 +369,6 @@ date_format = "%Y-%m-%d" ```bash # View current configuration -quicommit config list - -# Show configuration details quicommit config show # Edit configuration file diff --git a/examples/config.example.toml b/examples/config.example.toml index 49077b6..013ae4b 100644 --- a/examples/config.example.toml +++ b/examples/config.example.toml @@ -46,61 +46,35 @@ use_agent = true # LLM Configuration [llm] -# Provider: ollama, openai, or anthropic +# Provider: ollama, openai, anthropic, kimi, deepseek, openrouter provider = "ollama" +# Model name (provider-appropriate) +model = "llama2" +# API base URL (optional, provider default will be used if not set) +# base_url = "http://localhost:11434" max_tokens = 500 temperature = 0.7 timeout = 30 - -# Ollama settings (local LLM) -[llm.ollama] -url = "http://localhost:11434" -model = "llama2" - -# OpenAI settings -[llm.openai] -# api_key = "sk-..." # Set via: quicommit config set-openai-key -model = "gpt-4" -base_url = "https://api.openai.com/v1" - -# Anthropic settings -[llm.anthropic] -# api_key = "sk-ant-..." # Set via: quicommit config set-anthropic-key -model = "claude-3-sonnet-20240229" +# API key storage: keyring, config, environment +api_key_storage = "keyring" +# Enable thinking/reasoning mode (deepseek, kimi, anthropic) +thinking_enabled = false # Commit settings [commit] # Format: conventional or commitlint format = "conventional" auto_generate = true -allow_empty = false -gpg_sign = false -max_subject_length = 100 -require_scope = false -require_body = false -body_required_types = ["feat", "fix"] # Tag settings [tag] version_prefix = "v" auto_generate = true -gpg_sign = false -include_changelog = true # Changelog settings [changelog] path = "CHANGELOG.md" auto_generate = true -format = "keep-a-changelog" # or "github-releases" -include_hashes = false -include_authors = false -group_by_type = true - -# Theme settings -[theme] -colors = true -icons = true -date_format = "%Y-%m-%d" # Repository-specific profile mappings # [repo_profiles] diff --git a/readme_zh.md b/readme_zh.md index f97cb46..215289d 100644 --- a/readme_zh.md +++ b/readme_zh.md @@ -331,58 +331,25 @@ use_agent = true [llm] provider = "ollama" +model = "llama2" +# base_url = "http://localhost:11434" max_tokens = 500 temperature = 0.7 timeout = 30 - -[llm.ollama] -url = "http://localhost:11434" -model = "llama2" - -[llm.openai] -model = "gpt-4" -base_url = "https://api.openai.com/v1" - -[llm.anthropic] -model = "claude-3-sonnet-20240229" - -[llm.kimi] -model = "moonshot-v1-8k" - -[llm.deepseek] -model = "deepseek-chat" - -[llm.openrouter] -model = "openai/gpt-4" +api_key_storage = "keyring" +thinking_enabled = false [commit] format = "conventional" auto_generate = true -allow_empty = false -gpg_sign = false -max_subject_length = 100 -require_scope = false -require_body = false -body_required_types = ["feat", "fix"] [tag] version_prefix = "v" auto_generate = true -gpg_sign = false -include_changelog = true [changelog] path = "CHANGELOG.md" auto_generate = true -format = "keep-a-changelog" -include_hashes = false -include_authors = false -group_by_type = true - -[theme] -colors = true -icons = true -date_format = "%Y-%m-%d" [repo_profiles] "/path/to/work/project" = "work" @@ -401,9 +368,6 @@ date_format = "%Y-%m-%d" ```bash # 查看当前配置 -quicommit config list - -# 显示配置详情 quicommit config show # 编辑配置文件 diff --git a/src/commands/changelog.rs b/src/commands/changelog.rs index a468a51..897bd76 100644 --- a/src/commands/changelog.rs +++ b/src/commands/changelog.rs @@ -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 diff --git a/src/commands/commit.rs b/src/commands/commit.rs index 51e9745..c3f5b18 100644 --- a/src/commands/commit.rs +++ b/src/commands/commit.rs @@ -280,7 +280,11 @@ impl CommitCommand { ) -> Result { 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.")?; diff --git a/src/commands/config.rs b/src/commands/config.rs index a7f6eb1..37b9214 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -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) -> 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) -> 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), }; diff --git a/src/commands/init.rs b/src/commands/init.rs index ab8b504..68a0c66 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -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, }) } diff --git a/src/commands/profile.rs b/src/commands/profile.rs index 358e7a8..41a0d7b 100644 --- a/src/commands/profile.rs +++ b/src/commands/profile.rs @@ -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) -> 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 = 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) -> 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, }) } diff --git a/src/commands/tag.rs b/src/commands/tag.rs index cfce000..f908f4d 100644 --- a/src/commands/tag.rs +++ b/src/commands/tag.rs @@ -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 diff --git a/src/config/manager.rs b/src/config/manager.rs index 1aaf20c..6a80afe 100644 --- a/src/config/manager.rs +++ b/src/config/manager.rs @@ -386,6 +386,22 @@ impl ConfigManager { self.config.repo_profiles.get(repo_path) } + /// Find profiles whose repo_patterns match the given repo path + pub fn match_profiles_by_repo_pattern(&self, repo_path: &str) -> Vec<&GitProfile> { + self.config + .profiles + .values() + .filter(|p| { + p.settings.repo_patterns.iter().any(|pattern| { + let trimmed = pattern.trim_matches('*'); + repo_path.ends_with(trimmed) + || repo_path.starts_with(trimmed) + || repo_path == trimmed + }) + }) + .collect() + } + // LLM configuration /// Get LLM provider diff --git a/src/config/mod.rs b/src/config/mod.rs index 3830235..d19cc9d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -17,10 +17,11 @@ pub struct AppConfig { pub version: String, /// Default profile name + #[serde(skip_serializing_if = "Option::is_none")] pub default_profile: Option, /// All configured profiles - #[serde(default)] + #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub profiles: HashMap, /// LLM configuration @@ -40,17 +41,9 @@ pub struct AppConfig { pub changelog: ChangelogConfig, /// Repository-specific profile mappings - #[serde(default)] + #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub repo_profiles: HashMap, - /// Whether to encrypt sensitive data - #[serde(default = "default_true")] - pub encrypt_sensitive: bool, - - /// Theme settings - #[serde(default)] - pub theme: ThemeConfig, - /// Language settings #[serde(default)] pub language: LanguageConfig, @@ -67,8 +60,6 @@ impl Default for AppConfig { tag: TagConfig::default(), changelog: ChangelogConfig::default(), repo_profiles: HashMap::new(), - encrypt_sensitive: true, - theme: ThemeConfig::default(), language: LanguageConfig::default(), } } @@ -81,11 +72,12 @@ pub struct LlmConfig { #[serde(default = "default_llm_provider")] pub provider: String, - /// Model to use (stored in config, not in keyring) + /// Model to use #[serde(default = "default_model")] pub model: String, /// API base URL (optional, will use provider default if not set) + #[serde(skip_serializing_if = "Option::is_none")] pub base_url: Option, /// Maximum tokens for generation @@ -104,8 +96,8 @@ pub struct LlmConfig { #[serde(default = "default_api_key_storage")] pub api_key_storage: String, - /// API key (stored in config for fallback, encrypted if encrypt_sensitive is true) - #[serde(default)] + /// API key (stored in config for fallback) + #[serde(default, skip_serializing_if = "Option::is_none")] pub api_key: Option, /// Enable thinking/reasoning mode (deepseek, kimi, anthropic) @@ -113,7 +105,7 @@ pub struct LlmConfig { pub thinking_enabled: bool, /// Budget tokens for thinking mode (Anthropic Claude 4) - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub thinking_budget_tokens: Option, } @@ -148,33 +140,6 @@ pub struct CommitConfig { /// Enable AI generation by default #[serde(default = "default_true")] pub auto_generate: bool, - - /// Allow empty commits - #[serde(default)] - pub allow_empty: bool, - - /// Sign commits with GPG - #[serde(default)] - pub gpg_sign: bool, - - /// Default scope (optional) - pub default_scope: Option, - - /// Maximum subject length - #[serde(default = "default_max_subject_length")] - pub max_subject_length: usize, - - /// Require scope - #[serde(default)] - pub require_scope: bool, - - /// Require body for certain types - #[serde(default)] - pub require_body: bool, - - /// Types that require body - #[serde(default = "default_body_required_types")] - pub body_required_types: Vec, } impl Default for CommitConfig { @@ -182,13 +147,6 @@ impl Default for CommitConfig { Self { format: default_commit_format(), auto_generate: true, - allow_empty: false, - gpg_sign: false, - default_scope: None, - max_subject_length: default_max_subject_length(), - require_scope: false, - require_body: false, - body_required_types: default_body_required_types(), } } } @@ -220,18 +178,6 @@ pub struct TagConfig { /// Enable AI generation for tag messages #[serde(default = "default_true")] pub auto_generate: bool, - - /// Sign tags with GPG - #[serde(default)] - pub gpg_sign: bool, - - /// Include changelog in annotated tags - #[serde(default = "default_true")] - pub include_changelog: bool, - - /// Default annotation template - #[serde(default)] - pub annotation_template: Option, } impl Default for TagConfig { @@ -239,9 +185,6 @@ impl Default for TagConfig { Self { version_prefix: default_version_prefix(), auto_generate: true, - gpg_sign: false, - include_changelog: true, - annotation_template: None, } } } @@ -256,26 +199,6 @@ pub struct ChangelogConfig { /// Enable AI generation for changelog entries #[serde(default = "default_true")] pub auto_generate: bool, - - /// Changelog format - #[serde(default = "default_changelog_format")] - pub format: ChangelogFormat, - - /// Include commit hashes - #[serde(default)] - pub include_hashes: bool, - - /// Include authors - #[serde(default)] - pub include_authors: bool, - - /// Group by type - #[serde(default = "default_true")] - pub group_by_type: bool, - - /// Custom categories - #[serde(default)] - pub custom_categories: Vec, } impl Default for ChangelogConfig { @@ -283,56 +206,6 @@ impl Default for ChangelogConfig { Self { path: default_changelog_path(), auto_generate: true, - format: default_changelog_format(), - include_hashes: false, - include_authors: false, - group_by_type: true, - custom_categories: vec![], - } - } -} - -/// Changelog format -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum ChangelogFormat { - KeepAChangelog, - GitHubReleases, - Custom, -} - -/// Changelog category mapping -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChangelogCategory { - /// Category title - pub title: String, - - /// Commit types included in this category - pub types: Vec, -} - -/// Theme configuration -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ThemeConfig { - /// Enable colors - #[serde(default = "default_true")] - pub colors: bool, - - /// Enable icons - #[serde(default = "default_true")] - pub icons: bool, - - /// Preferred date format - #[serde(default = "default_date_format")] - pub date_format: String, -} - -impl Default for ThemeConfig { - fn default() -> Self { - Self { - colors: true, - icons: true, - date_format: default_date_format(), } } } @@ -415,6 +288,7 @@ impl Language { } // Default value functions + fn default_version() -> String { "1".to_string() } @@ -447,14 +321,6 @@ fn default_commit_format() -> CommitFormat { CommitFormat::Conventional } -fn default_max_subject_length() -> usize { - 100 -} - -fn default_body_required_types() -> Vec { - vec!["feat".to_string(), "fix".to_string()] -} - fn default_version_prefix() -> String { "v".to_string() } @@ -463,14 +329,6 @@ fn default_changelog_path() -> String { "CHANGELOG.md".to_string() } -fn default_changelog_format() -> ChangelogFormat { - ChangelogFormat::KeepAChangelog -} - -fn default_date_format() -> String { - "%Y-%m-%d".to_string() -} - fn default_output_language() -> String { "en".to_string() } @@ -509,21 +367,6 @@ impl AppConfig { let config_dir = dirs::config_dir().context("Could not find config directory")?; Ok(config_dir.join("quicommit").join("config.toml")) } - - // /// Get profile for a repository - // pub fn get_profile_for_repo(&self, repo_path: &str) -> Option<&GitProfile> { - // let profile_name = self.repo_profiles.get(repo_path)?; - // self.profiles.get(profile_name) - // } - - // /// Set profile for a repository - // pub fn set_profile_for_repo(&mut self, repo_path: String, profile_name: String) -> Result<()> { - // if !self.profiles.contains_key(&profile_name) { - // anyhow::bail!("Profile '{}' does not exist", profile_name); - // } - // self.repo_profiles.insert(repo_path, profile_name); - // Ok(()) - // } } /// Encrypted PAT data for export diff --git a/src/config/profile.rs b/src/config/profile.rs index 398e7a9..33e59c4 100644 --- a/src/config/profile.rs +++ b/src/config/profile.rs @@ -124,6 +124,11 @@ impl GitProfile { .or_else(|| self.gpg.as_ref().map(|g| g.key_id.as_str())) } + /// Get the commit template if set + pub fn commit_template(&self) -> Option<&str> { + self.settings.commit_template.as_deref() + } + /// Add a token to the profile pub fn add_token(&mut self, service: String, token: TokenConfig) { self.tokens.insert(service, token); @@ -159,78 +164,120 @@ impl GitProfile { pub fn apply_to_repo(&self, repo: &git2::Repository) -> Result<()> { let mut config = repo.config()?; - config.set_str("user.name", &self.user_name)?; - config.set_str("user.email", &self.user_email)?; - - if let Some(key) = self.signing_key() { - config.set_str("user.signingkey", key)?; - - if self.settings.auto_sign_commits { - config.set_bool("commit.gpgsign", true)?; - } - - if self.settings.auto_sign_tags { - config.set_bool("tag.gpgsign", true)?; - } + // Clean up old managed keys that the new profile won't set + if self.ssh.as_ref().and_then(|s| s.git_ssh_command()).is_none() { + let _ = config.remove("core.sshCommand"); + } + if self.signing_key().is_none() { + let _ = config.remove("user.signingkey"); + let _ = config.remove("commit.gpgsign"); + let _ = config.remove("tag.gpgsign"); + } + if self.gpg.is_none() { + let _ = config.remove("gpg.program"); } - if let Some(ref ssh) = self.ssh - && let Some(ref key_path) = ssh.private_key_path - { - let path_str = key_path.display().to_string(); - #[cfg(target_os = "windows")] - { - config.set_str( - "core.sshCommand", - &format!("ssh -i \"{}\"", path_str.replace('\\', "/")), - )?; + // Apply new values; track whether we've written past name/email for rollback + let mut wrote_optional = false; + let result = (|| -> Result<()> { + config.set_str("user.name", &self.user_name)?; + config.set_str("user.email", &self.user_email)?; + + if let Some(ref gpg) = self.gpg { + config.set_str("gpg.program", &gpg.program)?; + wrote_optional = true; } - #[cfg(not(target_os = "windows"))] - { - config.set_str("core.sshCommand", &format!("ssh -i '{}'", path_str))?; + + if let Some(key) = self.signing_key() { + config.set_str("user.signingkey", key)?; + if self.settings.auto_sign_commits { + config.set_bool("commit.gpgsign", true)?; + } + if self.settings.auto_sign_tags { + config.set_bool("tag.gpgsign", true)?; + } + wrote_optional = true; } + + if let Some(ref ssh) = self.ssh { + if let Some(ssh_cmd) = ssh.git_ssh_command() { + config.set_str("core.sshCommand", &ssh_cmd)?; + wrote_optional = true; + } + } + + Ok(()) + })(); + + if result.is_err() && wrote_optional { + let _ = config.remove("core.sshCommand"); + let _ = config.remove("user.signingkey"); + let _ = config.remove("commit.gpgsign"); + let _ = config.remove("tag.gpgsign"); + let _ = config.remove("gpg.program"); } - Ok(()) + result } /// Apply this profile globally pub fn apply_global(&self) -> Result<()> { let mut config = git2::Config::open_default()?; - config.set_str("user.name", &self.user_name)?; - config.set_str("user.email", &self.user_email)?; - - if let Some(key) = self.signing_key() { - config.set_str("user.signingkey", key)?; - - if self.settings.auto_sign_commits { - config.set_bool("commit.gpgsign", true)?; - } - - if self.settings.auto_sign_tags { - config.set_bool("tag.gpgsign", true)?; - } + // Clean up old managed keys that the new profile won't set + if self.ssh.as_ref().and_then(|s| s.git_ssh_command()).is_none() { + let _ = config.remove("core.sshCommand"); + } + if self.signing_key().is_none() { + let _ = config.remove("user.signingkey"); + let _ = config.remove("commit.gpgsign"); + let _ = config.remove("tag.gpgsign"); + } + if self.gpg.is_none() { + let _ = config.remove("gpg.program"); } - if let Some(ref ssh) = self.ssh - && let Some(ref key_path) = ssh.private_key_path - { - let path_str = key_path.display().to_string(); - #[cfg(target_os = "windows")] - { - config.set_str( - "core.sshCommand", - &format!("ssh -i \"{}\"", path_str.replace('\\', "/")), - )?; + // Apply new values; track whether we've written past name/email for rollback + let mut wrote_optional = false; + let result = (|| -> Result<()> { + config.set_str("user.name", &self.user_name)?; + config.set_str("user.email", &self.user_email)?; + + if let Some(ref gpg) = self.gpg { + config.set_str("gpg.program", &gpg.program)?; + wrote_optional = true; } - #[cfg(not(target_os = "windows"))] - { - config.set_str("core.sshCommand", &format!("ssh -i '{}'", path_str))?; + + if let Some(key) = self.signing_key() { + config.set_str("user.signingkey", key)?; + if self.settings.auto_sign_commits { + config.set_bool("commit.gpgsign", true)?; + } + if self.settings.auto_sign_tags { + config.set_bool("tag.gpgsign", true)?; + } + wrote_optional = true; } + + if let Some(ref ssh) = self.ssh { + if let Some(ssh_cmd) = ssh.git_ssh_command() { + config.set_str("core.sshCommand", &ssh_cmd)?; + wrote_optional = true; + } + } + + Ok(()) + })(); + + if result.is_err() && wrote_optional { + let _ = config.remove("core.sshCommand"); + let _ = config.remove("user.signingkey"); + let _ = config.remove("commit.gpgsign"); + let _ = config.remove("tag.gpgsign"); + let _ = config.remove("gpg.program"); } - Ok(()) + result } /// Compare with current git configuration @@ -349,25 +396,72 @@ impl SshConfig { bail!("SSH public key does not exist: {:?}", path); } + if let Some(ref path) = self.known_hosts_file + && !path.exists() + { + bail!("SSH known_hosts file does not exist: {:?}", path); + } + Ok(()) } - /// Get SSH command for git + /// Get the effective public key path, deriving from private key if not explicitly set + pub fn effective_public_key_path(&self) -> Option { + self.public_key_path.clone().or_else(|| { + self.private_key_path.as_ref().map(|pk| { + let mut pub_path = pk.clone(); + let ext = pk + .extension() + .map(|e| format!("{}.pub", e.to_string_lossy())) + .unwrap_or_else(|| "pub".to_string()); + pub_path.set_extension(&ext); + pub_path + }) + }) + } + + /// Get the effective SSH command for git config + /// + /// Priority: custom `ssh_command` > constructed from key/agent/known_hosts pub fn git_ssh_command(&self) -> Option { if let Some(ref cmd) = self.ssh_command { - Some(cmd.clone()) - } else if let Some(ref key_path) = self.private_key_path { + return Some(cmd.clone()); + } + + let mut parts: Vec = vec!["ssh".to_string()]; + + if self.agent_forwarding { + parts.push("-A".to_string()); + } + + if let Some(ref key_path) = self.private_key_path { let path_str = key_path.display().to_string(); #[cfg(target_os = "windows")] { - Some(format!("ssh -i \"{}\"", path_str.replace('\\', "/"))) + parts.push(format!("-i \"{}\"", path_str.replace('\\', "/"))); } #[cfg(not(target_os = "windows"))] { - Some(format!("ssh -i '{}'", path_str)) + parts.push(format!("-i '{}'", path_str)); } - } else { + } + + if let Some(ref known_hosts) = self.known_hosts_file { + let kh_str = known_hosts.display().to_string(); + #[cfg(target_os = "windows")] + { + parts.push(format!("-o UserKnownHostsFile=\"{}\"", kh_str.replace('\\', "/"))); + } + #[cfg(not(target_os = "windows"))] + { + parts.push(format!("-o UserKnownHostsFile='{}'", kh_str)); + } + } + + if parts.len() == 1 { None + } else { + Some(parts.join(" ")) } } } diff --git a/src/generator/mod.rs b/src/generator/mod.rs index f91e187..fd95189 100644 --- a/src/generator/mod.rs +++ b/src/generator/mod.rs @@ -7,16 +7,21 @@ use anyhow::{Context, Result}; /// Content generator using LLM pub struct ContentGenerator { llm_client: LlmClient, + template: Option, } impl ContentGenerator { /// Create new content generator pub async fn new(manager: &ConfigManager) -> Result { - Self::new_with_think(manager, false).await + Self::new_with_think(manager, false, None).await } - /// Create new content generator with thinking override - pub async fn new_with_think(manager: &ConfigManager, think_override: bool) -> Result { + /// Create new content generator with thinking override and optional commit template + pub async fn new_with_think( + manager: &ConfigManager, + think_override: bool, + template: Option, + ) -> Result { let mut thinking_enabled = if think_override { true } else { @@ -42,7 +47,10 @@ impl ContentGenerator { anyhow::bail!("LLM provider '{}' is not available", manager.llm_provider()); } - Ok(Self { llm_client }) + Ok(Self { + llm_client, + template, + }) } fn supports_thinking(provider: &str) -> bool { @@ -66,7 +74,7 @@ impl ContentGenerator { }; self.llm_client - .generate_commit_message(&truncated_diff, format, language) + .generate_commit_message(&truncated_diff, format, language, self.template.as_deref()) .await } diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 94dfd1e..051cddc 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -201,8 +201,16 @@ impl LlmClient { diff: &str, format: crate::config::CommitFormat, language: Language, + template: Option<&str>, ) -> Result { - let system_prompt = get_commit_system_prompt(format, language); + let mut system_prompt = get_commit_system_prompt(format, language).to_string(); + + if let Some(tmpl) = template { + system_prompt.push_str(&format!( + "\n\n## Commit Message Template\nFollow this template structure:\n{}", + tmpl + )); + } // Add language instruction to the prompt let language_instruction = match language { @@ -218,7 +226,7 @@ impl LlmClient { let prompt = format!("{}{}", diff, language_instruction); let response = self .provider - .generate_with_system(system_prompt, &prompt) + .generate_with_system(&system_prompt, &prompt) .await?; self.parse_commit_response(&response, format) diff --git a/tests/config_export_import_tests.rs b/tests/config_export_import_tests.rs index 3a306a4..07107af 100644 --- a/tests/config_export_import_tests.rs +++ b/tests/config_export_import_tests.rs @@ -126,31 +126,14 @@ api_key_storage = "keyring" [commit] format = "conventional" auto_generate = true -allow_empty = false -gpg_sign = false -max_subject_length = 100 -require_scope = false -require_body = false -body_required_types = ["feat", "fix"] [tag] version_prefix = "v" auto_generate = true -gpg_sign = false -include_changelog = true [changelog] path = "CHANGELOG.md" auto_generate = true -format = "keep-a-changelog" -include_hashes = false -include_authors = false -group_by_type = true - -[theme] -colors = true -icons = true -date_format = "%Y-%m-%d" [language] output_language = "en"