feat(changelog): 添加 --no-generate 标志以支持纯模板生成

- 新增 `--no-generate` 参数,强制使用模板生成而非 AI
- 调整 `--yes` 参数仅跳过交互提示,不改变生成行为
- 统一使用工具函数替代 println 输出
This commit is contained in:
2026-08-21 13:57:25 +08:00
parent e6b8b344aa
commit a40c88c768
22 changed files with 5709 additions and 1473 deletions

View File

@@ -236,6 +236,10 @@ quicommit profile token
quicommit config set-llm ollama quicommit config set-llm ollama
quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2 quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2
> ⚠️ **Security note**: passing your API key as a CLI argument puts it in your shell
> history and process list. Prefer running `quicommit config set-api-key` (or
> `config set-llm`) without the key to enter it via a hidden prompt instead.
# Configure OpenAI # Configure OpenAI
quicommit config set-llm openai quicommit config set-llm openai
quicommit config set-api-key YOUR_API_KEY quicommit config set-api-key YOUR_API_KEY
@@ -329,7 +333,7 @@ quicommit config reset --force
| `--commitlint` | Use commitlint format | | `--commitlint` | Use commitlint format |
| `--no-verify` | Skip commit message verification | | `--no-verify` | Skip commit message verification |
| `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) | | `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) |
| `-y, --yes` | Skip confirmation | | `-y, --yes` | Skip confirmation prompts only (generation behavior unchanged) |
| `--push` | Push after committing | | `--push` | Push after committing |
| `--remote` | Specify remote repository (default: origin) | | `--remote` | Specify remote repository (default: origin) |
@@ -349,7 +353,7 @@ quicommit config reset --force
| `-r, --remote` | Specify remote repository (default: origin) | | `-r, --remote` | Specify remote repository (default: origin) |
| `--dry-run` | Dry run | | `--dry-run` | Dry run |
| `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) | | `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) |
| `-y, --yes` | Skip confirmation | | `-y, --yes` | Skip confirmation prompts only (generation behavior unchanged) |
### Changelog Options ### Changelog Options
@@ -366,7 +370,8 @@ quicommit config reset --force
| `--format` | Format (keep-a-changelog, github-releases) | | `--format` | Format (keep-a-changelog, github-releases) |
| `--dry-run` | Dry run (output to stdout) | | `--dry-run` | Dry run (output to stdout) |
| `--think` | Enable LLM thinking/reasoning mode (overrides config) | | `--think` | Enable LLM thinking/reasoning mode (overrides config) |
| `-y, --yes` | Skip confirmation | | `--no-generate` | Generate deterministically from template (no AI call) |
| `-y, --yes` | Skip confirmation prompts only (generation behavior unchanged) |
## Configuration File ## Configuration File
@@ -425,6 +430,9 @@ auto_generate = true
path = "CHANGELOG.md" path = "CHANGELOG.md"
auto_generate = true auto_generate = true
[output]
emoji = true
[repo_profiles] [repo_profiles]
"/path/to/work/project" = "work" "/path/to/work/project" = "work"
"/path/to/personal/project" = "personal" "/path/to/personal/project" = "personal"
@@ -436,7 +444,15 @@ auto_generate = true
|----------|-------------| |----------|-------------|
| `QUICOMMIT_CONFIG` | Configuration file path | | `QUICOMMIT_CONFIG` | Configuration file path |
| `EDITOR` | Default editor | | `EDITOR` | Default editor |
| `NO_COLOR` | Disable colored output | | `NO_COLOR` | Disable colored output and emoji decorations (any value) |
| `RUST_LOG` | Overrides `-v` log filtering (e.g. `debug`) |
## Global Options
- `-v, --verbose` (repeatable): `-v` shows info logs, `-vv` debug, `-vvv` trace.
- `--no-color`: disable colors **and** emoji decorations (highest priority).
- `--emoji` / `--no-emoji`: force emoji decorations on/off, overriding the `output.emoji` config value.
- Emoji decorations can also be configured via `config set output.emoji true|false`.
## Troubleshooting ## Troubleshooting
@@ -453,7 +469,7 @@ quicommit config set llm.provider ollama
# Get configuration value # Get configuration value
quicommit config get llm.provider quicommit config get llm.provider
# Set API key (stored in system keyring) # Set API key (stored in system keyring; omit the value for hidden prompt input)
quicommit config set-api-key YOUR_API_KEY quicommit config set-api-key YOUR_API_KEY
# Delete API key from keyring # Delete API key from keyring

View File

