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

View File

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

View File

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

View File

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

View File

@@ -1,458 +1,457 @@
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use crate::config::manager::ConfigManager;
use crate::config::{TokenConfig, TokenType};
/// Git credential helper command.
///
/// Implements the git credential helper protocol
/// (https://git-scm.com/docs/gitcredentials). Intended to be invoked by git
/// via `credential.helper` configuration, not by end users. Hidden from the
/// main help output.
#[derive(Parser)]
#[command(hide = true)]
pub struct CredentialCommand {
#[command(subcommand)]
command: CredentialSubcommand,
}
#[derive(Subcommand)]
enum CredentialSubcommand {
/// Read attributes on stdin and output credentials on stdout.
#[command(hide = true)]
Get,
/// Read attributes (including password) on stdin and store them.
#[command(hide = true)]
Store,
/// Read attributes on stdin and erase any matching stored credentials.
#[command(hide = true)]
Erase,
}
impl CredentialCommand {
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
match &self.command {
CredentialSubcommand::Get => Self::get(config_path),
CredentialSubcommand::Store => Self::store(config_path),
CredentialSubcommand::Erase => Self::erase(config_path),
}
}
fn get_manager(config_path: &Option<PathBuf>) -> Result<ConfigManager> {
match config_path {
Some(path) => ConfigManager::with_path(path),
None => ConfigManager::new(),
}
}
/// `git credential get`: look up a PAT for the requested host and emit it
/// on stdout following the git credential helper protocol.
fn get(config_path: Option<PathBuf>) -> Result<()> {
let attrs = CredentialAttributes::from_stdin()?;
let host = match attrs.host.as_deref() {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
let service = host_to_service(host);
let manager = match Self::get_manager(&config_path) {
Ok(m) => m,
Err(_) => return Ok(()),
};
let (profile_name, pat) = match find_pat_for_service(&manager, &service) {
Some(tuple) => tuple,
None => return Ok(()),
};
// Prefer a git-supplied username; fall back to the profile's user_name.
let username = attrs.username.clone().or_else(|| {
manager
.get_profile(&profile_name)
.map(|p| p.user_name.clone())
});
let output = CredentialAttributes {
protocol: attrs.protocol.clone(),
host: attrs.host.clone(),
path: attrs.path.clone(),
username,
password: Some(pat),
};
output.to_stdout()?;
Ok(())
}
/// `git credential store`: persist the PAT provided by git using the
/// existing keyring-backed storage, associated with a matching profile.
fn store(config_path: Option<PathBuf>) -> Result<()> {
let attrs = CredentialAttributes::from_stdin()?;
let host = match attrs.host.as_deref() {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
let service = host_to_service(host);
let password = match attrs.password.as_deref() {
Some(p) if !p.is_empty() => p,
_ => return Ok(()),
};
let mut manager = Self::get_manager(&config_path)?;
let profile_name = match find_profile_for_store(&manager, attrs.username.as_deref()) {
Some(name) => name,
None => return Ok(()),
};
// Store PAT in keyring using the existing profile-bound logic.
if let Err(e) = manager.store_pat_for_profile(&profile_name, &service, password) {
eprintln!("[quicommit credential] failed to store PAT: {}", e);
return Ok(());
}
// Register the token in the profile config if not already present.
let already_has = manager
.get_profile(&profile_name)
.map(|p| p.tokens.contains_key(&service))
.unwrap_or(false);
if !already_has {
let _ = manager.add_token_to_profile(
&profile_name,
service.clone(),
TokenConfig::new(TokenType::Personal),
);
}
manager.save()?;
Ok(())
}
/// `git credential erase`: remove any PAT stored for the requested host
/// from the keyring and the associated profile config entries.
fn erase(config_path: Option<PathBuf>) -> Result<()> {
let attrs = CredentialAttributes::from_stdin()?;
let host = match attrs.host.as_deref() {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
let service = host_to_service(host);
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();
for name in &profile_names {
if let Err(e) = manager.remove_token_from_profile(name, &service) {
eprintln!(
"[quicommit credential] failed to erase PAT for '{}': {}",
name, e
);
}
}
manager.save()?;
Ok(())
}
}
/// Credential attributes exchanged with git via stdin/stdout following the
/// git credential helper protocol (`key=value`, one per line, blank line ends).
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CredentialAttributes {
pub protocol: Option<String>,
pub host: Option<String>,
pub path: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
}
impl CredentialAttributes {
/// Read attributes from stdin following the git credential helper protocol.
pub fn from_stdin() -> Result<Self> {
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")?;
if line.is_empty() {
break;
}
if let Some((key, value)) = line.split_once('=') {
match key {
"protocol" => attrs.protocol = Some(value.to_string()),
"host" => attrs.host = Some(value.to_string()),
"path" => attrs.path = Some(value.to_string()),
"username" => attrs.username = Some(value.to_string()),
"password" => attrs.password = Some(value.to_string()),
_ => {} // ignore unknown keys
}
}
}
Ok(attrs)
}
/// Write attributes to stdout following the git credential helper protocol.
pub fn to_stdout(&self) -> Result<()> {
let stdout = io::stdout();
let mut handle = stdout.lock();
if let Some(ref v) = self.protocol {
writeln!(handle, "protocol={}", v)?;
}
if let Some(ref v) = self.host {
writeln!(handle, "host={}", v)?;
}
if let Some(ref v) = self.path {
writeln!(handle, "path={}", v)?;
}
if let Some(ref v) = self.username {
writeln!(handle, "username={}", v)?;
}
if let Some(ref v) = self.password {
writeln!(handle, "password={}", v)?;
}
writeln!(handle)?; // blank line terminates the attribute list
handle.flush()?;
Ok(())
}
/// Parse attributes from a text block following the git credential helper
/// protocol. Intended for testing and non-stdin input handling.
pub fn parse_str(input: &str) -> Self {
let mut attrs = Self::default();
for line in input.lines() {
if line.is_empty() {
break;
}
if let Some((key, value)) = line.split_once('=') {
match key {
"protocol" => attrs.protocol = Some(value.to_string()),
"host" => attrs.host = Some(value.to_string()),
"path" => attrs.path = Some(value.to_string()),
"username" => attrs.username = Some(value.to_string()),
"password" => attrs.password = Some(value.to_string()),
_ => {}
}
}
}
attrs
}
/// Serialize attributes to a string following the git credential helper
/// protocol. Intended for testing and non-stdout output handling.
pub fn serialize(&self) -> String {
let mut out = String::new();
if let Some(ref v) = self.protocol {
out.push_str(&format!("protocol={}\n", v));
}
if let Some(ref v) = self.host {
out.push_str(&format!("host={}\n", v));
}
if let Some(ref v) = self.path {
out.push_str(&format!("path={}\n", v));
}
if let Some(ref v) = self.username {
out.push_str(&format!("username={}\n", v));
}
if let Some(ref v) = self.password {
out.push_str(&format!("password={}\n", v));
}
out.push('\n');
out
}
}
/// Map a git host to a service name used by the keyring-backed PAT storage.
///
/// Common git hosting services are mapped to short canonical names. Unknown
/// hosts are used as-is (lowercased, trailing slash trimmed).
pub fn host_to_service(host: &str) -> String {
let host = host.to_lowercase();
let host = host.trim_end_matches('/');
match host {
"github.com" | "www.github.com" => "github".to_string(),
"gitlab.com" | "www.gitlab.com" => "gitlab".to_string(),
"bitbucket.org" | "www.bitbucket.org" => "bitbucket".to_string(),
"codeberg.org" | "www.codeberg.org" => "codeberg".to_string(),
"gitea.com" | "www.gitea.com" => "gitea".to_string(),
"gitee.com" | "www.gitee.com" => "gitee".to_string(),
other => other.to_string(),
}
}
/// Search all profiles for one that has a PAT stored for the given service.
/// Returns the profile name and the PAT value.
fn find_pat_for_service(manager: &ConfigManager, service: &str) -> Option<(String, String)> {
for profile_name in manager.list_profiles() {
if manager.has_pat_for_profile(profile_name, service) {
if let Ok(Some(pat)) = manager.get_pat_for_profile(profile_name, service) {
return Some((profile_name.clone(), pat));
}
}
}
None
}
/// Determine which profile to use when storing a credential.
///
/// 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> {
if let Some(username) = username {
for name in manager.list_profiles() {
if let Some(profile) = manager.get_profile(name) {
if profile.user_name == username || profile.user_email == username {
return Some(name.clone());
}
}
}
}
manager.default_profile_name().cloned()
}
/// Extract a PAT for the given host from saved credentials across all profiles.
///
/// This is intended for use by other parts of the application (e.g. when
/// verifying access to a git hosting service) and searches every configured
/// profile for a stored PAT matching the host.
pub fn get_pat_for_host(host: &str) -> Result<Option<String>> {
let manager = ConfigManager::new()?;
let service = host_to_service(host);
Ok(find_pat_for_service(&manager, &service).map(|(_, pat)| pat))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_host_to_service_known_hosts() {
assert_eq!(host_to_service("github.com"), "github");
assert_eq!(host_to_service("www.github.com"), "github");
assert_eq!(host_to_service("gitlab.com"), "gitlab");
assert_eq!(host_to_service("bitbucket.org"), "bitbucket");
assert_eq!(host_to_service("codeberg.org"), "codeberg");
assert_eq!(host_to_service("gitea.com"), "gitea");
assert_eq!(host_to_service("gitee.com"), "gitee");
}
#[test]
fn test_host_to_service_case_insensitive() {
assert_eq!(host_to_service("GitHub.Com"), "github");
assert_eq!(host_to_service("GITLAB.COM"), "gitlab");
}
#[test]
fn test_host_to_service_trailing_slash() {
assert_eq!(host_to_service("github.com/"), "github");
assert_eq!(host_to_service("gitlab.com//"), "gitlab");
}
#[test]
fn test_host_to_service_unknown_host() {
assert_eq!(host_to_service("example.com"), "example.com");
assert_eq!(host_to_service("git.internal.corp"), "git.internal.corp");
}
#[test]
fn test_credential_attributes_parse_basic() {
let input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_token123\n\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.protocol.as_deref(), Some("https"));
assert_eq!(attrs.host.as_deref(), Some("github.com"));
assert_eq!(attrs.username.as_deref(), Some("octocat"));
assert_eq!(attrs.password.as_deref(), Some("ghp_token123"));
assert!(attrs.path.is_none());
}
#[test]
fn test_credential_attributes_parse_with_path() {
let input = "protocol=https\nhost=github.com\npath=owner/repo.git\n\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.path.as_deref(), Some("owner/repo.git"));
}
#[test]
fn test_credential_attributes_parse_empty() {
let attrs = CredentialAttributes::parse_str("");
assert!(attrs.protocol.is_none());
assert!(attrs.host.is_none());
assert!(attrs.username.is_none());
assert!(attrs.password.is_none());
}
#[test]
fn test_credential_attributes_parse_ignores_unknown_keys() {
let input = "protocol=https\nhost=github.com\nunknown=value\nfoo=bar\n\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.protocol.as_deref(), Some("https"));
assert_eq!(attrs.host.as_deref(), Some("github.com"));
}
#[test]
fn test_credential_attributes_parse_stops_at_blank_line() {
let input = "protocol=https\nhost=github.com\n\npassword=should_be_ignored\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.protocol.as_deref(), Some("https"));
assert_eq!(attrs.host.as_deref(), Some("github.com"));
assert!(attrs.password.is_none());
}
#[test]
fn test_credential_attributes_serialize_roundtrip() {
let attrs = CredentialAttributes {
protocol: Some("https".to_string()),
host: Some("github.com".to_string()),
path: None,
username: Some("octocat".to_string()),
password: Some("ghp_token".to_string()),
};
let serialized = attrs.serialize();
let reparsed = CredentialAttributes::parse_str(&serialized);
assert_eq!(attrs, reparsed);
}
#[test]
fn test_credential_attributes_serialize_includes_blank_line() {
let attrs = CredentialAttributes {
protocol: Some("https".to_string()),
host: Some("github.com".to_string()),
path: None,
username: None,
password: None,
};
let serialized = attrs.serialize();
assert!(serialized.ends_with("\n\n"));
assert!(serialized.contains("protocol=https\n"));
assert!(serialized.contains("host=github.com\n"));
}
#[test]
fn test_credential_attributes_default() {
let attrs = CredentialAttributes::default();
assert!(attrs.protocol.is_none());
assert!(attrs.host.is_none());
assert!(attrs.path.is_none());
assert!(attrs.username.is_none());
assert!(attrs.password.is_none());
}
}
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use crate::config::manager::ConfigManager;
use crate::config::{TokenConfig, TokenType};
/// Git credential helper command.
///
/// Implements the git credential helper protocol
/// (https://git-scm.com/docs/gitcredentials). Intended to be invoked by git
/// via `credential.helper` configuration, not by end users. Hidden from the
/// main help output.
#[derive(Parser)]
#[command(hide = true)]
pub struct CredentialCommand {
#[command(subcommand)]
command: CredentialSubcommand,
}
#[derive(Subcommand)]
enum CredentialSubcommand {
/// Read attributes on stdin and output credentials on stdout.
#[command(hide = true)]
Get,
/// Read attributes (including password) on stdin and store them.
#[command(hide = true)]
Store,
/// Read attributes on stdin and erase any matching stored credentials.
#[command(hide = true)]
Erase,
}
impl CredentialCommand {
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
match &self.command {
CredentialSubcommand::Get => Self::get(config_path),
CredentialSubcommand::Store => Self::store(config_path),
CredentialSubcommand::Erase => Self::erase(config_path),
}
}
fn get_manager(config_path: &Option<PathBuf>) -> Result<ConfigManager> {
match config_path {
Some(path) => ConfigManager::with_path(path),
None => ConfigManager::new(),
}
}
/// `git credential get`: look up a PAT for the requested host and emit it
/// on stdout following the git credential helper protocol.
fn get(config_path: Option<PathBuf>) -> Result<()> {
let attrs = CredentialAttributes::from_stdin()?;
let host = match attrs.host.as_deref() {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
let service = host_to_service(host);
let manager = match Self::get_manager(&config_path) {
Ok(m) => m,
Err(_) => return Ok(()),
};
let (profile_name, pat) = match find_pat_for_service(&manager, &service) {
Some(tuple) => tuple,
None => return Ok(()),
};
// Prefer a git-supplied username; fall back to the profile's user_name.
let username = attrs.username.clone().or_else(|| {
manager
.get_profile(&profile_name)
.map(|p| p.user_name.clone())
});
let output = CredentialAttributes {
protocol: attrs.protocol.clone(),
host: attrs.host.clone(),
path: attrs.path.clone(),
username,
password: Some(pat),
};
output.to_stdout()?;
Ok(())
}
/// `git credential store`: persist the PAT provided by git using the
/// existing keyring-backed storage, associated with a matching profile.
fn store(config_path: Option<PathBuf>) -> Result<()> {
let attrs = CredentialAttributes::from_stdin()?;
let host = match attrs.host.as_deref() {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
let service = host_to_service(host);
let password = match attrs.password.as_deref() {
Some(p) if !p.is_empty() => p,
_ => return Ok(()),
};
let mut manager = Self::get_manager(&config_path)?;
let profile_name = match find_profile_for_store(&manager, attrs.username.as_deref()) {
Some(name) => name,
None => return Ok(()),
};
// Store PAT in keyring using the existing profile-bound logic.
if let Err(e) = manager.store_pat_for_profile(&profile_name, &service, password) {
eprintln!("[quicommit credential] failed to store PAT: {}", e);
return Ok(());
}
// Register the token in the profile config if not already present.
let already_has = manager
.get_profile(&profile_name)
.map(|p| p.tokens.contains_key(&service))
.unwrap_or(false);
if !already_has {
let _ = manager.add_token_to_profile(
&profile_name,
service.clone(),
TokenConfig::new(TokenType::Personal),
);
}
manager.save()?;
Ok(())
}
/// `git credential erase`: remove any PAT stored for the requested host
/// from the keyring and the associated profile config entries.
fn erase(config_path: Option<PathBuf>) -> Result<()> {
let attrs = CredentialAttributes::from_stdin()?;
let host = match attrs.host.as_deref() {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
let service = host_to_service(host);
let mut manager = Self::get_manager(&config_path)?;
let profile_names: Vec<String> = manager.list_profiles().into_iter().cloned().collect();
for name in &profile_names {
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] erased unregistered PAT for '{}' (service {}) from keyring",
name, service
);
}
}
manager.save()?;
Ok(())
}
}
/// Credential attributes exchanged with git via stdin/stdout following the
/// git credential helper protocol (`key=value`, one per line, blank line ends).
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CredentialAttributes {
pub protocol: Option<String>,
pub host: Option<String>,
pub path: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
}
impl CredentialAttributes {
/// Read attributes from stdin following the git credential helper protocol.
pub fn from_stdin() -> Result<Self> {
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")?;
if line.is_empty() {
break;
}
if let Some((key, value)) = line.split_once('=') {
match key {
"protocol" => attrs.protocol = Some(value.to_string()),
"host" => attrs.host = Some(value.to_string()),
"path" => attrs.path = Some(value.to_string()),
"username" => attrs.username = Some(value.to_string()),
"password" => attrs.password = Some(value.to_string()),
_ => {} // ignore unknown keys
}
}
}
Ok(attrs)
}
/// Write attributes to stdout following the git credential helper protocol.
pub fn to_stdout(&self) -> Result<()> {
let stdout = io::stdout();
let mut handle = stdout.lock();
if let Some(ref v) = self.protocol {
writeln!(handle, "protocol={}", v)?;
}
if let Some(ref v) = self.host {
writeln!(handle, "host={}", v)?;
}
if let Some(ref v) = self.path {
writeln!(handle, "path={}", v)?;
}
if let Some(ref v) = self.username {
writeln!(handle, "username={}", v)?;
}
if let Some(ref v) = self.password {
writeln!(handle, "password={}", v)?;
}
writeln!(handle)?; // blank line terminates the attribute list
handle.flush()?;
Ok(())
}
/// Parse attributes from a text block following the git credential helper
/// protocol. Intended for testing and non-stdin input handling.
pub fn parse_str(input: &str) -> Self {
let mut attrs = Self::default();
for line in input.lines() {
if line.is_empty() {
break;
}
if let Some((key, value)) = line.split_once('=') {
match key {
"protocol" => attrs.protocol = Some(value.to_string()),
"host" => attrs.host = Some(value.to_string()),
"path" => attrs.path = Some(value.to_string()),
"username" => attrs.username = Some(value.to_string()),
"password" => attrs.password = Some(value.to_string()),
_ => {}
}
}
}
attrs
}
/// Serialize attributes to a string following the git credential helper
/// protocol. Intended for testing and non-stdout output handling.
pub fn serialize(&self) -> String {
let mut out = String::new();
if let Some(ref v) = self.protocol {
out.push_str(&format!("protocol={}\n", v));
}
if let Some(ref v) = self.host {
out.push_str(&format!("host={}\n", v));
}
if let Some(ref v) = self.path {
out.push_str(&format!("path={}\n", v));
}
if let Some(ref v) = self.username {
out.push_str(&format!("username={}\n", v));
}
if let Some(ref v) = self.password {
out.push_str(&format!("password={}\n", v));
}
out.push('\n');
out
}
}
/// Map a git host to a service name used by the keyring-backed PAT storage.
///
/// Common git hosting services are mapped to short canonical names. Unknown
/// hosts are used as-is (lowercased, trailing slash trimmed).
pub fn host_to_service(host: &str) -> String {
let host = host.to_lowercase();
let host = host.trim_end_matches('/');
match host {
"github.com" | "www.github.com" => "github".to_string(),
"gitlab.com" | "www.gitlab.com" => "gitlab".to_string(),
"bitbucket.org" | "www.bitbucket.org" => "bitbucket".to_string(),
"codeberg.org" | "www.codeberg.org" => "codeberg".to_string(),
"gitea.com" | "www.gitea.com" => "gitea".to_string(),
"gitee.com" | "www.gitee.com" => "gitee".to_string(),
other => other.to_string(),
}
}
/// Search all profiles for one that has a PAT stored for the given service.
/// Returns the profile name and the PAT value.
fn find_pat_for_service(manager: &ConfigManager, service: &str) -> Option<(String, String)> {
for profile_name in manager.list_profiles() {
if manager.has_pat_for_profile(profile_name, service) {
if let Ok(Some(pat)) = manager.get_pat_for_profile(profile_name, service) {
return Some((profile_name.clone(), pat));
}
}
}
None
}
/// Determine which profile to use when storing a credential.
///
/// 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> {
if let Some(username) = username {
for name in manager.list_profiles() {
if let Some(profile) = manager.get_profile(name) {
if profile.user_name == username || profile.user_email == username {
return Some(name.clone());
}
}
}
}
manager.default_profile_name().cloned()
}
/// Extract a PAT for the given host from saved credentials across all profiles.
///
/// This is intended for use by other parts of the application (e.g. when
/// verifying access to a git hosting service) and searches every configured
/// profile for a stored PAT matching the host.
pub fn get_pat_for_host(host: &str) -> Result<Option<String>> {
let manager = ConfigManager::new()?;
let service = host_to_service(host);
Ok(find_pat_for_service(&manager, &service).map(|(_, pat)| pat))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_host_to_service_known_hosts() {
assert_eq!(host_to_service("github.com"), "github");
assert_eq!(host_to_service("www.github.com"), "github");
assert_eq!(host_to_service("gitlab.com"), "gitlab");
assert_eq!(host_to_service("bitbucket.org"), "bitbucket");
assert_eq!(host_to_service("codeberg.org"), "codeberg");
assert_eq!(host_to_service("gitea.com"), "gitea");
assert_eq!(host_to_service("gitee.com"), "gitee");
}
#[test]
fn test_host_to_service_case_insensitive() {
assert_eq!(host_to_service("GitHub.Com"), "github");
assert_eq!(host_to_service("GITLAB.COM"), "gitlab");
}
#[test]
fn test_host_to_service_trailing_slash() {
assert_eq!(host_to_service("github.com/"), "github");
assert_eq!(host_to_service("gitlab.com//"), "gitlab");
}
#[test]
fn test_host_to_service_unknown_host() {
assert_eq!(host_to_service("example.com"), "example.com");
assert_eq!(host_to_service("git.internal.corp"), "git.internal.corp");
}
#[test]
fn test_credential_attributes_parse_basic() {
let input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_token123\n\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.protocol.as_deref(), Some("https"));
assert_eq!(attrs.host.as_deref(), Some("github.com"));
assert_eq!(attrs.username.as_deref(), Some("octocat"));
assert_eq!(attrs.password.as_deref(), Some("ghp_token123"));
assert!(attrs.path.is_none());
}
#[test]
fn test_credential_attributes_parse_with_path() {
let input = "protocol=https\nhost=github.com\npath=owner/repo.git\n\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.path.as_deref(), Some("owner/repo.git"));
}
#[test]
fn test_credential_attributes_parse_empty() {
let attrs = CredentialAttributes::parse_str("");
assert!(attrs.protocol.is_none());
assert!(attrs.host.is_none());
assert!(attrs.username.is_none());
assert!(attrs.password.is_none());
}
#[test]
fn test_credential_attributes_parse_ignores_unknown_keys() {
let input = "protocol=https\nhost=github.com\nunknown=value\nfoo=bar\n\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.protocol.as_deref(), Some("https"));
assert_eq!(attrs.host.as_deref(), Some("github.com"));
}
#[test]
fn test_credential_attributes_parse_stops_at_blank_line() {
let input = "protocol=https\nhost=github.com\n\npassword=should_be_ignored\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.protocol.as_deref(), Some("https"));
assert_eq!(attrs.host.as_deref(), Some("github.com"));
assert!(attrs.password.is_none());
}
#[test]
fn test_credential_attributes_serialize_roundtrip() {
let attrs = CredentialAttributes {
protocol: Some("https".to_string()),
host: Some("github.com".to_string()),
path: None,
username: Some("octocat".to_string()),
password: Some("ghp_token".to_string()),
};
let serialized = attrs.serialize();
let reparsed = CredentialAttributes::parse_str(&serialized);
assert_eq!(attrs, reparsed);
}
#[test]
fn test_credential_attributes_serialize_includes_blank_line() {
let attrs = CredentialAttributes {
protocol: Some("https".to_string()),
host: Some("github.com".to_string()),
path: None,
username: None,
password: None,
};
let serialized = attrs.serialize();
assert!(serialized.ends_with("\n\n"));
assert!(serialized.contains("protocol=https\n"));
assert!(serialized.contains("host=github.com\n"));
}
#[test]
fn test_credential_attributes_default() {
let attrs = CredentialAttributes::default();
assert!(attrs.protocol.is_none());
assert!(attrs.host.is_none());
assert!(attrs.path.is_none());
assert!(attrs.username.is_none());
assert!(attrs.password.is_none());
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()),
_ => {}
}
}

View File

@@ -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();

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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)
@@ -1453,4 +1525,4 @@ mod tests {
let text = client.generate(None, "Say 'Hello, World!'").await.unwrap();
assert!(!text.is_empty());
}
}
}

View File

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

View File

@@ -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();

View File

@@ -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(),
}
}
}

View File

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

View File

@@ -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(),

View File

@@ -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(),
])