1 Commits
v0.3.1 ... main

Author SHA1 Message Date
14ebb6857a feat(commit): 添加提交消息模板支持
- 移除 config 命令中未使用的 List 子命令及相关显示字段
- 统一 ChangelogCommand 和 CommitCommand 的 ContentGenerator 初始化方式
2026-06-03 15:20:50 +08:00
17 changed files with 494 additions and 653 deletions

View File

@@ -1,6 +1,6 @@
[package]
name = "quicommit"
version = "0.3.1"
version = "0.3.2"
edition = "2024"
authors = ["Sidney Zhang <zly@lyzhang.me>"]
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation(alpha version)"

View File

@@ -88,10 +88,10 @@
提升 AI 生成提交信息、标签说明和变更日志时的用户体验。
- [x] **流式输出与实时反馈**
- 支持 SSEServer-Sent Events流式生成
- 终端打字机效果实时显示生成内容
- 流式生成过程中支持 `Ctrl+C` 中断
- [ ] **流式输出与实时反馈**
- [x] 支持 SSEServer-Sent Events流式生成
- [ ]终端打字机效果实时显示生成内容
- [ ]流式生成过程中支持 `Ctrl+C` 中断
- [ ] **生成质量提升**
- 基于 commitlint 规则的后校验与自动修正

View File

@@ -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

View File

@@ -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]

View File

@@ -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
# 编辑配置文件

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

View File

@@ -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

View File

@@ -17,10 +17,11 @@ pub struct AppConfig {
pub version: String,
/// Default profile name
#[serde(skip_serializing_if = "Option::is_none")]
pub default_profile: Option<String>,
/// All configured profiles
#[serde(default)]
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub profiles: HashMap<String, GitProfile>,
/// 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<String, String>,
/// 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<String>,
/// 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<String>,
/// 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<u32>,
}
@@ -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<String>,
/// 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<String>,
}
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<String>,
}
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<ChangelogCategory>,
}
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<String>,
}
/// 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<String> {
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

View File

@@ -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<std::path::PathBuf> {
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<String> {
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<String> = 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(" "))
}
}
}

View File

@@ -7,16 +7,21 @@ use anyhow::{Context, Result};
/// Content generator using LLM
pub struct ContentGenerator {
llm_client: LlmClient,
template: Option<String>,
}
impl ContentGenerator {
/// Create new content generator
pub async fn new(manager: &ConfigManager) -> Result<Self> {
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<Self> {
/// Create new content generator with thinking override and optional commit template
pub async fn new_with_think(
manager: &ConfigManager,
think_override: bool,
template: Option<String>,
) -> Result<Self> {
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
}

View File

@@ -201,8 +201,16 @@ impl LlmClient {
diff: &str,
format: crate::config::CommitFormat,
language: Language,
template: Option<&str>,
) -> Result<GeneratedCommit> {
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)

View File

@@ -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"