@@ -230,6 +230,10 @@ quicommit profile token
quicommit config set-llm ollama quicommit config set-llm ollama
quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2 quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2
> ⚠️ **安全提示**:把 API 密钥作为命令行参数传入会留在 shell history 与进程列表中。
> 建议直接运行 `quicommit config set-api-key`(或 `config set-llm`)不携带密钥,
> 以隐藏方式输入。
# 配置OpenAI # 配置OpenAI
quicommit config set-llm openai quicommit config set-llm openai
quicommit config set-api-key YOUR_API_KEY quicommit config set-api-key YOUR_API_KEY
@@ -323,7 +327,7 @@ quicommit config reset --force
| `--commitlint` | 使用commitlint格式 | | `--commitlint` | 使用commitlint格式 |
| `--no-verify` | 不验证提交信息 | | `--no-verify` | 不验证提交信息 |
| `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) | | `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) |
| `-y, --yes` | 跳过确认提示 | | `-y, --yes` | 跳过确认提示(生成行为不变) |
| `--push` | 提交后推送到远程 | | `--push` | 提交后推送到远程 |
| `--remote` | 指定远程仓库默认origin | | `--remote` | 指定远程仓库默认origin |
@@ -343,7 +347,7 @@ quicommit config reset --force
| `-r, --remote` | 指定远程仓库默认origin | | `-r, --remote` | 指定远程仓库默认origin |
| `--dry-run` | 试运行 | | `--dry-run` | 试运行 |
| `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) | | `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) |
| `-y, --yes` | 跳过确认提示 | | `-y, --yes` | 跳过确认提示(生成行为不变) |
### changelog命令选项 ### changelog命令选项
@@ -360,7 +364,8 @@ quicommit config reset --force
| `--format` | 格式keep-a-changelog、github-releases | | `--format` | 格式keep-a-changelog、github-releases |
| `--dry-run` | 试运行输出到stdout | | `--dry-run` | 试运行输出到stdout |
| `--think` | 启用 LLM 思考/推理模式(覆盖配置) | | `--think` | 启用 LLM 思考/推理模式(覆盖配置) |
| `-y, --yes` | 跳过确认提示 | | `--no-generate` | 使用模板确定性生成(不调用 AI |
| `-y, --yes` | 仅跳过确认提示(生成行为不变) |
## 配置文件 ## 配置文件
@@ -419,6 +424,9 @@ auto_generate = true
path = "CHANGELOG.md" path = "CHANGELOG.md"
auto_generate = true auto_generate = true
[output]
emoji = true
[repo_profiles] [repo_profiles]
"/path/to/work/project" = "work" "/path/to/work/project" = "work"
"/path/to/personal/project" = "personal" "/path/to/personal/project" = "personal"
@@ -430,7 +438,15 @@ auto_generate = true
|--------|------| |--------|------|
| `QUICOMMIT_CONFIG` | 配置文件路径 | | `QUICOMMIT_CONFIG` | 配置文件路径 |
| `EDITOR` | 默认编辑器 | | `EDITOR` | 默认编辑器 |
| `NO_COLOR` | 禁用彩色输出 | | `NO_COLOR` | 禁用彩色与 Emoji 装饰输出(任意值即生效) |
| `RUST_LOG` | 覆盖 `-v` 的日志过滤(例如 `debug` |
## 全局选项
- `-v, --verbose`(可叠加):`-v` 显示 info 日志、`-vv` debug、`-vvv` trace。
- `--no-color`:禁用彩色**与** Emoji 装饰(最高优先级)。
- `--emoji` / `--no-emoji`:强制开启/关闭 Emoji 装饰,覆盖 `output.emoji` 配置。
- Emoji 装饰也可通过 `quicommit config set output.emoji true|false` 配置。
## 故障排除 ## 故障排除
@@ -447,7 +463,7 @@ quicommit config set llm.provider ollama
# 获取配置值 # 获取配置值
quicommit config get llm.provider quicommit config get llm.provider
# 设置API密钥存储在系统密钥环中 # 设置API密钥存储在系统密钥环中;省略密钥值可通过隐藏提示输入
quicommit config set-api-key YOUR_API_KEY quicommit config set-api-key YOUR_API_KEY
# 从密钥环删除API密钥 # 从密钥环删除API密钥

View File

@@ -11,6 +11,7 @@ use crate::git::GitRepo;
use crate::git::find_repo; use crate::git::find_repo;
use crate::git::{CommitInfo, changelog::*}; use crate::git::{CommitInfo, changelog::*};
use crate::i18n::{Messages, translate_changelog_category}; use crate::i18n::{Messages, translate_changelog_category};
use crate::utils::{print_progress, print_success, print_warning};
/// Generate changelog /// Generate changelog
#[derive(Parser)] #[derive(Parser)]
@@ -60,7 +61,11 @@ pub struct ChangelogCommand {
#[arg(long)] #[arg(long)]
think: bool, think: bool,
/// Skip interactive prompts /// Generate deterministically from template (no AI call)
#[arg(long, conflicts_with = "generate")]
no_generate: bool,
/// Skip interactive prompts only (generation behavior unchanged)
#[arg(short = 'y', long)] #[arg(short = 'y', long)]
yes: bool, yes: bool,
} }
@@ -85,7 +90,7 @@ impl ChangelogCommand {
.unwrap_or_else(|| PathBuf::from(&config.changelog.path)); .unwrap_or_else(|| PathBuf::from(&config.changelog.path));
init_changelog(&path)?; init_changelog(&path)?;
println!("{}", messages.initialized_changelog(&format!("{:?}", path))); print_success(&messages.initialized_changelog(&path.display().to_string()));
return Ok(()); return Ok(());
} }
@@ -101,10 +106,7 @@ impl ChangelogCommand {
Some("keep") | Some("keep-a-changelog") => ChangelogFormat::KeepAChangelog, Some("keep") | Some("keep-a-changelog") => ChangelogFormat::KeepAChangelog,
Some("custom") => ChangelogFormat::Custom, Some("custom") => ChangelogFormat::Custom,
None => ChangelogFormat::KeepAChangelog, None => ChangelogFormat::KeepAChangelog,
Some(f) => bail!( Some(f) => bail!("{}", messages.unknown_changelog_format(f)),
"Unknown format: {}. Use: keep-a-changelog, github-releases",
f
),
}; };
// Get version // Get version
@@ -120,7 +122,7 @@ impl ChangelogCommand {
}; };
// Get commits // Get commits
println!("{}", messages.fetching_commits()); print_progress(messages.fetching_commits());
// Determine from_tag: use explicit --from, or auto-detect from changelog // Determine from_tag: use explicit --from, or auto-detect from changelog
let from_tag = self.resolve_from_tag(&repo, &output_path, &messages); let from_tag = self.resolve_from_tag(&repo, &output_path, &messages);
@@ -130,10 +132,10 @@ impl ChangelogCommand {
bail!("{}", messages.no_commits_found()); bail!("{}", messages.no_commits_found());
} }
println!("{}", messages.found_commits(commits.len())); print_success(&messages.found_commits(commits.len()));
// Generate changelog // Generate changelog
let changelog = if self.generate || (config.changelog.auto_generate && !self.yes) { let changelog = if self.generate || (config.changelog.auto_generate && !self.no_generate) {
self.generate_with_ai(&version, &commits, &messages).await? self.generate_with_ai(&version, &commits, &messages).await?
} else { } else {
self.generate_with_template(format, &version, &commits, language)? self.generate_with_template(format, &version, &commits, language)?
@@ -161,12 +163,12 @@ impl ChangelogCommand {
println!("{}", "".repeat(60)); println!("{}", "".repeat(60));
let confirm = Confirm::new() let confirm = Confirm::new()
.with_prompt(messages.write_to_file(&format!("{:?}", output_path))) .with_prompt(messages.write_to_file(&output_path.display().to_string()))
.default(true) .default(true)
.interact()?; .interact()?;
if !confirm { if !confirm {
println!("{}", messages.cancelled().yellow()); print_warning(messages.cancelled());
return Ok(()); return Ok(());
} }
} }
@@ -185,7 +187,11 @@ impl ChangelogCommand {
std::fs::write(&output_path, content)?; std::fs::write(&output_path, content)?;
} }
println!("{} {:?}", messages.changelog_written(), output_path); print_success(&format!(
"{} {}",
messages.changelog_written(),
output_path.display()
));
Ok(()) Ok(())
} }
@@ -221,12 +227,13 @@ impl ChangelogCommand {
let manager = ConfigManager::new()?; let manager = ConfigManager::new()?;
let language = manager.get_language().unwrap_or(Language::English); let language = manager.get_language().unwrap_or(Language::English);
println!("{}", messages.ai_generating_changelog());
let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?; let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?;
generator let spinner = crate::utils::Spinner::start(messages.ai_generating_changelog());
let result = generator
.generate_changelog_entry(version, commits, language) .generate_changelog_entry(version, commits, language)
.await .await;
spinner.finish_clear();
result
} }
fn generate_with_template( fn generate_with_template(

View File

@@ -11,6 +11,7 @@ use crate::git::commit::{CommitBuilder, create_date_commit_message};
use crate::git::{GitRepo, find_repo}; use crate::git::{GitRepo, find_repo};
use crate::i18n::Messages; use crate::i18n::Messages;
use crate::utils::validators::get_commit_types; use crate::utils::validators::get_commit_types;
use crate::utils::{print_progress, print_success, print_warning};
/// Generate and execute conventional commits /// Generate and execute conventional commits
#[derive(Parser)] #[derive(Parser)]
@@ -75,7 +76,7 @@ pub struct CommitCommand {
#[arg(short = 't', long)] #[arg(short = 't', long)]
think: bool, think: bool,
/// Skip interactive prompts /// Skip interactive prompts only (generation behavior unchanged)
#[arg(short = 'y', long)] #[arg(short = 'y', long)]
yes: bool, yes: bool,
@@ -121,13 +122,13 @@ impl CommitCommand {
// Auto-add if no files are staged and there are unstaged/untracked changes // Auto-add if no files are staged and there are unstaged/untracked changes
if status.staged == 0 && (status.unstaged > 0 || status.untracked > 0) && !self.all { if status.staged == 0 && (status.unstaged > 0 || status.untracked > 0) && !self.all {
println!("{}", messages.auto_stage_changes().yellow()); println!("{}", messages.auto_stage_changes().yellow());
let removed = repo.stage_all()?; let removed = repo.stage_all(&messages)?;
println!("{}", messages.staged_all().green()); println!("{}", messages.staged_all().green());
if !removed.is_empty() { if !removed.is_empty() {
println!( print_warning(&format!(
"{}", "Removed {} ignored files from staging:",
format!("Removed {} ignored files from staging:", removed.len()).yellow() removed.len()
); ));
for file in &removed { for file in &removed {
println!("{}", file); println!("{}", file);
} }
@@ -136,19 +137,19 @@ impl CommitCommand {
// Re-check status after staging to ensure changes are detected // Re-check status after staging to ensure changes are detected
let new_status = repo.status_summary()?; let new_status = repo.status_summary()?;
if new_status.staged == 0 { if new_status.staged == 0 {
bail!("Failed to stage changes. Please try running 'git add -A' manually."); bail!("{}", messages.failed_to_stage());
} }
} }
// Stage all if requested // Stage all if requested
if self.all { if self.all {
let removed = repo.stage_all()?; let removed = repo.stage_all(&messages)?;
println!("{}", messages.staged_all().green()); println!("{}", messages.staged_all().green());
if !removed.is_empty() { if !removed.is_empty() {
println!( print_warning(&format!(
"{}", "Removed {} ignored files from staging:",
format!("Removed {} ignored files from staging:", removed.len()).yellow() removed.len()
); ));
for file in &removed { for file in &removed {
println!("{}", file); println!("{}", file);
} }
@@ -194,29 +195,21 @@ impl CommitCommand {
.interact()?; .interact()?;
if !confirm { if !confirm {
println!("{}", messages.commit_cancelled().yellow()); print_warning(messages.commit_cancelled());
return Ok(()); return Ok(());
} }
} }
let result = if self.amend { let result = if self.amend {
if self.dry_run { if self.dry_run {
println!( println!("\n{}", messages.dry_run_commit_not_amended().yellow());
"\n{} {}",
messages.dry_run(),
"- commit not amended.".yellow()
);
return Ok(()); return Ok(());
} }
self.amend_commit(&repo, &commit_message)?; self.amend_commit(&repo, &commit_message)?;
None None
} else { } else {
if self.dry_run { if self.dry_run {
println!( println!("\n{}", messages.dry_run_commit_not_created().yellow());
"\n{} {}",
messages.dry_run(),
"- commit not created.".yellow()
);
return Ok(()); return Ok(());
} }
CommitBuilder::new() CommitBuilder::new()
@@ -226,30 +219,34 @@ impl CommitCommand {
}; };
if let Some(commit_oid) = result { if let Some(commit_oid) = result {
println!( print_success(&format!(
"{} {}", "{} {}",
messages.commit_created().green().bold(), messages.commit_created().green().bold(),
commit_oid.to_string()[..8].to_string().cyan() commit_oid.to_string()[..8].to_string().cyan()
); ));
} else { } else {
println!("{} successfully", messages.commit_amended().green().bold()); print_success(messages.commit_amended_successfully());
} }
// Push after commit if requested or ask user // Push after commit if requested or ask user
if self.push { if self.push || (!self.yes && !self.dry_run) {
println!("\n{}", messages.pushing_commit(&self.remote)); let branch = repo
repo.push(&self.remote, "HEAD")?; .current_branch()
println!("{}", messages.pushed_commit(&self.remote)); .unwrap_or_else(|_| "HEAD (detached)".to_string());
} else if !self.yes && !self.dry_run {
let should_push = Confirm::new() let should_push = if self.push {
.with_prompt(messages.push_after_commit()) true
.default(false) } else {
.interact()?; Confirm::new()
.with_prompt(messages.push_after_commit(&branch))
.default(false)
.interact()?
};
if should_push { if should_push {
println!("\n{}", messages.pushing_commit(&self.remote)); print_progress(&messages.pushing_commit(&self.remote, &branch));
repo.push(&self.remote, "HEAD")?; repo.push(&self.remote, "HEAD")?;
println!("{}", messages.pushed_commit(&self.remote)); print_success(&messages.pushed_commit(&self.remote, &branch));
} }
} }
@@ -306,21 +303,22 @@ impl CommitCommand {
.await .await
.context("Failed to initialize LLM. Use --manual for manual commit.")?; .context("Failed to initialize LLM. Use --manual for manual commit.")?;
println!("{}", messages.ai_analyzing()); let spinner = crate::utils::Spinner::start(messages.ai_analyzing());
let language = manager.get_language().unwrap_or(Language::English); let language = manager.get_language().unwrap_or(Language::English);
let generated = if self.yes { let generated = if self.yes {
generator generator
.generate_commit_from_repo(repo, format, language) .generate_commit_from_repo(repo, format, language)
.await? .await
} else { } else {
generator generator
.generate_commit_interactive(repo, format, language) .generate_commit_interactive(repo, format, language, messages)
.await? .await
}; };
spinner.finish_clear();
Ok(generated.to_conventional()) Ok(generated?.to_conventional())
} }
async fn create_interactive_commit( async fn create_interactive_commit(

File diff suppressed because it is too large Load Diff

View File

@@ -146,23 +146,26 @@ impl CredentialCommand {
let mut manager = Self::get_manager(&config_path)?; let mut manager = Self::get_manager(&config_path)?;
let profile_names: Vec<String> = manager let profile_names: Vec<String> = manager.list_profiles().into_iter().cloned().collect();
.list_profiles()
.into_iter()
.filter(|name| {
manager
.get_profile(name)
.map(|p| p.tokens.contains_key(&service))
.unwrap_or(false)
})
.cloned()
.collect();
for name in &profile_names { for name in &profile_names {
if let Err(e) = manager.remove_token_from_profile(name, &service) { let registered = manager
.get_profile(name)
.map(|p| p.tokens.contains_key(&service))
.unwrap_or(false);
if registered {
if let Err(e) = manager.remove_token_from_profile(name, &service) {
eprintln!(
"[quicommit credential] failed to erase PAT for '{}': {}",
name, e
);
}
} else if let Ok(true) = manager.delete_orphan_pat(name, &service) {
// Keyring PAT without a config token entry (issue 24).
eprintln!( eprintln!(
"[quicommit credential] failed to erase PAT for '{}': {}", "[quicommit credential] erased unregistered PAT for '{}' (service {}) from keyring",
name, e name, service
); );
} }
} }
@@ -189,8 +192,7 @@ impl CredentialAttributes {
let stdin = io::stdin(); let stdin = io::stdin();
let mut attrs = Self::default(); let mut attrs = Self::default();
for line in stdin.lock().lines() { for line in stdin.lock().lines() {
let line = let line = line.context("Failed to read credential attributes from stdin")?;
line.context("Failed to read credential attributes from stdin")?;
if line.is_empty() { if line.is_empty() {
break; break;
} }
@@ -313,10 +315,7 @@ fn find_pat_for_service(manager: &ConfigManager, service: &str) -> Option<(Strin
/// ///
/// Prefers a profile whose `user_name` or `user_email` matches the /// Prefers a profile whose `user_name` or `user_email` matches the
/// git-supplied username; otherwise falls back to the default profile. /// git-supplied username; otherwise falls back to the default profile.
fn find_profile_for_store( fn find_profile_for_store(manager: &ConfigManager, username: Option<&str>) -> Option<String> {
manager: &ConfigManager,
username: Option<&str>,
) -> Option<String> {
if let Some(username) = username { if let Some(username) = username {
for name in manager.list_profiles() { for name in manager.list_profiles() {
if let Some(profile) = manager.get_profile(name) { if let Some(profile) = manager.get_profile(name) {

View File

@@ -10,6 +10,7 @@ use crate::config::{GitProfile, Language};
use crate::i18n::Messages; use crate::i18n::Messages;
use crate::utils::keyring::{get_default_model, get_supported_providers, provider_needs_api_key}; use crate::utils::keyring::{get_default_model, get_supported_providers, provider_needs_api_key};
use crate::utils::validators::validate_email; use crate::utils::validators::validate_email;
use crate::utils::{print_success, print_warning};
/// Initialize quicommit configuration /// Initialize quicommit configuration
#[derive(Parser)] #[derive(Parser)]
@@ -34,19 +35,16 @@ impl InitCommand {
if config_path.exists() && !self.reset { if config_path.exists() && !self.reset {
if !self.yes { if !self.yes {
let overwrite = Confirm::new() let overwrite = Confirm::new()
.with_prompt("Configuration already exists. Overwrite?") .with_prompt(messages.config_exists_overwrite())
.default(false) .default(false)
.interact()?; .interact()?;
if !overwrite { if !overwrite {
println!("{}", "Initialization cancelled.".yellow()); print_warning(messages.init_cancelled());
return Ok(()); return Ok(());
} }
} else { } else {
println!( print_warning(messages.config_exists_use_reset());
"{}",
"Configuration already exists. Use --reset to overwrite.".yellow()
);
return Ok(()); return Ok(());
} }
} }
@@ -59,7 +57,7 @@ impl InitCommand {
let mut manager = ConfigManager::with_path_fresh(&config_path)?; let mut manager = ConfigManager::with_path_fresh(&config_path)?;
if self.yes { if self.yes {
self.quick_setup(&mut manager).await?; self.quick_setup(&mut manager, &messages).await?;
} else { } else {
self.interactive_setup(&mut manager).await?; self.interactive_setup(&mut manager).await?;
} }
@@ -69,27 +67,52 @@ impl InitCommand {
let language = manager.get_language().unwrap_or(Language::English); let language = manager.get_language().unwrap_or(Language::English);
let messages = Messages::new(language); let messages = Messages::new(language);
println!("{}", messages.init_success().bold().green()); print_success(messages.init_success());
println!("\n{}: {}", messages.config_file(), config_path.display()); println!("\n{}: {}", messages.config_file(), config_path.display());
println!("\n{}:", messages.next_steps()); println!("\n{}:", messages.next_steps());
println!(" 1. Create a profile: {}", "quicommit profile add".cyan()); println!(
println!(" 2. Configure LLM: {}", "quicommit config set-llm".cyan()); " 1. {}: {}",
println!(" 3. Start committing: {}", "quicommit commit".cyan()); messages.next_steps_create_profile(),
"quicommit profile add".cyan()
);
println!(
" 2. {}: {}",
messages.next_steps_configure_llm(),
"quicommit config set-llm".cyan()
);
println!(
" 3. {}: {}",
messages.next_steps_start_committing(),
"quicommit commit".cyan()
);
Ok(()) Ok(())
} }
async fn quick_setup(&self, manager: &mut ConfigManager) -> Result<()> { async fn quick_setup(&self, manager: &mut ConfigManager, messages: &Messages) -> Result<()> {
let git_config = git2::Config::open_default()?; let git_config = git2::Config::open_default()?;
let user_name = git_config let name_result = git_config.get_string("user.name");
.get_string("user.name") let email_result = git_config.get_string("user.email");
.unwrap_or_else(|_| "User".to_string()); let name_missing = name_result.is_err();
let user_email = git_config let email_missing = email_result.is_err();
.get_string("user.email") let user_name = name_result.unwrap_or_else(|_| "User".to_string());
.unwrap_or_else(|_| "user@example.com".to_string()); let user_email = email_result.unwrap_or_else(|_| "user@example.com".to_string());
let profile = GitProfile::new("default".to_string(), user_name, user_email); let profile = GitProfile::new("default".to_string(), user_name.clone(), user_email.clone());
println!("\n{}", messages.quick_setup_summary().bold());
println!(" {}: {}", messages.profile_label(), "default".cyan());
println!(
" {}: {} <{}>",
messages.identity_label(),
user_name,
user_email
);
if name_missing || email_missing {
print_warning(messages.identity_placeholder_warning());
}
println!(" {}: {}", messages.llm_provider_label(), "ollama".cyan());
manager.add_profile("default".to_string(), profile)?; manager.add_profile("default".to_string(), profile)?;
manager.set_default_profile(Some("default".to_string()))?; manager.set_default_profile(Some("default".to_string()))?;
@@ -231,11 +254,8 @@ impl InitCommand {
let keyring_available = keyring.is_available(); let keyring_available = keyring.is_available();
if !keyring_available { if !keyring_available {
println!( print_warning(messages.keyring_unavailable());
"\n{}", print_warning(&keyring.get_status_message());
"⚠ Keyring is not available on this system.".yellow()
);
println!("{}", keyring.get_status_message().yellow());
} }
let api_key = if provider_needs_api_key(&provider) { let api_key = if provider_needs_api_key(&provider) {
@@ -246,11 +266,7 @@ impl InitCommand {
.ok(); .ok();
if let Some(_key) = env_key { if let Some(_key) = env_key {
println!( print_success(messages.api_key_found_env());
"\n{} {}",
"".green(),
"Found API key in environment variable.".green()
);
None None
} else if keyring_available { } else if keyring_available {
let prompt = match provider.as_str() { let prompt = match provider.as_str() {
@@ -259,16 +275,13 @@ impl InitCommand {
"kimi" => messages.kimi_api_key(), "kimi" => messages.kimi_api_key(),
"deepseek" => messages.deepseek_api_key(), "deepseek" => messages.deepseek_api_key(),
"openrouter" => messages.openrouter_api_key(), "openrouter" => messages.openrouter_api_key(),
_ => "API Key", _ => messages.api_key_prompt_other(),
}; };
let key: String = Input::new().with_prompt(prompt).interact_text()?; let key: String = crate::utils::password_input(prompt)?;
Some(key) Some(key)
} else { } else {
println!( print_warning(messages.please_set_api_key_env());
"\n{}",
"Please set the QUICOMMIT_API_KEY environment variable.".yellow()
);
None None
} }
} else { } else {
@@ -277,24 +290,26 @@ impl InitCommand {
let default_model = get_default_model(&provider); let default_model = get_default_model(&provider);
let model: String = Input::new() let model: String = Input::new()
.with_prompt("Model name") .with_prompt(messages.model_name())
.default(default_model.to_string()) .default(default_model.to_string())
.interact_text()?; .interact_text()?;
let base_url: Option<String> = if provider == "ollama" { let base_url: Option<String> = if provider == "ollama" {
let url: String = Input::new() let url: String = Input::new()
.with_prompt("Ollama server URL") .with_prompt(messages.ollama_server_url())
.default("http://localhost:11434".to_string()) .default("http://localhost:11434".to_string())
.interact_text()?; .interact_text()?;
Some(url) Some(url)
} else { } else {
let use_custom_url = Confirm::new() let use_custom_url = Confirm::new()
.with_prompt("Use custom API base URL?") .with_prompt(messages.use_custom_base_url())
.default(false) .default(false)
.interact()?; .interact()?;
if use_custom_url { if use_custom_url {
let url: String = Input::new().with_prompt("Base URL").interact_text()?; let url: String = Input::new()
.with_prompt(messages.base_url_plain())
.interact_text()?;
Some(url) Some(url)
} else { } else {
None None
@@ -309,11 +324,7 @@ impl InitCommand {
&& provider_needs_api_key(&provider) && provider_needs_api_key(&provider)
{ {
manager.set_api_key(&key)?; manager.set_api_key(&key)?;
println!( print_success(messages.api_key_stored_keyring());
"\n{} {}",
"".green(),
"API key stored securely in system keyring.".green()
);
} }
Ok(()) Ok(())
@@ -332,7 +343,7 @@ impl InitCommand {
.interact_text()?; .interact_text()?;
let pub_key_path: String = Input::new() let pub_key_path: String = Input::new()
.with_prompt("SSH public key path (optional, leave empty to auto-detect)") .with_prompt(messages.ssh_public_key_path())
.default(ssh_dir.join("id_rsa.pub").display().to_string()) .default(ssh_dir.join("id_rsa.pub").display().to_string())
.allow_empty(true) .allow_empty(true)
.interact_text()?; .interact_text()?;
@@ -354,12 +365,12 @@ impl InitCommand {
}; };
let agent_forwarding = Confirm::new() let agent_forwarding = Confirm::new()
.with_prompt("Enable SSH agent forwarding (-A)?") .with_prompt(messages.ssh_agent_forwarding())
.default(false) .default(false)
.interact()?; .interact()?;
let known_hosts: String = Input::new() let known_hosts: String = Input::new()
.with_prompt("Custom known_hosts file path (optional)") .with_prompt(messages.known_hosts_path())
.allow_empty(true) .allow_empty(true)
.interact_text()?; .interact_text()?;
let known_hosts_file = if known_hosts.is_empty() { let known_hosts_file = if known_hosts.is_empty() {
@@ -369,7 +380,7 @@ impl InitCommand {
}; };
let custom_cmd: String = Input::new() let custom_cmd: String = Input::new()
.with_prompt("Custom SSH command (optional, overrides all other SSH settings)") .with_prompt(messages.custom_ssh_command())
.allow_empty(true) .allow_empty(true)
.interact_text()?; .interact_text()?;
let ssh_command = if custom_cmd.is_empty() { let ssh_command = if custom_cmd.is_empty() {

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ use crate::git::tag::{
}; };
use crate::git::{GitRepo, find_repo}; use crate::git::{GitRepo, find_repo};
use crate::i18n::Messages; use crate::i18n::Messages;
use crate::utils::{print_progress, print_success, print_warning};
/// Generate and create Git tags /// Generate and create Git tags
#[derive(Parser)] #[derive(Parser)]
@@ -61,7 +62,7 @@ pub struct TagCommand {
#[arg(short = 't', long)] #[arg(short = 't', long)]
think: bool, think: bool,
/// Skip interactive prompts /// Skip interactive prompts only (generation behavior unchanged)
#[arg(short = 'y', long)] #[arg(short = 'y', long)]
yes: bool, yes: bool,
@@ -115,11 +116,11 @@ impl TagCommand {
{ {
let version_str = tag_name.trim_start_matches('v'); let version_str = tag_name.trim_start_matches('v');
if let Err(e) = crate::utils::validators::validate_semver(version_str) { if let Err(e) = crate::utils::validators::validate_semver(version_str) {
println!("{}: {}", "Warning".yellow(), e); print_warning(&format!("{}: {}", messages.warning(), e));
if !self.yes { if !self.yes {
let proceed = Confirm::new() let proceed = Confirm::new()
.with_prompt("Proceed with this tag name anyway?") .with_prompt(messages.proceed_invalid_tag_name())
.default(true) .default(true)
.interact()?; .interact()?;
@@ -135,7 +136,7 @@ impl TagCommand {
None None
} else if let Some(msg) = &self.message { } else if let Some(msg) = &self.message {
Some(msg.clone()) Some(msg.clone())
} else if self.generate || (config.tag.auto_generate && !self.yes) { } else if self.generate || config.tag.auto_generate {
Some( Some(
self.generate_tag_message(&repo, &tag_name, &messages) self.generate_tag_message(&repo, &tag_name, &messages)
.await?, .await?,
@@ -143,7 +144,7 @@ impl TagCommand {
} else if !self.yes { } else if !self.yes {
Some(self.input_message_interactive(&tag_name, &messages)?) Some(self.input_message_interactive(&tag_name, &messages)?)
} else { } else {
Some(format!("Release {}", tag_name)) Some(messages.release_default(&tag_name))
}; };
// Show preview // Show preview
@@ -165,13 +166,13 @@ impl TagCommand {
.interact()?; .interact()?;
if !confirm { if !confirm {
println!("{}", messages.tag_cancelled().yellow()); print_warning(messages.tag_cancelled());
return Ok(()); return Ok(());
} }
} }
if self.dry_run { if self.dry_run {
println!("\n{} {}", messages.dry_run(), "- tag not created.".yellow()); println!("\n{}", messages.dry_run_tag_not_created().yellow());
return Ok(()); return Ok(());
} }
@@ -184,13 +185,13 @@ impl TagCommand {
builder.execute(&repo)?; builder.execute(&repo)?;
println!("{} {}", messages.tag_created().green(), tag_name.cyan()); print_success(&format!("{} {}", messages.tag_created(), tag_name.cyan()));
// Push if requested or ask user // Push if requested or ask user
if self.push { if self.push {
println!("{}", messages.pushing_tag(&self.remote)); print_progress(&messages.pushing_tag(&self.remote));
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?; repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
println!("{}", messages.pushed_tag(&self.remote)); print_success(&messages.pushed_tag(&self.remote));
} else if !self.yes && !self.dry_run { } else if !self.yes && !self.dry_run {
let should_push = Confirm::new() let should_push = Confirm::new()
.with_prompt(messages.push_after_tag()) .with_prompt(messages.push_after_tag())
@@ -198,9 +199,9 @@ impl TagCommand {
.interact()?; .interact()?;
if should_push { if should_push {
println!("{}", messages.pushing_tag(&self.remote)); print_progress(&messages.pushing_tag(&self.remote));
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?; repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
println!("{}", messages.pushed_tag(&self.remote)); print_success(&messages.pushed_tag(&self.remote));
} }
} }
@@ -323,15 +324,16 @@ impl TagCommand {
}; };
if commits.is_empty() { if commits.is_empty() {
return Ok(format!("Release {}", version)); return Ok(messages.release_default(version));
} }
println!("{}", messages.ai_generating_tag(commits.len()));
let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?; let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?;
generator let spinner = crate::utils::Spinner::start(&messages.ai_generating_tag(commits.len()));
let result = generator
.generate_tag_message(version, &commits, language) .generate_tag_message(version, &commits, language)
.await .await;
spinner.finish_clear();
result
} }
async fn auto_detect_version( async fn auto_detect_version(
@@ -418,7 +420,7 @@ impl TagCommand {
} }
fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result<String> { fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result<String> {
let default_msg = format!("Release {}", version); let default_msg = messages.release_default(version);
let use_editor = Confirm::new() let use_editor = Confirm::new()
.with_prompt(messages.open_editor()) .with_prompt(messages.open_editor())

View File

@@ -278,6 +278,22 @@ impl ConfigManager {
Ok(()) Ok(())
} }
/// Delete a PAT from the keyring even when no config token entry exists.
/// Returns true if a keyring entry was deleted (issue 24).
pub fn delete_orphan_pat(&self, profile_name: &str, service: &str) -> Result<bool> {
if let Some(profile) = self.get_profile(profile_name) {
let user_email = &profile.user_email;
if self.keyring.has_pat(profile_name, user_email, service) {
self.keyring.delete_pat(profile_name, user_email, service)?;
Ok(true)
} else {
Ok(false)
}
} else {
Ok(false)
}
}
/// Delete all PAT tokens for a profile (used when removing a profile) /// Delete all PAT tokens for a profile (used when removing a profile)
pub fn delete_all_pats_for_profile(&self, profile_name: &str) -> Result<()> { pub fn delete_all_pats_for_profile(&self, profile_name: &str) -> Result<()> {
if let Some(profile) = self.get_profile(profile_name) { if let Some(profile) = self.get_profile(profile_name) {
@@ -658,6 +674,17 @@ impl ConfigManager {
self.modified = true; self.modified = true;
} }
/// Check if emoji decorations are enabled
pub fn emoji_enabled(&self) -> bool {
self.config.output.emoji
}
/// Set emoji decorations flag
pub fn set_emoji_enabled(&mut self, enabled: bool) {
self.config.output.emoji = enabled;
self.modified = true;
}
/// Export configuration to TOML string /// Export configuration to TOML string
pub fn export(&self) -> Result<String> { pub fn export(&self) -> Result<String> {
toml::to_string_pretty(&self.config).context("Failed to serialize config") toml::to_string_pretty(&self.config).context("Failed to serialize config")

View File

@@ -47,6 +47,10 @@ pub struct AppConfig {
/// Language settings /// Language settings
#[serde(default)] #[serde(default)]
pub language: LanguageConfig, pub language: LanguageConfig,
/// Output settings
#[serde(default)]
pub output: OutputConfig,
} }
impl Default for AppConfig { impl Default for AppConfig {
@@ -61,6 +65,7 @@ impl Default for AppConfig {
changelog: ChangelogConfig::default(), changelog: ChangelogConfig::default(),
repo_profiles: HashMap::new(), repo_profiles: HashMap::new(),
language: LanguageConfig::default(), language: LanguageConfig::default(),
output: OutputConfig::default(),
} }
} }
} }
@@ -236,6 +241,20 @@ impl Default for LanguageConfig {
} }
} }
/// Output configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputConfig {
/// Show emoji/symbol decorations (✓/✗/⚠/→)
#[serde(default = "default_true")]
pub emoji: bool,
}
impl Default for OutputConfig {
fn default() -> Self {
Self { emoji: true }
}
}
/// Supported languages /// Supported languages
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Language { pub enum Language {

View File

@@ -1,8 +1,10 @@
use crate::config::manager::ConfigManager; use crate::config::manager::ConfigManager;
use crate::config::{CommitFormat, Language}; use crate::config::{CommitFormat, Language};
use crate::git::{CommitInfo, GitRepo}; use crate::git::{CommitInfo, GitRepo};
use crate::i18n::Messages;
use crate::llm::parsing::GeneratedCommit; use crate::llm::parsing::GeneratedCommit;
use crate::llm::rig::LlmClient; use crate::llm::rig::LlmClient;
use crate::utils::{eprint_warning, success_prefix};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
/// Content generator using LLM /// Content generator using LLM
@@ -33,11 +35,9 @@ impl ContentGenerator {
if thinking_enabled { if thinking_enabled {
let provider = manager.llm_provider(); let provider = manager.llm_provider();
if !Self::supports_thinking(provider) { if !Self::supports_thinking(provider) {
eprintln!( let language = manager.get_language().unwrap_or(Language::English);
"Warning: Provider '{}' does not support thinking mode. \ let messages = Messages::new(language);
Disabling thinking for this invocation.", eprint_warning(&messages.thinking_unsupported(provider));
provider
);
thinking_enabled = false; thinking_enabled = false;
} }
} }
@@ -156,6 +156,7 @@ impl ContentGenerator {
repo: &GitRepo, repo: &GitRepo,
format: CommitFormat, format: CommitFormat,
language: Language, language: Language,
messages: &Messages,
) -> Result<GeneratedCommit> { ) -> Result<GeneratedCommit> {
use dialoguer::Select; use dialoguer::Select;
@@ -167,33 +168,33 @@ impl ContentGenerator {
// Show diff summary // Show diff summary
let files = repo.get_staged_files()?; let files = repo.get_staged_files()?;
println!("\nStaged files ({}):", files.len()); println!("\n{}", messages.staged_files(files.len()));
for file in &files { for file in &files {
println!("{}", file); println!("{}", file);
} }
// Generate initial commit // Generate initial commit
println!("\nGenerating commit message..."); println!("\n{}", messages.generating_commit_message());
let mut generated = self let mut generated = self
.generate_commit_message(&diff, format, language) .generate_commit_message(&diff, format, language)
.await?; .await?;
loop { loop {
println!("\n{}", "".repeat(60)); println!("\n{}", "".repeat(60));
println!("Generated commit message:"); println!("{}", messages.generated_commit_message());
println!("{}", "".repeat(60)); println!("{}", "".repeat(60));
println!("{}", generated.to_conventional()); println!("{}", generated.to_conventional());
println!("{}", "".repeat(60)); println!("{}", "".repeat(60));
let options = vec![ let options = vec![
"✓ Accept and commit", format!("{} {}", success_prefix(), messages.accept_and_commit()),
"🔄 Regenerate", messages.regenerate().to_string(),
"✏️ Edit", messages.edit().to_string(),
"❌ Cancel", messages.cancel().to_string(),
]; ];
let selection = Select::new() let selection = Select::new()
.with_prompt("What would you like to do?") .with_prompt(messages.what_would_you_like_to_do())
.items(&options) .items(&options)
.default(0) .default(0)
.interact()?; .interact()?;
@@ -201,7 +202,7 @@ impl ContentGenerator {
match selection { match selection {
0 => return Ok(generated), 0 => return Ok(generated),
1 => { 1 => {
println!("Regenerating..."); println!("{}", messages.regenerating());
generated = self generated = self
.generate_commit_message(&diff, format, language) .generate_commit_message(&diff, format, language)
.await?; .await?;
@@ -210,7 +211,7 @@ impl ContentGenerator {
let edited = crate::utils::editor::edit_content(&generated.to_conventional())?; let edited = crate::utils::editor::edit_content(&generated.to_conventional())?;
generated = self.parse_edited_commit(&edited, format)?; generated = self.parse_edited_commit(&edited, format)?;
} }
3 => anyhow::bail!("Cancelled by user"), 3 => anyhow::bail!("{}", messages.cancelled_by_user()),
_ => {} _ => {}
} }
} }

View File

@@ -76,7 +76,7 @@ fn try_open_repo_with_git2(path: &Path) -> Result<Repository> {
.or_else(|_| Repository::discover(&normalized)) .or_else(|_| Repository::discover(&normalized))
.or_else(|_| Repository::open(&normalized)); .or_else(|_| Repository::open(&normalized));
repo.map_err(|e| anyhow::anyhow!("git2 failed: {}", e)) repo.map_err(|e| anyhow::anyhow!("Failed to open repository: {}", e))
} }
fn try_open_repo_with_git_cli(path: &Path) -> Result<Repository> { fn try_open_repo_with_git_cli(path: &Path) -> Result<Repository> {
@@ -642,7 +642,7 @@ impl GitRepo {
} }
/// Stage all changes including subdirectories, then remove ignored tracked files /// Stage all changes including subdirectories, then remove ignored tracked files
pub fn stage_all(&self) -> Result<Vec<String>> { pub fn stage_all(&self, messages: &crate::i18n::Messages) -> Result<Vec<String>> {
// Use git command for reliable staging (handles all edge cases) // Use git command for reliable staging (handles all edge cases)
let output = std::process::Command::new("git") let output = std::process::Command::new("git")
.args(["add", "-A"]) .args(["add", "-A"])
@@ -662,7 +662,11 @@ impl GitRepo {
match self.remove_ignored_from_index() { match self.remove_ignored_from_index() {
Ok(removed) => Ok(removed), Ok(removed) => Ok(removed),
Err(e) => { Err(e) => {
eprintln!("Warning: failed to clean ignored files from index: {}", e); crate::utils::eprint_warning(&format!(
"{}: {}",
messages.failed_clean_ignored(),
e
));
Ok(Vec::new()) Ok(Vec::new())
} }
} }
@@ -937,16 +941,6 @@ impl GitRepo {
Ok(()) Ok(())
} }
/// Create GPG signature for arbitrary content
fn create_gpg_signature_for_content(
&self,
_content: &str,
_gpg_program: &str,
_signing_key: &str,
) -> Result<String> {
Ok(String::new())
}
/// Delete a tag /// Delete a tag
pub fn delete_tag(&self, name: &str) -> Result<()> { pub fn delete_tag(&self, name: &str) -> Result<()> {
self.repo.tag_delete(name)?; self.repo.tag_delete(name)?;
@@ -1515,7 +1509,14 @@ mod tests {
let head = repo.repo.head().unwrap().peel_to_commit().unwrap(); let head = repo.repo.head().unwrap().peel_to_commit().unwrap();
// Create a lightweight tag // Create a lightweight tag
repo.repo.tag("v0.1.0", head.as_object(), &repo.repo.signature().unwrap(), "", false) repo.repo
.tag(
"v0.1.0",
head.as_object(),
&repo.repo.signature().unwrap(),
"",
false,
)
.unwrap(); .unwrap();
let tags = repo.get_tags().unwrap(); let tags = repo.get_tags().unwrap();
@@ -1541,7 +1542,13 @@ mod tests {
// Create lightweight tag // Create lightweight tag
repo.repo repo.repo
.tag("v0.1.0", head.as_object(), &repo.repo.signature().unwrap(), "", false) .tag(
"v0.1.0",
head.as_object(),
&repo.repo.signature().unwrap(),
"",
false,
)
.unwrap(); .unwrap();
let tags = repo.get_tags().unwrap(); let tags = repo.get_tags().unwrap();

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,3 @@
#![allow(dead_code)]
pub mod commands; pub mod commands;
pub mod config; pub mod config;
pub mod generator; pub mod generator;

View File

@@ -4,7 +4,7 @@
//! 全部 provider 接入并切换后,旧实现将被删除。 //! 全部 provider 接入并切换后,旧实现将被删除。
use crate::config::manager::ConfigManager; use crate::config::manager::ConfigManager;
use crate::llm::thinking::{create_console_thinking_state, ThinkingStateManager}; use crate::llm::thinking::{ThinkingStateManager, create_console_thinking_state};
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use rig_core::{ use rig_core::{
client::{CompletionClient, Nothing, VerifyClient}, client::{CompletionClient, Nothing, VerifyClient},
@@ -154,7 +154,8 @@ fn required_api_key(provider: &str, key: Option<String>) -> Result<String> {
} }
impl<H> LlmClient<H> { impl<H> LlmClient<H> {
/// 内部与测试构造入口。 /// 内部与测试构造入口(仅测试使用)
#[cfg(test)]
pub(crate) fn new( pub(crate) fn new(
backend: Backend<H>, backend: Backend<H>,
model: impl Into<String>, model: impl Into<String>,
@@ -332,7 +333,9 @@ where
let language_instruction = match language { let language_instruction = match language {
crate::config::Language::Chinese => "\n\n请用中文生成提交消息。", crate::config::Language::Chinese => "\n\n请用中文生成提交消息。",
crate::config::Language::Japanese => "\n\n日本語でコミットメッセージを生成してください。", crate::config::Language::Japanese => {
"\n\n日本語でコミットメッセージを生成してください。"
}
crate::config::Language::Korean => "\n\n한국어로 커밋 메시지를 생성하세요.", crate::config::Language::Korean => "\n\n한국어로 커밋 메시지를 생성하세요.",
crate::config::Language::Spanish => { crate::config::Language::Spanish => {
"\n\nPor favor, genera el mensaje de commit en español." "\n\nPor favor, genera el mensaje de commit en español."
@@ -465,7 +468,8 @@ where
let event = event.map_err(|e| map_completion_error(&self.provider, e))?; let event = event.map_err(|e| map_completion_error(&self.provider, e))?;
match event { match event {
StreamedAssistantContent::Text(chunk) => { StreamedAssistantContent::Text(chunk) => {
if has_reasoning && !has_content if has_reasoning
&& !has_content
&& let Some(state) = state && let Some(state) = state
{ {
state.end_thinking(); state.end_thinking();
@@ -474,7 +478,9 @@ where
text.push_str(&chunk.text); text.push_str(&chunk.text);
} }
StreamedAssistantContent::Reasoning(_) StreamedAssistantContent::Reasoning(_)
| StreamedAssistantContent::ReasoningDelta { .. } if !has_reasoning => { | StreamedAssistantContent::ReasoningDelta { .. }
if !has_reasoning =>
{
has_reasoning = true; has_reasoning = true;
if let Some(state) = state { if let Some(state) = state {
state.start_thinking(); state.start_thinking();
@@ -519,7 +525,11 @@ fn build_ollama_client(base_url: &str, http: ReqwestClient) -> Result<ollama::Cl
} }
/// 构建 DeepSeek 客户端OpenAI 兼容 API /// 构建 DeepSeek 客户端OpenAI 兼容 API
fn build_deepseek_client(key: &str, base_url: &str, http: ReqwestClient) -> Result<deepseek::Client> { fn build_deepseek_client(
key: &str,
base_url: &str,
http: ReqwestClient,
) -> Result<deepseek::Client> {
deepseek::Client::builder() deepseek::Client::builder()
.api_key(key) .api_key(key)
.base_url(base_url) .base_url(base_url)
@@ -539,7 +549,11 @@ fn build_kimi_client(key: &str, base_url: &str, http: ReqwestClient) -> Result<m
} }
/// 构建 Anthropic 客户端rig 会自动规范化 base_url 的 /v1 等后缀)。 /// 构建 Anthropic 客户端rig 会自动规范化 base_url 的 /v1 等后缀)。
fn build_anthropic_client(key: &str, base_url: &str, http: ReqwestClient) -> Result<anthropic::Client> { fn build_anthropic_client(
key: &str,
base_url: &str,
http: ReqwestClient,
) -> Result<anthropic::Client> {
anthropic::Client::builder() anthropic::Client::builder()
.api_key(key) .api_key(key)
.base_url(base_url) .base_url(base_url)
@@ -564,7 +578,11 @@ fn build_openai_client(
} }
/// 构建 OpenRouter 客户端(携带 QuiCommit 应用标识头)。 /// 构建 OpenRouter 客户端(携带 QuiCommit 应用标识头)。
fn build_openrouter_client(key: &str, base_url: &str, http: ReqwestClient) -> Result<openrouter::Client> { fn build_openrouter_client(
key: &str,
base_url: &str,
http: ReqwestClient,
) -> Result<openrouter::Client> {
openrouter::Client::builder() openrouter::Client::builder()
.api_key(key) .api_key(key)
.base_url(base_url) .base_url(base_url)
@@ -713,8 +731,14 @@ mod tests {
.build() .build()
.expect("build deepseek client with mock backend"), .expect("build deepseek client with mock backend"),
); );
let client = let client = LlmClient::new(
LlmClient::new(backend, "deepseek-v4-flash", "deepseek", test_config(), thinking, None); backend,
"deepseek-v4-flash",
"deepseek",
test_config(),
thinking,
None,
);
(client, recorder) (client, recorder)
} }
@@ -817,7 +841,10 @@ mod tests {
assert_eq!(text, "hello from deepseek"); assert_eq!(text, "hello from deepseek");
let captured = recorder.requests(); let captured = recorder.requests();
assert_eq!(captured[0].uri, "https://api.deepseek.com/v1/chat/completions"); assert_eq!(
captured[0].uri,
"https://api.deepseek.com/v1/chat/completions"
);
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap(); let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap();
assert_eq!(body["model"], "deepseek-v4-flash"); assert_eq!(body["model"], "deepseek-v4-flash");
// 非流式请求省略 stream 字段API 默认 false // 非流式请求省略 stream 字段API 默认 false
@@ -843,7 +870,10 @@ mod tests {
client.generate(None, "hi").await.unwrap(); client.generate(None, "hi").await.unwrap();
let captured = recorder.requests(); let captured = recorder.requests();
assert_eq!(captured[0].uri, "https://api.moonshot.cn/v1/chat/completions"); assert_eq!(
captured[0].uri,
"https://api.moonshot.cn/v1/chat/completions"
);
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap(); let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap();
assert_eq!(body["temperature"], 0.6); assert_eq!(body["temperature"], 0.6);
assert_eq!(body["thinking"]["type"], "disabled"); assert_eq!(body["thinking"]["type"], "disabled");
@@ -882,7 +912,9 @@ mod tests {
deepseek::Client::builder() deepseek::Client::builder()
.api_key("sk-test") .api_key("sk-test")
.base_url("https://api.deepseek.com/v1") .base_url("https://api.deepseek.com/v1")
.http_client(MockStreamingClient { sse_bytes: sse.to_string().into() }) .http_client(MockStreamingClient {
sse_bytes: sse.to_string().into(),
})
.build() .build()
.expect("build deepseek client with streaming mock"), .expect("build deepseek client with streaming mock"),
); );
@@ -962,9 +994,10 @@ mod tests {
#[test] #[test]
fn map_error_without_body_keeps_provider_prefix() { fn map_error_without_body_keeps_provider_prefix() {
let err = map_completion_error("DeepSeek", CompletionError::ProviderError("boom".into())); let err = map_completion_error("DeepSeek", CompletionError::ProviderError("boom".into()));
assert!(err assert!(
.to_string() err.to_string()
.contains("DeepSeek API request failed: ProviderError: boom")); .contains("DeepSeek API request failed: ProviderError: boom")
);
} }
#[test] #[test]
@@ -980,10 +1013,16 @@ mod tests {
#[test] #[test]
fn supports_thinking_whitelist_matches_legacy() { fn supports_thinking_whitelist_matches_legacy() {
for provider in ["deepseek", "kimi", "anthropic", "openai"] { for provider in ["deepseek", "kimi", "anthropic", "openai"] {
assert!(supports_thinking(provider), "{provider} should support thinking"); assert!(
supports_thinking(provider),
"{provider} should support thinking"
);
} }
for provider in ["ollama", "openrouter"] { for provider in ["ollama", "openrouter"] {
assert!(!supports_thinking(provider), "{provider} should not support thinking"); assert!(
!supports_thinking(provider),
"{provider} should not support thinking"
);
} }
} }
@@ -1015,7 +1054,14 @@ mod tests {
); );
let mut config = test_config(); let mut config = test_config();
config.thinking_budget_tokens = budget; config.thinking_budget_tokens = budget;
let client = LlmClient::new(backend, "claude-sonnet-4-6", "anthropic", config, thinking, None); let client = LlmClient::new(
backend,
"claude-sonnet-4-6",
"anthropic",
config,
thinking,
None,
);
(client, recorder) (client, recorder)
} }
@@ -1111,7 +1157,9 @@ mod tests {
anthropic::Client::builder() anthropic::Client::builder()
.api_key("sk-ant-test") .api_key("sk-ant-test")
.base_url("https://api.anthropic.com/v1") .base_url("https://api.anthropic.com/v1")
.http_client(MockStreamingClient { sse_bytes: sse.to_string().into() }) .http_client(MockStreamingClient {
sse_bytes: sse.to_string().into(),
})
.build() .build()
.expect("build anthropic client with streaming mock"), .expect("build anthropic client with streaming mock"),
); );
@@ -1186,12 +1234,18 @@ mod tests {
assert_eq!(text, "hello from openai"); assert_eq!(text, "hello from openai");
let captured = recorder.requests(); let captured = recorder.requests();
assert_eq!(captured[0].uri, "https://api.openai.com/v1/chat/completions"); assert_eq!(
captured[0].uri,
"https://api.openai.com/v1/chat/completions"
);
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap(); let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap();
assert_eq!(body["model"], "gpt-4o"); assert_eq!(body["model"], "gpt-4o");
assert_eq!(body["temperature"], 0.5); assert_eq!(body["temperature"], 0.5);
assert_eq!(body["max_tokens"], 123); assert_eq!(body["max_tokens"], 123);
assert!(body.get("reasoning_effort").is_none(), "非 o 系列不应传 reasoning_effort"); assert!(
body.get("reasoning_effort").is_none(),
"非 o 系列不应传 reasoning_effort"
);
} }
#[test] #[test]
@@ -1200,7 +1254,9 @@ mod tests {
let (client, _) = openai_client(recorder, "o3", false); let (client, _) = openai_client(recorder, "o3", false);
let options = client.request_options(); let options = client.request_options();
let params = options.additional_params.expect("o 系列应传 reasoning_effort"); let params = options
.additional_params
.expect("o 系列应传 reasoning_effort");
assert_eq!(params["reasoning_effort"], "none"); assert_eq!(params["reasoning_effort"], "none");
assert!(!options.stream); assert!(!options.stream);
} }
@@ -1328,7 +1384,10 @@ mod tests {
let captured = recorder.requests(); let captured = recorder.requests();
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap(); let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap();
let system = body["messages"][0]["content"].as_str().unwrap(); let system = body["messages"][0]["content"].as_str().unwrap();
assert!(system.contains("Conventional Commits"), "system prompt 应包含规范说明"); assert!(
system.contains("Conventional Commits"),
"system prompt 应包含规范说明"
);
let user = body["messages"][1]["content"].as_str().unwrap(); let user = body["messages"][1]["content"].as_str().unwrap();
assert!(user.contains("diff --git"), "user prompt 应包含 diff"); assert!(user.contains("diff --git"), "user prompt 应包含 diff");
assert!(user.contains("请用中文生成提交消息"), "应包含语言指令"); assert!(user.contains("请用中文生成提交消息"), "应包含语言指令");
@@ -1336,12 +1395,15 @@ mod tests {
// ---- 实网冒烟(#[ignore],需真实服务/API key---- // ---- 实网冒烟(#[ignore],需真实服务/API key----
fn smoke_client( fn smoke_client(backend: Backend, model: &str, provider: &str) -> LlmClient {
backend: Backend, LlmClient::new(
model: &str, backend,
provider: &str, model,
) -> LlmClient { provider,
LlmClient::new(backend, model, provider, LlmClientConfig::default(), false, None) LlmClientConfig::default(),
false,
None,
)
} }
fn smoke_http() -> ReqwestClient { fn smoke_http() -> ReqwestClient {
@@ -1370,7 +1432,9 @@ mod tests {
#[ignore = "requires QUICOMMIT_API_KEY with a valid DeepSeek key"] #[ignore = "requires QUICOMMIT_API_KEY with a valid DeepSeek key"]
#[tokio::test] #[tokio::test]
async fn live_smoke_deepseek() { async fn live_smoke_deepseek() {
let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return }; let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else {
return;
};
let backend = Backend::DeepSeek( let backend = Backend::DeepSeek(
deepseek::Client::builder() deepseek::Client::builder()
.api_key(&key) .api_key(&key)
@@ -1387,7 +1451,9 @@ mod tests {
#[ignore = "requires QUICOMMIT_API_KEY with a valid Moonshot key"] #[ignore = "requires QUICOMMIT_API_KEY with a valid Moonshot key"]
#[tokio::test] #[tokio::test]
async fn live_smoke_kimi() { async fn live_smoke_kimi() {
let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return }; let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else {
return;
};
let backend = Backend::Kimi( let backend = Backend::Kimi(
moonshot::Client::builder() moonshot::Client::builder()
.api_key(&key) .api_key(&key)
@@ -1404,7 +1470,9 @@ mod tests {
#[ignore = "requires QUICOMMIT_API_KEY with a valid Anthropic key"] #[ignore = "requires QUICOMMIT_API_KEY with a valid Anthropic key"]
#[tokio::test] #[tokio::test]
async fn live_smoke_anthropic() { async fn live_smoke_anthropic() {
let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return }; let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else {
return;
};
let backend = Backend::Anthropic( let backend = Backend::Anthropic(
anthropic::Client::builder() anthropic::Client::builder()
.api_key(&key) .api_key(&key)
@@ -1421,7 +1489,9 @@ mod tests {
#[ignore = "requires QUICOMMIT_API_KEY with a valid OpenAI key"] #[ignore = "requires QUICOMMIT_API_KEY with a valid OpenAI key"]
#[tokio::test] #[tokio::test]
async fn live_smoke_openai() { async fn live_smoke_openai() {
let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return }; let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else {
return;
};
let backend = Backend::OpenAi( let backend = Backend::OpenAi(
openai::Client::builder() openai::Client::builder()
.api_key(&key) .api_key(&key)
@@ -1439,7 +1509,9 @@ mod tests {
#[ignore = "requires QUICOMMIT_API_KEY with a valid OpenRouter key"] #[ignore = "requires QUICOMMIT_API_KEY with a valid OpenRouter key"]
#[tokio::test] #[tokio::test]
async fn live_smoke_openrouter() { async fn live_smoke_openrouter() {
let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return }; let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else {
return;
};
let backend = Backend::OpenRouter( let backend = Backend::OpenRouter(
openrouter::Client::builder() openrouter::Client::builder()
.api_key(&key) .api_key(&key)

View File

@@ -64,18 +64,10 @@ impl Default for ThinkingStateManager {
/// 线程安全的思考状态管理器引用 /// 线程安全的思考状态管理器引用
pub type SharedThinkingState = Arc<ThinkingStateManager>; pub type SharedThinkingState = Arc<ThinkingStateManager>;
/// 创建带有默认控制台输出的思考状态管理器 /// 创建 LLM 流式使用的共享思考状态
/// 在思考开始时打印 "thinking...",在思考结束时清除该标识 /// 进度显示由 AI 生成 spinner 负责issue 21此处不再附加控制台输出。
pub fn create_console_thinking_state() -> SharedThinkingState { pub fn create_console_thinking_state() -> SharedThinkingState {
Arc::new( Arc::new(ThinkingStateManager::new())
ThinkingStateManager::new()
.on_thinking_start(|| {
eprint!("\rthinking...");
})
.on_thinking_end(|| {
eprint!("\r \r");
}),
)
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -1,5 +1,3 @@
#![allow(dead_code)]
use anyhow::Result; use anyhow::Result;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use std::path::PathBuf; use std::path::PathBuf;
@@ -22,7 +20,7 @@ use quicommit::commands::{
#[command(propagate_version = true)] #[command(propagate_version = true)]
#[command(arg_required_else_help = true)] #[command(arg_required_else_help = true)]
struct Cli { struct Cli {
/// Enable verbose output /// Increase verbosity (-v: info, -vv: debug, -vvv: trace)
#[arg(short, long, global = true, action = clap::ArgAction::Count)] #[arg(short, long, global = true, action = clap::ArgAction::Count)]
verbose: u8, verbose: u8,
@@ -31,9 +29,17 @@ struct Cli {
config: Option<String>, config: Option<String>,
/// Disable colored output /// Disable colored output
#[arg(long, global = true, env = "NO_COLOR")] #[arg(long, global = true)]
no_color: bool, no_color: bool,
/// Force emoji decorations on (overrides config)
#[arg(long, global = true, conflicts_with = "no_emoji")]
emoji: bool,
/// Disable emoji/symbol decorations
#[arg(long, global = true)]
no_emoji: bool,
#[command(subcommand)] #[command(subcommand)]
command: Commands, command: Commands,
} }
@@ -73,6 +79,31 @@ enum Commands {
async fn main() -> Result<()> { async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
// Apply the global color decision before any output is produced.
// --no-color disables colors; NO_COLOR follows the no-color.org spec
// (any presence, regardless of value, disables color).
let no_color = cli.no_color || std::env::var_os("NO_COLOR").is_some();
colored::control::set_override(!no_color);
// Resolve the decoration switch (issue 14): explicit flag > config >
// default; --no-color disables decorations too (ADR-0003).
let emoji_from_config = cli
.config
.as_deref()
.map(PathBuf::from)
.or_else(|| quicommit::config::AppConfig::default_path().ok())
.and_then(|path| quicommit::config::AppConfig::load(&path).ok())
.map(|config| config.output.emoji)
.unwrap_or(true);
let emoji_enabled = if no_color || cli.no_emoji {
false
} else if cli.emoji {
true
} else {
emoji_from_config
};
quicommit::utils::set_emoji_enabled(emoji_enabled);
let log_level = match cli.verbose { let log_level = match cli.verbose {
0 => "warn", 0 => "warn",
1 => "info", 1 => "info",
@@ -80,8 +111,11 @@ async fn main() -> Result<()> {
_ => "trace", _ => "trace",
}; };
// RUST_LOG takes precedence when set; otherwise -v decides (issue 23).
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(log_level));
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(log_level) .with_env_filter(filter)
.with_target(false) .with_target(false)
.init(); .init();

View File

@@ -232,9 +232,7 @@ impl KeyringManager {
"Keyring is available".to_string() "Keyring is available".to_string()
} }
} }
KeyringStatus::Unavailable => { KeyringStatus::Unavailable => "Keyring is not available on this system.".to_string(),
"Keyring is not available. Set QUICOMMIT_API_KEY environment variable.".to_string()
}
} }
} }
} }

View File

@@ -6,48 +6,144 @@ pub mod validators;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use colored::Colorize; use colored::Colorize;
use std::io::{self, Write}; use std::io;
use std::sync::OnceLock;
use std::time::Duration;
/// Process-global decoration switch (issue 14).
static EMOJI_ENABLED: OnceLock<bool> = OnceLock::new();
/// Set whether emoji/symbol decorations are shown. Called once from `main`
/// after resolving config + flags.
pub fn set_emoji_enabled(enabled: bool) {
let _ = EMOJI_ENABLED.set(enabled);
}
/// Whether emoji/symbol decorations are shown.
pub fn emoji_enabled() -> bool {
*EMOJI_ENABLED.get_or_init(|| true)
}
/// Styled decoration prefix for status lines; empty when decorations are off
/// (ADR-0003: message text never embeds decorations).
pub fn success_prefix() -> colored::ColoredString {
if emoji_enabled() {
"".green().bold()
} else {
"".normal()
}
}
pub fn error_prefix() -> colored::ColoredString {
if emoji_enabled() {
"".red().bold()
} else {
"".normal()
}
}
pub fn warning_prefix() -> colored::ColoredString {
if emoji_enabled() {
"".yellow().bold()
} else {
"".normal()
}
}
pub fn info_prefix() -> colored::ColoredString {
if emoji_enabled() {
"".blue().bold()
} else {
"".normal()
}
}
pub fn progress_prefix() -> colored::ColoredString {
if emoji_enabled() {
"".cyan()
} else {
"".normal()
}
}
fn print_decorated(prefix: colored::ColoredString, msg: &str, to_stderr: bool) {
if emoji_enabled() {
if to_stderr {
eprintln!("{} {}", prefix, msg);
} else {
println!("{} {}", prefix, msg);
}
} else if to_stderr {
eprintln!("{}", msg);
} else {
println!("{}", msg);
}
}
/// Print success message /// Print success message
pub fn print_success(msg: &str) { pub fn print_success(msg: &str) {
println!("{} {}", "".green().bold(), msg); print_decorated(success_prefix(), msg, false);
} }
/// Print error message /// Print error message to stderr
pub fn print_error(msg: &str) { pub fn print_error(msg: &str) {
eprintln!("{} {}", "".red().bold(), msg); print_decorated(error_prefix(), msg, true);
} }
/// Print warning message /// Print warning message
pub fn print_warning(msg: &str) { pub fn print_warning(msg: &str) {
println!("{} {}", "".yellow().bold(), msg); print_decorated(warning_prefix(), msg, false);
}
/// Print warning message to stderr (stdout must stay clean, e.g. exports)
pub fn eprint_warning(msg: &str) {
print_decorated(warning_prefix(), msg, true);
} }
/// Print info message /// Print info message
pub fn print_info(msg: &str) { pub fn print_info(msg: &str) {
println!("{} {}", "".blue().bold(), msg); print_decorated(info_prefix(), msg, false);
} }
/// Confirm action with user /// Print progress/status message (→ prefix when decorations are on)
pub fn confirm(prompt: &str) -> Result<bool> { pub fn print_progress(msg: &str) {
print!("{} [y/N] ", prompt); print_decorated(progress_prefix(), msg, false);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(input.trim().to_lowercase().starts_with('y'))
} }
/// Get user input /// Progress spinner for long-running operations (issue 21).
pub fn input(prompt: &str) -> Result<String> { /// Degrades to a static line when stdout is not a terminal or when
print!("{}: ", prompt); /// decorations are disabled.
io::stdout().flush()?; pub struct Spinner {
bar: Option<indicatif::ProgressBar>,
}
let mut input = String::new(); impl Spinner {
io::stdin().read_line(&mut input)?; pub fn start(msg: &str) -> Self {
if emoji_enabled() && std::io::IsTerminal::is_terminal(&io::stdout()) {
let bar = indicatif::ProgressBar::new_spinner();
bar.set_message(msg.to_string());
bar.enable_steady_tick(Duration::from_millis(100));
Self { bar: Some(bar) }
} else {
println!("{}", msg);
Self { bar: None }
}
}
Ok(input.trim().to_string()) /// Stop the spinner, erasing it from the terminal (no completion line).
pub fn finish_clear(&self) {
if let Some(bar) = &self.bar {
bar.finish_and_clear();
}
}
/// Stop the spinner and print a completion line.
pub fn finish_with(&self, msg: &str) {
if let Some(bar) = &self.bar {
bar.finish_and_clear();
}
println!("{}", msg);
}
} }
/// Get password input (hidden) /// Get password input (hidden)
@@ -59,19 +155,3 @@ pub fn password_input(prompt: &str) -> Result<String> {
.interact() .interact()
.context("Failed to read password") .context("Failed to read password")
} }
/// Check if running in a terminal
pub fn is_terminal() -> bool {
std::io::IsTerminal::is_terminal(&io::stdout())
}
/// Format duration in human-readable format
pub fn format_duration(secs: u64) -> String {
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
}

View File

@@ -115,7 +115,8 @@ fn test_stage_all_removes_ignored_tracked_files() {
write_file(repo_path, "__pycache__/foo.pyc", "modified bytecode"); write_file(repo_path, "__pycache__/foo.pyc", "modified bytecode");
let repo = GitRepo::open(repo_path).expect("Failed to open repo"); let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let removed = repo.stage_all().expect("stage_all should succeed"); let messages = quicommit::i18n::Messages::new(quicommit::config::Language::English);
let removed = repo.stage_all(&messages).expect("stage_all should succeed");
assert!( assert!(
removed.iter().any(|f| f == "__pycache__/foo.pyc"), removed.iter().any(|f| f == "__pycache__/foo.pyc"),
@@ -142,7 +143,8 @@ fn test_stage_all_no_ignored_files_returns_empty() {
write_file(repo_path, "src/main.rs", "fn main() {}"); write_file(repo_path, "src/main.rs", "fn main() {}");
let repo = GitRepo::open(repo_path).expect("Failed to open repo"); let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let removed = repo.stage_all().expect("stage_all should succeed"); let messages = quicommit::i18n::Messages::new(quicommit::config::Language::English);
let removed = repo.stage_all(&messages).expect("stage_all should succeed");
assert!( assert!(
removed.is_empty(), removed.is_empty(),

View File

@@ -258,6 +258,190 @@ mod config_command {
.success() .success()
.stdout(predicate::str::contains("config.toml")); .stdout(predicate::str::contains("config.toml"));
} }
#[test]
fn test_no_color_disables_ansi() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config",
"show",
"--no-color",
"--config",
config_path.to_str().unwrap(),
]);
let output = cmd.output().unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("\x1b["),
"ANSI color codes present despite --no-color"
);
}
#[test]
fn test_emoji_switch_precedence() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let set_format = |extra: &[&str], cfg: &std::path::Path| {
let mut c = cargo_bin_cmd!("quicommit");
c.env_remove("NO_COLOR");
c.args(&["config", "set", "commit.format", "conventional"])
.args(extra)
.args(&["--config", cfg.to_str().unwrap()]);
c.output().unwrap()
};
// Default (config true): decorations shown
let out = set_format(&[], &config_path);
assert!(out.status.success());
assert!(String::from_utf8_lossy(&out.stdout).contains(""));
// Config output.emoji=false: no decorations
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&[
"config",
"set",
"output.emoji",
"false",
"--config",
config_path.to_str().unwrap(),
]);
cmd.assert().success();
let out = set_format(&[], &config_path);
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stdout).contains(""));
// Explicit --emoji overrides config false
let out = set_format(&["--emoji"], &config_path);
assert!(out.status.success());
assert!(String::from_utf8_lossy(&out.stdout).contains(""));
// Config back to true, then --no-emoji overrides
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&[
"config",
"set",
"output.emoji",
"true",
"--config",
config_path.to_str().unwrap(),
]);
cmd.assert().success();
let out = set_format(&["--no-emoji"], &config_path);
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stdout).contains(""));
}
#[test]
fn test_chinese_output_smoke() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_test_repo_with_file(&repo_path, "test.txt", "content");
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&[
"config",
"set",
"language.output_language",
"zh",
"--config",
config_path.to_str().unwrap(),
]);
cmd.assert().success();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&[
"commit",
"--manual",
"-m",
"feat: 中文冒烟",
"--dry-run",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("试运行"));
}
#[test]
fn test_rust_log_env() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.env("RUST_LOG", "debug");
cmd.args(&["config", "path", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("Starting quicommit"));
}
#[test]
fn test_auto_generate_defaults_true() {
// Documents the unified --yes semantics (issue 05): with default
// config, --yes still AI-generates; deterministic output needs
// --no-generate (changelog) or -m (tag).
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
for key in [
"commit.auto_generate",
"tag.auto_generate",
"changelog.auto_generate",
] {
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&[
"config",
"get",
key,
"--config",
config_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicate::str::contains("true"));
}
}
} }
mod commit_command { mod commit_command {
@@ -438,6 +622,8 @@ mod tag_command {
"tag", "tag",
"--name", "--name",
"v0.1.0", "v0.1.0",
"-m",
"Release v0.1.0",
"--dry-run", "--dry-run",
"--yes", "--yes",
"--config", "--config",
@@ -469,6 +655,8 @@ mod tag_command {
"--think", "--think",
"--name", "--name",
"v0.2.0", "v0.2.0",
"-m",
"Release v0.2.0",
"--dry-run", "--dry-run",
"--yes", "--yes",
"--config", "--config",
@@ -528,6 +716,7 @@ mod changelog_command {
"changelog", "changelog",
"--dry-run", "--dry-run",
"--yes", "--yes",
"--no-generate",
"--config", "--config",
config_path.to_str().unwrap(), config_path.to_str().unwrap(),
]) ])