feat(changelog): 添加 --no-generate 标志以支持纯模板生成
- 新增 `--no-generate` 参数,强制使用模板生成而非 AI - 调整 `--yes` 参数仅跳过交互提示,不改变生成行为 - 统一使用工具函数替代 println 输出
This commit is contained in:
26
README.md
26
README.md
@@ -236,6 +236,10 @@ quicommit profile token
|
||||
quicommit config set-llm ollama
|
||||
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
|
||||
quicommit config set-llm openai
|
||||
quicommit config set-api-key YOUR_API_KEY
|
||||
@@ -329,7 +333,7 @@ quicommit config reset --force
|
||||
| `--commitlint` | Use commitlint format |
|
||||
| `--no-verify` | Skip commit message verification |
|
||||
| `-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 |
|
||||
| `--remote` | Specify remote repository (default: origin) |
|
||||
|
||||
@@ -349,7 +353,7 @@ quicommit config reset --force
|
||||
| `-r, --remote` | Specify remote repository (default: origin) |
|
||||
| `--dry-run` | Dry run |
|
||||
| `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) |
|
||||
| `-y, --yes` | Skip confirmation |
|
||||
| `-y, --yes` | Skip confirmation prompts only (generation behavior unchanged) |
|
||||
|
||||
### Changelog Options
|
||||
|
||||
@@ -366,7 +370,8 @@ quicommit config reset --force
|
||||
| `--format` | Format (keep-a-changelog, github-releases) |
|
||||
| `--dry-run` | Dry run (output to stdout) |
|
||||
| `--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
|
||||
|
||||
@@ -425,6 +430,9 @@ auto_generate = true
|
||||
path = "CHANGELOG.md"
|
||||
auto_generate = true
|
||||
|
||||
[output]
|
||||
emoji = true
|
||||
|
||||
[repo_profiles]
|
||||
"/path/to/work/project" = "work"
|
||||
"/path/to/personal/project" = "personal"
|
||||
@@ -436,7 +444,15 @@ auto_generate = true
|
||||
|----------|-------------|
|
||||
| `QUICOMMIT_CONFIG` | Configuration file path |
|
||||
| `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
|
||||
|
||||
@@ -453,7 +469,7 @@ quicommit config set llm.provider ollama
|
||||
# Get configuration value
|
||||
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
|
||||
|
||||
# Delete API key from keyring
|
||||
|
||||
26
readme_zh.md
26
readme_zh.md
@@ -230,6 +230,10 @@ quicommit profile token
|
||||
quicommit config set-llm ollama
|
||||
quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2
|
||||
|
||||
> ⚠️ **安全提示**:把 API 密钥作为命令行参数传入会留在 shell history 与进程列表中。
|
||||
> 建议直接运行 `quicommit config set-api-key`(或 `config set-llm`)不携带密钥,
|
||||
> 以隐藏方式输入。
|
||||
|
||||
# 配置OpenAI
|
||||
quicommit config set-llm openai
|
||||
quicommit config set-api-key YOUR_API_KEY
|
||||
@@ -323,7 +327,7 @@ quicommit config reset --force
|
||||
| `--commitlint` | 使用commitlint格式 |
|
||||
| `--no-verify` | 不验证提交信息 |
|
||||
| `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) |
|
||||
| `-y, --yes` | 跳过确认提示 |
|
||||
| `-y, --yes` | 仅跳过确认提示(生成行为不变) |
|
||||
| `--push` | 提交后推送到远程 |
|
||||
| `--remote` | 指定远程仓库(默认:origin) |
|
||||
|
||||
@@ -343,7 +347,7 @@ quicommit config reset --force
|
||||
| `-r, --remote` | 指定远程仓库(默认:origin) |
|
||||
| `--dry-run` | 试运行 |
|
||||
| `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) |
|
||||
| `-y, --yes` | 跳过确认提示 |
|
||||
| `-y, --yes` | 仅跳过确认提示(生成行为不变) |
|
||||
|
||||
### changelog命令选项
|
||||
|
||||
@@ -360,7 +364,8 @@ quicommit config reset --force
|
||||
| `--format` | 格式(keep-a-changelog、github-releases) |
|
||||
| `--dry-run` | 试运行(输出到stdout) |
|
||||
| `--think` | 启用 LLM 思考/推理模式(覆盖配置) |
|
||||
| `-y, --yes` | 跳过确认提示 |
|
||||
| `--no-generate` | 使用模板确定性生成(不调用 AI) |
|
||||
| `-y, --yes` | 仅跳过确认提示(生成行为不变) |
|
||||
|
||||
## 配置文件
|
||||
|
||||
@@ -419,6 +424,9 @@ auto_generate = true
|
||||
path = "CHANGELOG.md"
|
||||
auto_generate = true
|
||||
|
||||
[output]
|
||||
emoji = true
|
||||
|
||||
[repo_profiles]
|
||||
"/path/to/work/project" = "work"
|
||||
"/path/to/personal/project" = "personal"
|
||||
@@ -430,7 +438,15 @@ auto_generate = true
|
||||
|--------|------|
|
||||
| `QUICOMMIT_CONFIG` | 配置文件路径 |
|
||||
| `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
|
||||
|
||||
# 设置API密钥(存储在系统密钥环中)
|
||||
# 设置API密钥(存储在系统密钥环中;省略密钥值可通过隐藏提示输入)
|
||||
quicommit config set-api-key YOUR_API_KEY
|
||||
|
||||
# 从密钥环删除API密钥
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::git::GitRepo;
|
||||
use crate::git::find_repo;
|
||||
use crate::git::{CommitInfo, changelog::*};
|
||||
use crate::i18n::{Messages, translate_changelog_category};
|
||||
use crate::utils::{print_progress, print_success, print_warning};
|
||||
|
||||
/// Generate changelog
|
||||
#[derive(Parser)]
|
||||
@@ -60,7 +61,11 @@ pub struct ChangelogCommand {
|
||||
#[arg(long)]
|
||||
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)]
|
||||
yes: bool,
|
||||
}
|
||||
@@ -85,7 +90,7 @@ impl ChangelogCommand {
|
||||
.unwrap_or_else(|| PathBuf::from(&config.changelog.path));
|
||||
|
||||
init_changelog(&path)?;
|
||||
println!("{}", messages.initialized_changelog(&format!("{:?}", path)));
|
||||
print_success(&messages.initialized_changelog(&path.display().to_string()));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -101,10 +106,7 @@ impl ChangelogCommand {
|
||||
Some("keep") | Some("keep-a-changelog") => ChangelogFormat::KeepAChangelog,
|
||||
Some("custom") => ChangelogFormat::Custom,
|
||||
None => ChangelogFormat::KeepAChangelog,
|
||||
Some(f) => bail!(
|
||||
"Unknown format: {}. Use: keep-a-changelog, github-releases",
|
||||
f
|
||||
),
|
||||
Some(f) => bail!("{}", messages.unknown_changelog_format(f)),
|
||||
};
|
||||
|
||||
// Get version
|
||||
@@ -120,7 +122,7 @@ impl ChangelogCommand {
|
||||
};
|
||||
|
||||
// Get commits
|
||||
println!("{}", messages.fetching_commits());
|
||||
print_progress(messages.fetching_commits());
|
||||
|
||||
// Determine from_tag: use explicit --from, or auto-detect from changelog
|
||||
let from_tag = self.resolve_from_tag(&repo, &output_path, &messages);
|
||||
@@ -130,10 +132,10 @@ impl ChangelogCommand {
|
||||
bail!("{}", messages.no_commits_found());
|
||||
}
|
||||
|
||||
println!("{}", messages.found_commits(commits.len()));
|
||||
print_success(&messages.found_commits(commits.len()));
|
||||
|
||||
// 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?
|
||||
} else {
|
||||
self.generate_with_template(format, &version, &commits, language)?
|
||||
@@ -161,12 +163,12 @@ impl ChangelogCommand {
|
||||
println!("{}", "─".repeat(60));
|
||||
|
||||
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)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
println!("{}", messages.cancelled().yellow());
|
||||
print_warning(messages.cancelled());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -185,7 +187,11 @@ impl ChangelogCommand {
|
||||
std::fs::write(&output_path, content)?;
|
||||
}
|
||||
|
||||
println!("{} {:?}", messages.changelog_written(), output_path);
|
||||
print_success(&format!(
|
||||
"{} {}",
|
||||
messages.changelog_written(),
|
||||
output_path.display()
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -221,12 +227,13 @@ impl ChangelogCommand {
|
||||
let manager = ConfigManager::new()?;
|
||||
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?;
|
||||
generator
|
||||
let spinner = crate::utils::Spinner::start(messages.ai_generating_changelog());
|
||||
let result = generator
|
||||
.generate_changelog_entry(version, commits, language)
|
||||
.await
|
||||
.await;
|
||||
spinner.finish_clear();
|
||||
result
|
||||
}
|
||||
|
||||
fn generate_with_template(
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::git::commit::{CommitBuilder, create_date_commit_message};
|
||||
use crate::git::{GitRepo, find_repo};
|
||||
use crate::i18n::Messages;
|
||||
use crate::utils::validators::get_commit_types;
|
||||
use crate::utils::{print_progress, print_success, print_warning};
|
||||
|
||||
/// Generate and execute conventional commits
|
||||
#[derive(Parser)]
|
||||
@@ -75,7 +76,7 @@ pub struct CommitCommand {
|
||||
#[arg(short = 't', long)]
|
||||
think: bool,
|
||||
|
||||
/// Skip interactive prompts
|
||||
/// Skip interactive prompts only (generation behavior unchanged)
|
||||
#[arg(short = 'y', long)]
|
||||
yes: bool,
|
||||
|
||||
@@ -121,13 +122,13 @@ impl CommitCommand {
|
||||
// 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 {
|
||||
println!("{}", messages.auto_stage_changes().yellow());
|
||||
let removed = repo.stage_all()?;
|
||||
let removed = repo.stage_all(&messages)?;
|
||||
println!("{}", messages.staged_all().green());
|
||||
if !removed.is_empty() {
|
||||
println!(
|
||||
"{}",
|
||||
format!("Removed {} ignored files from staging:", removed.len()).yellow()
|
||||
);
|
||||
print_warning(&format!(
|
||||
"Removed {} ignored files from staging:",
|
||||
removed.len()
|
||||
));
|
||||
for file in &removed {
|
||||
println!(" • {}", file);
|
||||
}
|
||||
@@ -136,19 +137,19 @@ impl CommitCommand {
|
||||
// Re-check status after staging to ensure changes are detected
|
||||
let new_status = repo.status_summary()?;
|
||||
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
|
||||
if self.all {
|
||||
let removed = repo.stage_all()?;
|
||||
let removed = repo.stage_all(&messages)?;
|
||||
println!("{}", messages.staged_all().green());
|
||||
if !removed.is_empty() {
|
||||
println!(
|
||||
"{}",
|
||||
format!("Removed {} ignored files from staging:", removed.len()).yellow()
|
||||
);
|
||||
print_warning(&format!(
|
||||
"Removed {} ignored files from staging:",
|
||||
removed.len()
|
||||
));
|
||||
for file in &removed {
|
||||
println!(" • {}", file);
|
||||
}
|
||||
@@ -194,29 +195,21 @@ impl CommitCommand {
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
println!("{}", messages.commit_cancelled().yellow());
|
||||
print_warning(messages.commit_cancelled());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let result = if self.amend {
|
||||
if self.dry_run {
|
||||
println!(
|
||||
"\n{} {}",
|
||||
messages.dry_run(),
|
||||
"- commit not amended.".yellow()
|
||||
);
|
||||
println!("\n{}", messages.dry_run_commit_not_amended().yellow());
|
||||
return Ok(());
|
||||
}
|
||||
self.amend_commit(&repo, &commit_message)?;
|
||||
None
|
||||
} else {
|
||||
if self.dry_run {
|
||||
println!(
|
||||
"\n{} {}",
|
||||
messages.dry_run(),
|
||||
"- commit not created.".yellow()
|
||||
);
|
||||
println!("\n{}", messages.dry_run_commit_not_created().yellow());
|
||||
return Ok(());
|
||||
}
|
||||
CommitBuilder::new()
|
||||
@@ -226,30 +219,34 @@ impl CommitCommand {
|
||||
};
|
||||
|
||||
if let Some(commit_oid) = result {
|
||||
println!(
|
||||
print_success(&format!(
|
||||
"{} {}",
|
||||
messages.commit_created().green().bold(),
|
||||
commit_oid.to_string()[..8].to_string().cyan()
|
||||
);
|
||||
));
|
||||
} else {
|
||||
println!("{} successfully", messages.commit_amended().green().bold());
|
||||
print_success(messages.commit_amended_successfully());
|
||||
}
|
||||
|
||||
// Push after commit if requested or ask user
|
||||
if self.push {
|
||||
println!("\n{}", messages.pushing_commit(&self.remote));
|
||||
repo.push(&self.remote, "HEAD")?;
|
||||
println!("{}", messages.pushed_commit(&self.remote));
|
||||
} else if !self.yes && !self.dry_run {
|
||||
let should_push = Confirm::new()
|
||||
.with_prompt(messages.push_after_commit())
|
||||
.default(false)
|
||||
.interact()?;
|
||||
if self.push || (!self.yes && !self.dry_run) {
|
||||
let branch = repo
|
||||
.current_branch()
|
||||
.unwrap_or_else(|_| "HEAD (detached)".to_string());
|
||||
|
||||
let should_push = if self.push {
|
||||
true
|
||||
} else {
|
||||
Confirm::new()
|
||||
.with_prompt(messages.push_after_commit(&branch))
|
||||
.default(false)
|
||||
.interact()?
|
||||
};
|
||||
|
||||
if should_push {
|
||||
println!("\n{}", messages.pushing_commit(&self.remote));
|
||||
print_progress(&messages.pushing_commit(&self.remote, &branch));
|
||||
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
|
||||
.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 generated = if self.yes {
|
||||
generator
|
||||
.generate_commit_from_repo(repo, format, language)
|
||||
.await?
|
||||
.await
|
||||
} else {
|
||||
generator
|
||||
.generate_commit_interactive(repo, format, language)
|
||||
.await?
|
||||
.generate_commit_interactive(repo, format, language, messages)
|
||||
.await
|
||||
};
|
||||
spinner.finish_clear();
|
||||
|
||||
Ok(generated.to_conventional())
|
||||
Ok(generated?.to_conventional())
|
||||
}
|
||||
|
||||
async fn create_interactive_commit(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -146,23 +146,26 @@ impl CredentialCommand {
|
||||
|
||||
let mut manager = Self::get_manager(&config_path)?;
|
||||
|
||||
let profile_names: Vec<String> = manager
|
||||
.list_profiles()
|
||||
.into_iter()
|
||||
.filter(|name| {
|
||||
manager
|
||||
.get_profile(name)
|
||||
.map(|p| p.tokens.contains_key(&service))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let profile_names: Vec<String> = manager.list_profiles().into_iter().cloned().collect();
|
||||
|
||||
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!(
|
||||
"[quicommit credential] failed to erase PAT for '{}': {}",
|
||||
name, e
|
||||
"[quicommit credential] erased unregistered PAT for '{}' (service {}) from keyring",
|
||||
name, service
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -189,8 +192,7 @@ impl CredentialAttributes {
|
||||
let stdin = io::stdin();
|
||||
let mut attrs = Self::default();
|
||||
for line in stdin.lock().lines() {
|
||||
let line =
|
||||
line.context("Failed to read credential attributes from stdin")?;
|
||||
let line = line.context("Failed to read credential attributes from stdin")?;
|
||||
if line.is_empty() {
|
||||
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
|
||||
/// git-supplied username; otherwise falls back to the default profile.
|
||||
fn find_profile_for_store(
|
||||
manager: &ConfigManager,
|
||||
username: Option<&str>,
|
||||
) -> Option<String> {
|
||||
fn find_profile_for_store(manager: &ConfigManager, username: Option<&str>) -> Option<String> {
|
||||
if let Some(username) = username {
|
||||
for name in manager.list_profiles() {
|
||||
if let Some(profile) = manager.get_profile(name) {
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::config::{GitProfile, Language};
|
||||
use crate::i18n::Messages;
|
||||
use crate::utils::keyring::{get_default_model, get_supported_providers, provider_needs_api_key};
|
||||
use crate::utils::validators::validate_email;
|
||||
use crate::utils::{print_success, print_warning};
|
||||
|
||||
/// Initialize quicommit configuration
|
||||
#[derive(Parser)]
|
||||
@@ -34,19 +35,16 @@ impl InitCommand {
|
||||
if config_path.exists() && !self.reset {
|
||||
if !self.yes {
|
||||
let overwrite = Confirm::new()
|
||||
.with_prompt("Configuration already exists. Overwrite?")
|
||||
.with_prompt(messages.config_exists_overwrite())
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if !overwrite {
|
||||
println!("{}", "Initialization cancelled.".yellow());
|
||||
print_warning(messages.init_cancelled());
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
"Configuration already exists. Use --reset to overwrite.".yellow()
|
||||
);
|
||||
print_warning(messages.config_exists_use_reset());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -59,7 +57,7 @@ impl InitCommand {
|
||||
let mut manager = ConfigManager::with_path_fresh(&config_path)?;
|
||||
|
||||
if self.yes {
|
||||
self.quick_setup(&mut manager).await?;
|
||||
self.quick_setup(&mut manager, &messages).await?;
|
||||
} else {
|
||||
self.interactive_setup(&mut manager).await?;
|
||||
}
|
||||
@@ -69,27 +67,52 @@ impl InitCommand {
|
||||
let language = manager.get_language().unwrap_or(Language::English);
|
||||
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.next_steps());
|
||||
println!(" 1. Create a profile: {}", "quicommit profile add".cyan());
|
||||
println!(" 2. Configure LLM: {}", "quicommit config set-llm".cyan());
|
||||
println!(" 3. Start committing: {}", "quicommit commit".cyan());
|
||||
println!(
|
||||
" 1. {}: {}",
|
||||
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(())
|
||||
}
|
||||
|
||||
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 user_name = git_config
|
||||
.get_string("user.name")
|
||||
.unwrap_or_else(|_| "User".to_string());
|
||||
let user_email = git_config
|
||||
.get_string("user.email")
|
||||
.unwrap_or_else(|_| "user@example.com".to_string());
|
||||
let name_result = git_config.get_string("user.name");
|
||||
let email_result = git_config.get_string("user.email");
|
||||
let name_missing = name_result.is_err();
|
||||
let email_missing = email_result.is_err();
|
||||
let user_name = name_result.unwrap_or_else(|_| "User".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.set_default_profile(Some("default".to_string()))?;
|
||||
@@ -231,11 +254,8 @@ impl InitCommand {
|
||||
let keyring_available = keyring.is_available();
|
||||
|
||||
if !keyring_available {
|
||||
println!(
|
||||
"\n{}",
|
||||
"⚠ Keyring is not available on this system.".yellow()
|
||||
);
|
||||
println!("{}", keyring.get_status_message().yellow());
|
||||
print_warning(messages.keyring_unavailable());
|
||||
print_warning(&keyring.get_status_message());
|
||||
}
|
||||
|
||||
let api_key = if provider_needs_api_key(&provider) {
|
||||
@@ -246,11 +266,7 @@ impl InitCommand {
|
||||
.ok();
|
||||
|
||||
if let Some(_key) = env_key {
|
||||
println!(
|
||||
"\n{} {}",
|
||||
"✓".green(),
|
||||
"Found API key in environment variable.".green()
|
||||
);
|
||||
print_success(messages.api_key_found_env());
|
||||
None
|
||||
} else if keyring_available {
|
||||
let prompt = match provider.as_str() {
|
||||
@@ -259,16 +275,13 @@ impl InitCommand {
|
||||
"kimi" => messages.kimi_api_key(),
|
||||
"deepseek" => messages.deepseek_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)
|
||||
} else {
|
||||
println!(
|
||||
"\n{}",
|
||||
"Please set the QUICOMMIT_API_KEY environment variable.".yellow()
|
||||
);
|
||||
print_warning(messages.please_set_api_key_env());
|
||||
None
|
||||
}
|
||||
} else {
|
||||
@@ -277,24 +290,26 @@ impl InitCommand {
|
||||
|
||||
let default_model = get_default_model(&provider);
|
||||
let model: String = Input::new()
|
||||
.with_prompt("Model name")
|
||||
.with_prompt(messages.model_name())
|
||||
.default(default_model.to_string())
|
||||
.interact_text()?;
|
||||
|
||||
let base_url: Option<String> = if provider == "ollama" {
|
||||
let url: String = Input::new()
|
||||
.with_prompt("Ollama server URL")
|
||||
.with_prompt(messages.ollama_server_url())
|
||||
.default("http://localhost:11434".to_string())
|
||||
.interact_text()?;
|
||||
Some(url)
|
||||
} else {
|
||||
let use_custom_url = Confirm::new()
|
||||
.with_prompt("Use custom API base URL?")
|
||||
.with_prompt(messages.use_custom_base_url())
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
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)
|
||||
} else {
|
||||
None
|
||||
@@ -309,11 +324,7 @@ impl InitCommand {
|
||||
&& provider_needs_api_key(&provider)
|
||||
{
|
||||
manager.set_api_key(&key)?;
|
||||
println!(
|
||||
"\n{} {}",
|
||||
"✓".green(),
|
||||
"API key stored securely in system keyring.".green()
|
||||
);
|
||||
print_success(messages.api_key_stored_keyring());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -332,7 +343,7 @@ impl InitCommand {
|
||||
.interact_text()?;
|
||||
|
||||
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())
|
||||
.allow_empty(true)
|
||||
.interact_text()?;
|
||||
@@ -354,12 +365,12 @@ impl InitCommand {
|
||||
};
|
||||
|
||||
let agent_forwarding = Confirm::new()
|
||||
.with_prompt("Enable SSH agent forwarding (-A)?")
|
||||
.with_prompt(messages.ssh_agent_forwarding())
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
let known_hosts: String = Input::new()
|
||||
.with_prompt("Custom known_hosts file path (optional)")
|
||||
.with_prompt(messages.known_hosts_path())
|
||||
.allow_empty(true)
|
||||
.interact_text()?;
|
||||
let known_hosts_file = if known_hosts.is_empty() {
|
||||
@@ -369,7 +380,7 @@ impl InitCommand {
|
||||
};
|
||||
|
||||
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)
|
||||
.interact_text()?;
|
||||
let ssh_command = if custom_cmd.is_empty() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ use crate::git::tag::{
|
||||
};
|
||||
use crate::git::{GitRepo, find_repo};
|
||||
use crate::i18n::Messages;
|
||||
use crate::utils::{print_progress, print_success, print_warning};
|
||||
|
||||
/// Generate and create Git tags
|
||||
#[derive(Parser)]
|
||||
@@ -61,7 +62,7 @@ pub struct TagCommand {
|
||||
#[arg(short = 't', long)]
|
||||
think: bool,
|
||||
|
||||
/// Skip interactive prompts
|
||||
/// Skip interactive prompts only (generation behavior unchanged)
|
||||
#[arg(short = 'y', long)]
|
||||
yes: bool,
|
||||
|
||||
@@ -115,11 +116,11 @@ impl TagCommand {
|
||||
{
|
||||
let version_str = tag_name.trim_start_matches('v');
|
||||
if let Err(e) = crate::utils::validators::validate_semver(version_str) {
|
||||
println!("{}: {}", "Warning".yellow(), e);
|
||||
print_warning(&format!("{}: {}", messages.warning(), e));
|
||||
|
||||
if !self.yes {
|
||||
let proceed = Confirm::new()
|
||||
.with_prompt("Proceed with this tag name anyway?")
|
||||
.with_prompt(messages.proceed_invalid_tag_name())
|
||||
.default(true)
|
||||
.interact()?;
|
||||
|
||||
@@ -135,7 +136,7 @@ impl TagCommand {
|
||||
None
|
||||
} else if let Some(msg) = &self.message {
|
||||
Some(msg.clone())
|
||||
} else if self.generate || (config.tag.auto_generate && !self.yes) {
|
||||
} else if self.generate || config.tag.auto_generate {
|
||||
Some(
|
||||
self.generate_tag_message(&repo, &tag_name, &messages)
|
||||
.await?,
|
||||
@@ -143,7 +144,7 @@ impl TagCommand {
|
||||
} else if !self.yes {
|
||||
Some(self.input_message_interactive(&tag_name, &messages)?)
|
||||
} else {
|
||||
Some(format!("Release {}", tag_name))
|
||||
Some(messages.release_default(&tag_name))
|
||||
};
|
||||
|
||||
// Show preview
|
||||
@@ -165,13 +166,13 @@ impl TagCommand {
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
println!("{}", messages.tag_cancelled().yellow());
|
||||
print_warning(messages.tag_cancelled());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
if self.dry_run {
|
||||
println!("\n{} {}", messages.dry_run(), "- tag not created.".yellow());
|
||||
println!("\n{}", messages.dry_run_tag_not_created().yellow());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -184,13 +185,13 @@ impl TagCommand {
|
||||
|
||||
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
|
||||
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))?;
|
||||
println!("{}", messages.pushed_tag(&self.remote));
|
||||
print_success(&messages.pushed_tag(&self.remote));
|
||||
} else if !self.yes && !self.dry_run {
|
||||
let should_push = Confirm::new()
|
||||
.with_prompt(messages.push_after_tag())
|
||||
@@ -198,9 +199,9 @@ impl TagCommand {
|
||||
.interact()?;
|
||||
|
||||
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))?;
|
||||
println!("{}", messages.pushed_tag(&self.remote));
|
||||
print_success(&messages.pushed_tag(&self.remote));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,15 +324,16 @@ impl TagCommand {
|
||||
};
|
||||
|
||||
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?;
|
||||
generator
|
||||
let spinner = crate::utils::Spinner::start(&messages.ai_generating_tag(commits.len()));
|
||||
let result = generator
|
||||
.generate_tag_message(version, &commits, language)
|
||||
.await
|
||||
.await;
|
||||
spinner.finish_clear();
|
||||
result
|
||||
}
|
||||
|
||||
async fn auto_detect_version(
|
||||
@@ -418,7 +420,7 @@ impl TagCommand {
|
||||
}
|
||||
|
||||
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()
|
||||
.with_prompt(messages.open_editor())
|
||||
|
||||
@@ -278,6 +278,22 @@ impl ConfigManager {
|
||||
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)
|
||||
pub fn delete_all_pats_for_profile(&self, profile_name: &str) -> Result<()> {
|
||||
if let Some(profile) = self.get_profile(profile_name) {
|
||||
@@ -658,6 +674,17 @@ impl ConfigManager {
|
||||
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
|
||||
pub fn export(&self) -> Result<String> {
|
||||
toml::to_string_pretty(&self.config).context("Failed to serialize config")
|
||||
|
||||
@@ -47,6 +47,10 @@ pub struct AppConfig {
|
||||
/// Language settings
|
||||
#[serde(default)]
|
||||
pub language: LanguageConfig,
|
||||
|
||||
/// Output settings
|
||||
#[serde(default)]
|
||||
pub output: OutputConfig,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -61,6 +65,7 @@ impl Default for AppConfig {
|
||||
changelog: ChangelogConfig::default(),
|
||||
repo_profiles: HashMap::new(),
|
||||
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
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Language {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use crate::config::manager::ConfigManager;
|
||||
use crate::config::{CommitFormat, Language};
|
||||
use crate::git::{CommitInfo, GitRepo};
|
||||
use crate::i18n::Messages;
|
||||
use crate::llm::parsing::GeneratedCommit;
|
||||
use crate::llm::rig::LlmClient;
|
||||
use crate::utils::{eprint_warning, success_prefix};
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// Content generator using LLM
|
||||
@@ -33,11 +35,9 @@ impl ContentGenerator {
|
||||
if thinking_enabled {
|
||||
let provider = manager.llm_provider();
|
||||
if !Self::supports_thinking(provider) {
|
||||
eprintln!(
|
||||
"Warning: Provider '{}' does not support thinking mode. \
|
||||
Disabling thinking for this invocation.",
|
||||
provider
|
||||
);
|
||||
let language = manager.get_language().unwrap_or(Language::English);
|
||||
let messages = Messages::new(language);
|
||||
eprint_warning(&messages.thinking_unsupported(provider));
|
||||
thinking_enabled = false;
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,7 @@ impl ContentGenerator {
|
||||
repo: &GitRepo,
|
||||
format: CommitFormat,
|
||||
language: Language,
|
||||
messages: &Messages,
|
||||
) -> Result<GeneratedCommit> {
|
||||
use dialoguer::Select;
|
||||
|
||||
@@ -167,33 +168,33 @@ impl ContentGenerator {
|
||||
|
||||
// Show diff summary
|
||||
let files = repo.get_staged_files()?;
|
||||
println!("\nStaged files ({}):", files.len());
|
||||
println!("\n{}", messages.staged_files(files.len()));
|
||||
for file in &files {
|
||||
println!(" • {}", file);
|
||||
}
|
||||
|
||||
// Generate initial commit
|
||||
println!("\nGenerating commit message...");
|
||||
println!("\n{}", messages.generating_commit_message());
|
||||
let mut generated = self
|
||||
.generate_commit_message(&diff, format, language)
|
||||
.await?;
|
||||
|
||||
loop {
|
||||
println!("\n{}", "─".repeat(60));
|
||||
println!("Generated commit message:");
|
||||
println!("{}", messages.generated_commit_message());
|
||||
println!("{}", "─".repeat(60));
|
||||
println!("{}", generated.to_conventional());
|
||||
println!("{}", "─".repeat(60));
|
||||
|
||||
let options = vec![
|
||||
"✓ Accept and commit",
|
||||
"🔄 Regenerate",
|
||||
"✏️ Edit",
|
||||
"❌ Cancel",
|
||||
format!("{} {}", success_prefix(), messages.accept_and_commit()),
|
||||
messages.regenerate().to_string(),
|
||||
messages.edit().to_string(),
|
||||
messages.cancel().to_string(),
|
||||
];
|
||||
|
||||
let selection = Select::new()
|
||||
.with_prompt("What would you like to do?")
|
||||
.with_prompt(messages.what_would_you_like_to_do())
|
||||
.items(&options)
|
||||
.default(0)
|
||||
.interact()?;
|
||||
@@ -201,7 +202,7 @@ impl ContentGenerator {
|
||||
match selection {
|
||||
0 => return Ok(generated),
|
||||
1 => {
|
||||
println!("Regenerating...");
|
||||
println!("{}", messages.regenerating());
|
||||
generated = self
|
||||
.generate_commit_message(&diff, format, language)
|
||||
.await?;
|
||||
@@ -210,7 +211,7 @@ impl ContentGenerator {
|
||||
let edited = crate::utils::editor::edit_content(&generated.to_conventional())?;
|
||||
generated = self.parse_edited_commit(&edited, format)?;
|
||||
}
|
||||
3 => anyhow::bail!("Cancelled by user"),
|
||||
3 => anyhow::bail!("{}", messages.cancelled_by_user()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ fn try_open_repo_with_git2(path: &Path) -> Result<Repository> {
|
||||
.or_else(|_| Repository::discover(&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> {
|
||||
@@ -642,7 +642,7 @@ impl GitRepo {
|
||||
}
|
||||
|
||||
/// 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)
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["add", "-A"])
|
||||
@@ -662,7 +662,11 @@ impl GitRepo {
|
||||
match self.remove_ignored_from_index() {
|
||||
Ok(removed) => Ok(removed),
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -937,16 +941,6 @@ impl GitRepo {
|
||||
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
|
||||
pub fn delete_tag(&self, name: &str) -> Result<()> {
|
||||
self.repo.tag_delete(name)?;
|
||||
@@ -1515,7 +1509,14 @@ mod tests {
|
||||
let head = repo.repo.head().unwrap().peel_to_commit().unwrap();
|
||||
|
||||
// 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();
|
||||
|
||||
let tags = repo.get_tags().unwrap();
|
||||
@@ -1541,7 +1542,13 @@ mod tests {
|
||||
|
||||
// Create lightweight tag
|
||||
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();
|
||||
|
||||
let tags = repo.get_tags().unwrap();
|
||||
|
||||
4001
src/i18n/messages.rs
4001
src/i18n/messages.rs
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,3 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod generator;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! 全部 provider 接入并切换后,旧实现将被删除。
|
||||
|
||||
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 rig_core::{
|
||||
client::{CompletionClient, Nothing, VerifyClient},
|
||||
@@ -154,7 +154,8 @@ fn required_api_key(provider: &str, key: Option<String>) -> Result<String> {
|
||||
}
|
||||
|
||||
impl<H> LlmClient<H> {
|
||||
/// 内部与测试构造入口。
|
||||
/// 内部与测试构造入口(仅测试使用)。
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new(
|
||||
backend: Backend<H>,
|
||||
model: impl Into<String>,
|
||||
@@ -332,7 +333,9 @@ where
|
||||
|
||||
let language_instruction = match language {
|
||||
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::Spanish => {
|
||||
"\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))?;
|
||||
match event {
|
||||
StreamedAssistantContent::Text(chunk) => {
|
||||
if has_reasoning && !has_content
|
||||
if has_reasoning
|
||||
&& !has_content
|
||||
&& let Some(state) = state
|
||||
{
|
||||
state.end_thinking();
|
||||
@@ -474,7 +478,9 @@ where
|
||||
text.push_str(&chunk.text);
|
||||
}
|
||||
StreamedAssistantContent::Reasoning(_)
|
||||
| StreamedAssistantContent::ReasoningDelta { .. } if !has_reasoning => {
|
||||
| StreamedAssistantContent::ReasoningDelta { .. }
|
||||
if !has_reasoning =>
|
||||
{
|
||||
has_reasoning = true;
|
||||
if let Some(state) = state {
|
||||
state.start_thinking();
|
||||
@@ -519,7 +525,11 @@ fn build_ollama_client(base_url: &str, http: ReqwestClient) -> Result<ollama::Cl
|
||||
}
|
||||
|
||||
/// 构建 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()
|
||||
.api_key(key)
|
||||
.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 等后缀)。
|
||||
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()
|
||||
.api_key(key)
|
||||
.base_url(base_url)
|
||||
@@ -564,7 +578,11 @@ fn build_openai_client(
|
||||
}
|
||||
|
||||
/// 构建 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()
|
||||
.api_key(key)
|
||||
.base_url(base_url)
|
||||
@@ -713,8 +731,14 @@ mod tests {
|
||||
.build()
|
||||
.expect("build deepseek client with mock backend"),
|
||||
);
|
||||
let client =
|
||||
LlmClient::new(backend, "deepseek-v4-flash", "deepseek", test_config(), thinking, None);
|
||||
let client = LlmClient::new(
|
||||
backend,
|
||||
"deepseek-v4-flash",
|
||||
"deepseek",
|
||||
test_config(),
|
||||
thinking,
|
||||
None,
|
||||
);
|
||||
(client, recorder)
|
||||
}
|
||||
|
||||
@@ -817,7 +841,10 @@ mod tests {
|
||||
assert_eq!(text, "hello from deepseek");
|
||||
|
||||
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();
|
||||
assert_eq!(body["model"], "deepseek-v4-flash");
|
||||
// 非流式请求省略 stream 字段(API 默认 false)
|
||||
@@ -843,7 +870,10 @@ mod tests {
|
||||
client.generate(None, "hi").await.unwrap();
|
||||
|
||||
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();
|
||||
assert_eq!(body["temperature"], 0.6);
|
||||
assert_eq!(body["thinking"]["type"], "disabled");
|
||||
@@ -882,7 +912,9 @@ mod tests {
|
||||
deepseek::Client::builder()
|
||||
.api_key("sk-test")
|
||||
.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()
|
||||
.expect("build deepseek client with streaming mock"),
|
||||
);
|
||||
@@ -962,9 +994,10 @@ mod tests {
|
||||
#[test]
|
||||
fn map_error_without_body_keeps_provider_prefix() {
|
||||
let err = map_completion_error("DeepSeek", CompletionError::ProviderError("boom".into()));
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("DeepSeek API request failed: ProviderError: boom"));
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("DeepSeek API request failed: ProviderError: boom")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -980,10 +1013,16 @@ mod tests {
|
||||
#[test]
|
||||
fn supports_thinking_whitelist_matches_legacy() {
|
||||
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"] {
|
||||
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();
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1111,7 +1157,9 @@ mod tests {
|
||||
anthropic::Client::builder()
|
||||
.api_key("sk-ant-test")
|
||||
.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()
|
||||
.expect("build anthropic client with streaming mock"),
|
||||
);
|
||||
@@ -1186,12 +1234,18 @@ mod tests {
|
||||
assert_eq!(text, "hello from openai");
|
||||
|
||||
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();
|
||||
assert_eq!(body["model"], "gpt-4o");
|
||||
assert_eq!(body["temperature"], 0.5);
|
||||
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]
|
||||
@@ -1200,7 +1254,9 @@ mod tests {
|
||||
let (client, _) = openai_client(recorder, "o3", false);
|
||||
|
||||
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!(!options.stream);
|
||||
}
|
||||
@@ -1328,7 +1384,10 @@ mod tests {
|
||||
let captured = recorder.requests();
|
||||
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).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();
|
||||
assert!(user.contains("diff --git"), "user prompt 应包含 diff");
|
||||
assert!(user.contains("请用中文生成提交消息"), "应包含语言指令");
|
||||
@@ -1336,12 +1395,15 @@ mod tests {
|
||||
|
||||
// ---- 实网冒烟(#[ignore],需真实服务/API key)----
|
||||
|
||||
fn smoke_client(
|
||||
backend: Backend,
|
||||
model: &str,
|
||||
provider: &str,
|
||||
) -> LlmClient {
|
||||
LlmClient::new(backend, model, provider, LlmClientConfig::default(), false, None)
|
||||
fn smoke_client(backend: Backend, model: &str, provider: &str) -> LlmClient {
|
||||
LlmClient::new(
|
||||
backend,
|
||||
model,
|
||||
provider,
|
||||
LlmClientConfig::default(),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn smoke_http() -> ReqwestClient {
|
||||
@@ -1370,7 +1432,9 @@ mod tests {
|
||||
#[ignore = "requires QUICOMMIT_API_KEY with a valid DeepSeek key"]
|
||||
#[tokio::test]
|
||||
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(
|
||||
deepseek::Client::builder()
|
||||
.api_key(&key)
|
||||
@@ -1387,7 +1451,9 @@ mod tests {
|
||||
#[ignore = "requires QUICOMMIT_API_KEY with a valid Moonshot key"]
|
||||
#[tokio::test]
|
||||
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(
|
||||
moonshot::Client::builder()
|
||||
.api_key(&key)
|
||||
@@ -1404,7 +1470,9 @@ mod tests {
|
||||
#[ignore = "requires QUICOMMIT_API_KEY with a valid Anthropic key"]
|
||||
#[tokio::test]
|
||||
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(
|
||||
anthropic::Client::builder()
|
||||
.api_key(&key)
|
||||
@@ -1421,7 +1489,9 @@ mod tests {
|
||||
#[ignore = "requires QUICOMMIT_API_KEY with a valid OpenAI key"]
|
||||
#[tokio::test]
|
||||
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(
|
||||
openai::Client::builder()
|
||||
.api_key(&key)
|
||||
@@ -1439,7 +1509,9 @@ mod tests {
|
||||
#[ignore = "requires QUICOMMIT_API_KEY with a valid OpenRouter key"]
|
||||
#[tokio::test]
|
||||
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(
|
||||
openrouter::Client::builder()
|
||||
.api_key(&key)
|
||||
|
||||
@@ -64,18 +64,10 @@ impl Default for ThinkingStateManager {
|
||||
/// 线程安全的思考状态管理器引用
|
||||
pub type SharedThinkingState = Arc<ThinkingStateManager>;
|
||||
|
||||
/// 创建带有默认控制台输出的思考状态管理器
|
||||
/// 在思考开始时打印 "thinking...",在思考结束时清除该标识
|
||||
/// 创建 LLM 流式使用的共享思考状态。
|
||||
/// 进度显示由 AI 生成 spinner 负责(issue 21),此处不再附加控制台输出。
|
||||
pub fn create_console_thinking_state() -> SharedThinkingState {
|
||||
Arc::new(
|
||||
ThinkingStateManager::new()
|
||||
.on_thinking_start(|| {
|
||||
eprint!("\rthinking...");
|
||||
})
|
||||
.on_thinking_end(|| {
|
||||
eprint!("\r \r");
|
||||
}),
|
||||
)
|
||||
Arc::new(ThinkingStateManager::new())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
44
src/main.rs
44
src/main.rs
@@ -1,5 +1,3 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
@@ -22,7 +20,7 @@ use quicommit::commands::{
|
||||
#[command(propagate_version = true)]
|
||||
#[command(arg_required_else_help = true)]
|
||||
struct Cli {
|
||||
/// Enable verbose output
|
||||
/// Increase verbosity (-v: info, -vv: debug, -vvv: trace)
|
||||
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
||||
verbose: u8,
|
||||
|
||||
@@ -31,9 +29,17 @@ struct Cli {
|
||||
config: Option<String>,
|
||||
|
||||
/// Disable colored output
|
||||
#[arg(long, global = true, env = "NO_COLOR")]
|
||||
#[arg(long, global = true)]
|
||||
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: Commands,
|
||||
}
|
||||
@@ -73,6 +79,31 @@ enum Commands {
|
||||
async fn main() -> Result<()> {
|
||||
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 {
|
||||
0 => "warn",
|
||||
1 => "info",
|
||||
@@ -80,8 +111,11 @@ async fn main() -> Result<()> {
|
||||
_ => "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()
|
||||
.with_env_filter(log_level)
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.init();
|
||||
|
||||
|
||||
@@ -232,9 +232,7 @@ impl KeyringManager {
|
||||
"Keyring is available".to_string()
|
||||
}
|
||||
}
|
||||
KeyringStatus::Unavailable => {
|
||||
"Keyring is not available. Set QUICOMMIT_API_KEY environment variable.".to_string()
|
||||
}
|
||||
KeyringStatus::Unavailable => "Keyring is not available on this system.".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
156
src/utils/mod.rs
156
src/utils/mod.rs
@@ -6,48 +6,144 @@ pub mod validators;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
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
|
||||
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) {
|
||||
eprintln!("{} {}", "✗".red().bold(), msg);
|
||||
print_decorated(error_prefix(), msg, true);
|
||||
}
|
||||
|
||||
/// Print warning message
|
||||
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
|
||||
pub fn print_info(msg: &str) {
|
||||
println!("{} {}", "ℹ".blue().bold(), msg);
|
||||
print_decorated(info_prefix(), msg, false);
|
||||
}
|
||||
|
||||
/// Confirm action with user
|
||||
pub fn confirm(prompt: &str) -> Result<bool> {
|
||||
print!("{} [y/N] ", prompt);
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
|
||||
Ok(input.trim().to_lowercase().starts_with('y'))
|
||||
/// Print progress/status message (→ prefix when decorations are on)
|
||||
pub fn print_progress(msg: &str) {
|
||||
print_decorated(progress_prefix(), msg, false);
|
||||
}
|
||||
|
||||
/// Get user input
|
||||
pub fn input(prompt: &str) -> Result<String> {
|
||||
print!("{}: ", prompt);
|
||||
io::stdout().flush()?;
|
||||
/// Progress spinner for long-running operations (issue 21).
|
||||
/// Degrades to a static line when stdout is not a terminal or when
|
||||
/// decorations are disabled.
|
||||
pub struct Spinner {
|
||||
bar: Option<indicatif::ProgressBar>,
|
||||
}
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
impl Spinner {
|
||||
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)
|
||||
@@ -59,19 +155,3 @@ pub fn password_input(prompt: &str) -> Result<String> {
|
||||
.interact()
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,8 @@ fn test_stage_all_removes_ignored_tracked_files() {
|
||||
write_file(repo_path, "__pycache__/foo.pyc", "modified bytecode");
|
||||
|
||||
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!(
|
||||
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() {}");
|
||||
|
||||
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!(
|
||||
removed.is_empty(),
|
||||
|
||||
@@ -258,6 +258,190 @@ mod config_command {
|
||||
.success()
|
||||
.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 {
|
||||
@@ -438,6 +622,8 @@ mod tag_command {
|
||||
"tag",
|
||||
"--name",
|
||||
"v0.1.0",
|
||||
"-m",
|
||||
"Release v0.1.0",
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
"--config",
|
||||
@@ -469,6 +655,8 @@ mod tag_command {
|
||||
"--think",
|
||||
"--name",
|
||||
"v0.2.0",
|
||||
"-m",
|
||||
"Release v0.2.0",
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
"--config",
|
||||
@@ -528,6 +716,7 @@ mod changelog_command {
|
||||
"changelog",
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
"--no-generate",
|
||||
"--config",
|
||||
config_path.to_str().unwrap(),
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user