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
|
||||||
quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2
|
quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2
|
||||||
|
|
||||||
|
> ⚠️ **Security note**: passing your API key as a CLI argument puts it in your shell
|
||||||
|
> history and process list. Prefer running `quicommit config set-api-key` (or
|
||||||
|
> `config set-llm`) without the key to enter it via a hidden prompt instead.
|
||||||
|
|
||||||
# Configure OpenAI
|
# Configure OpenAI
|
||||||
quicommit config set-llm openai
|
quicommit config set-llm openai
|
||||||
quicommit config set-api-key YOUR_API_KEY
|
quicommit config set-api-key YOUR_API_KEY
|
||||||
@@ -329,7 +333,7 @@ quicommit config reset --force
|
|||||||
| `--commitlint` | Use commitlint format |
|
| `--commitlint` | Use commitlint format |
|
||||||
| `--no-verify` | Skip commit message verification |
|
| `--no-verify` | Skip commit message verification |
|
||||||
| `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) |
|
| `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) |
|
||||||
| `-y, --yes` | Skip confirmation |
|
| `-y, --yes` | Skip confirmation prompts only (generation behavior unchanged) |
|
||||||
| `--push` | Push after committing |
|
| `--push` | Push after committing |
|
||||||
| `--remote` | Specify remote repository (default: origin) |
|
| `--remote` | Specify remote repository (default: origin) |
|
||||||
|
|
||||||
@@ -349,7 +353,7 @@ quicommit config reset --force
|
|||||||
| `-r, --remote` | Specify remote repository (default: origin) |
|
| `-r, --remote` | Specify remote repository (default: origin) |
|
||||||
| `--dry-run` | Dry run |
|
| `--dry-run` | Dry run |
|
||||||
| `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) |
|
| `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) |
|
||||||
| `-y, --yes` | Skip confirmation |
|
| `-y, --yes` | Skip confirmation prompts only (generation behavior unchanged) |
|
||||||
|
|
||||||
### Changelog Options
|
### Changelog Options
|
||||||
|
|
||||||
@@ -366,7 +370,8 @@ quicommit config reset --force
|
|||||||
| `--format` | Format (keep-a-changelog, github-releases) |
|
| `--format` | Format (keep-a-changelog, github-releases) |
|
||||||
| `--dry-run` | Dry run (output to stdout) |
|
| `--dry-run` | Dry run (output to stdout) |
|
||||||
| `--think` | Enable LLM thinking/reasoning mode (overrides config) |
|
| `--think` | Enable LLM thinking/reasoning mode (overrides config) |
|
||||||
| `-y, --yes` | Skip confirmation |
|
| `--no-generate` | Generate deterministically from template (no AI call) |
|
||||||
|
| `-y, --yes` | Skip confirmation prompts only (generation behavior unchanged) |
|
||||||
|
|
||||||
## Configuration File
|
## Configuration File
|
||||||
|
|
||||||
@@ -425,6 +430,9 @@ auto_generate = true
|
|||||||
path = "CHANGELOG.md"
|
path = "CHANGELOG.md"
|
||||||
auto_generate = true
|
auto_generate = true
|
||||||
|
|
||||||
|
[output]
|
||||||
|
emoji = true
|
||||||
|
|
||||||
[repo_profiles]
|
[repo_profiles]
|
||||||
"/path/to/work/project" = "work"
|
"/path/to/work/project" = "work"
|
||||||
"/path/to/personal/project" = "personal"
|
"/path/to/personal/project" = "personal"
|
||||||
@@ -436,7 +444,15 @@ auto_generate = true
|
|||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `QUICOMMIT_CONFIG` | Configuration file path |
|
| `QUICOMMIT_CONFIG` | Configuration file path |
|
||||||
| `EDITOR` | Default editor |
|
| `EDITOR` | Default editor |
|
||||||
| `NO_COLOR` | Disable colored output |
|
| `NO_COLOR` | Disable colored output and emoji decorations (any value) |
|
||||||
|
| `RUST_LOG` | Overrides `-v` log filtering (e.g. `debug`) |
|
||||||
|
|
||||||
|
## Global Options
|
||||||
|
|
||||||
|
- `-v, --verbose` (repeatable): `-v` shows info logs, `-vv` debug, `-vvv` trace.
|
||||||
|
- `--no-color`: disable colors **and** emoji decorations (highest priority).
|
||||||
|
- `--emoji` / `--no-emoji`: force emoji decorations on/off, overriding the `output.emoji` config value.
|
||||||
|
- Emoji decorations can also be configured via `config set output.emoji true|false`.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -453,7 +469,7 @@ quicommit config set llm.provider ollama
|
|||||||
# Get configuration value
|
# Get configuration value
|
||||||
quicommit config get llm.provider
|
quicommit config get llm.provider
|
||||||
|
|
||||||
# Set API key (stored in system keyring)
|
# Set API key (stored in system keyring; omit the value for hidden prompt input)
|
||||||
quicommit config set-api-key YOUR_API_KEY
|
quicommit config set-api-key YOUR_API_KEY
|
||||||
|
|
||||||
# Delete API key from keyring
|
# Delete API key from keyring
|
||||||
|
|||||||
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
|
||||||
quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2
|
quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2
|
||||||
|
|
||||||
|
> ⚠️ **安全提示**:把 API 密钥作为命令行参数传入会留在 shell history 与进程列表中。
|
||||||
|
> 建议直接运行 `quicommit config set-api-key`(或 `config set-llm`)不携带密钥,
|
||||||
|
> 以隐藏方式输入。
|
||||||
|
|
||||||
# 配置OpenAI
|
# 配置OpenAI
|
||||||
quicommit config set-llm openai
|
quicommit config set-llm openai
|
||||||
quicommit config set-api-key YOUR_API_KEY
|
quicommit config set-api-key YOUR_API_KEY
|
||||||
@@ -323,7 +327,7 @@ quicommit config reset --force
|
|||||||
| `--commitlint` | 使用commitlint格式 |
|
| `--commitlint` | 使用commitlint格式 |
|
||||||
| `--no-verify` | 不验证提交信息 |
|
| `--no-verify` | 不验证提交信息 |
|
||||||
| `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) |
|
| `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) |
|
||||||
| `-y, --yes` | 跳过确认提示 |
|
| `-y, --yes` | 仅跳过确认提示(生成行为不变) |
|
||||||
| `--push` | 提交后推送到远程 |
|
| `--push` | 提交后推送到远程 |
|
||||||
| `--remote` | 指定远程仓库(默认:origin) |
|
| `--remote` | 指定远程仓库(默认:origin) |
|
||||||
|
|
||||||
@@ -343,7 +347,7 @@ quicommit config reset --force
|
|||||||
| `-r, --remote` | 指定远程仓库(默认:origin) |
|
| `-r, --remote` | 指定远程仓库(默认:origin) |
|
||||||
| `--dry-run` | 试运行 |
|
| `--dry-run` | 试运行 |
|
||||||
| `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) |
|
| `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) |
|
||||||
| `-y, --yes` | 跳过确认提示 |
|
| `-y, --yes` | 仅跳过确认提示(生成行为不变) |
|
||||||
|
|
||||||
### changelog命令选项
|
### changelog命令选项
|
||||||
|
|
||||||
@@ -360,7 +364,8 @@ quicommit config reset --force
|
|||||||
| `--format` | 格式(keep-a-changelog、github-releases) |
|
| `--format` | 格式(keep-a-changelog、github-releases) |
|
||||||
| `--dry-run` | 试运行(输出到stdout) |
|
| `--dry-run` | 试运行(输出到stdout) |
|
||||||
| `--think` | 启用 LLM 思考/推理模式(覆盖配置) |
|
| `--think` | 启用 LLM 思考/推理模式(覆盖配置) |
|
||||||
| `-y, --yes` | 跳过确认提示 |
|
| `--no-generate` | 使用模板确定性生成(不调用 AI) |
|
||||||
|
| `-y, --yes` | 仅跳过确认提示(生成行为不变) |
|
||||||
|
|
||||||
## 配置文件
|
## 配置文件
|
||||||
|
|
||||||
@@ -419,6 +424,9 @@ auto_generate = true
|
|||||||
path = "CHANGELOG.md"
|
path = "CHANGELOG.md"
|
||||||
auto_generate = true
|
auto_generate = true
|
||||||
|
|
||||||
|
[output]
|
||||||
|
emoji = true
|
||||||
|
|
||||||
[repo_profiles]
|
[repo_profiles]
|
||||||
"/path/to/work/project" = "work"
|
"/path/to/work/project" = "work"
|
||||||
"/path/to/personal/project" = "personal"
|
"/path/to/personal/project" = "personal"
|
||||||
@@ -430,7 +438,15 @@ auto_generate = true
|
|||||||
|--------|------|
|
|--------|------|
|
||||||
| `QUICOMMIT_CONFIG` | 配置文件路径 |
|
| `QUICOMMIT_CONFIG` | 配置文件路径 |
|
||||||
| `EDITOR` | 默认编辑器 |
|
| `EDITOR` | 默认编辑器 |
|
||||||
| `NO_COLOR` | 禁用彩色输出 |
|
| `NO_COLOR` | 禁用彩色与 Emoji 装饰输出(任意值即生效) |
|
||||||
|
| `RUST_LOG` | 覆盖 `-v` 的日志过滤(例如 `debug`) |
|
||||||
|
|
||||||
|
## 全局选项
|
||||||
|
|
||||||
|
- `-v, --verbose`(可叠加):`-v` 显示 info 日志、`-vv` debug、`-vvv` trace。
|
||||||
|
- `--no-color`:禁用彩色**与** Emoji 装饰(最高优先级)。
|
||||||
|
- `--emoji` / `--no-emoji`:强制开启/关闭 Emoji 装饰,覆盖 `output.emoji` 配置。
|
||||||
|
- Emoji 装饰也可通过 `quicommit config set output.emoji true|false` 配置。
|
||||||
|
|
||||||
## 故障排除
|
## 故障排除
|
||||||
|
|
||||||
@@ -447,7 +463,7 @@ quicommit config set llm.provider ollama
|
|||||||
# 获取配置值
|
# 获取配置值
|
||||||
quicommit config get llm.provider
|
quicommit config get llm.provider
|
||||||
|
|
||||||
# 设置API密钥(存储在系统密钥环中)
|
# 设置API密钥(存储在系统密钥环中;省略密钥值可通过隐藏提示输入)
|
||||||
quicommit config set-api-key YOUR_API_KEY
|
quicommit config set-api-key YOUR_API_KEY
|
||||||
|
|
||||||
# 从密钥环删除API密钥
|
# 从密钥环删除API密钥
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use crate::git::GitRepo;
|
|||||||
use crate::git::find_repo;
|
use crate::git::find_repo;
|
||||||
use crate::git::{CommitInfo, changelog::*};
|
use crate::git::{CommitInfo, changelog::*};
|
||||||
use crate::i18n::{Messages, translate_changelog_category};
|
use crate::i18n::{Messages, translate_changelog_category};
|
||||||
|
use crate::utils::{print_progress, print_success, print_warning};
|
||||||
|
|
||||||
/// Generate changelog
|
/// Generate changelog
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@@ -60,7 +61,11 @@ pub struct ChangelogCommand {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
think: bool,
|
think: bool,
|
||||||
|
|
||||||
/// Skip interactive prompts
|
/// Generate deterministically from template (no AI call)
|
||||||
|
#[arg(long, conflicts_with = "generate")]
|
||||||
|
no_generate: bool,
|
||||||
|
|
||||||
|
/// Skip interactive prompts only (generation behavior unchanged)
|
||||||
#[arg(short = 'y', long)]
|
#[arg(short = 'y', long)]
|
||||||
yes: bool,
|
yes: bool,
|
||||||
}
|
}
|
||||||
@@ -85,7 +90,7 @@ impl ChangelogCommand {
|
|||||||
.unwrap_or_else(|| PathBuf::from(&config.changelog.path));
|
.unwrap_or_else(|| PathBuf::from(&config.changelog.path));
|
||||||
|
|
||||||
init_changelog(&path)?;
|
init_changelog(&path)?;
|
||||||
println!("{}", messages.initialized_changelog(&format!("{:?}", path)));
|
print_success(&messages.initialized_changelog(&path.display().to_string()));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,10 +106,7 @@ impl ChangelogCommand {
|
|||||||
Some("keep") | Some("keep-a-changelog") => ChangelogFormat::KeepAChangelog,
|
Some("keep") | Some("keep-a-changelog") => ChangelogFormat::KeepAChangelog,
|
||||||
Some("custom") => ChangelogFormat::Custom,
|
Some("custom") => ChangelogFormat::Custom,
|
||||||
None => ChangelogFormat::KeepAChangelog,
|
None => ChangelogFormat::KeepAChangelog,
|
||||||
Some(f) => bail!(
|
Some(f) => bail!("{}", messages.unknown_changelog_format(f)),
|
||||||
"Unknown format: {}. Use: keep-a-changelog, github-releases",
|
|
||||||
f
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get version
|
// Get version
|
||||||
@@ -120,7 +122,7 @@ impl ChangelogCommand {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get commits
|
// Get commits
|
||||||
println!("{}", messages.fetching_commits());
|
print_progress(messages.fetching_commits());
|
||||||
|
|
||||||
// Determine from_tag: use explicit --from, or auto-detect from changelog
|
// Determine from_tag: use explicit --from, or auto-detect from changelog
|
||||||
let from_tag = self.resolve_from_tag(&repo, &output_path, &messages);
|
let from_tag = self.resolve_from_tag(&repo, &output_path, &messages);
|
||||||
@@ -130,10 +132,10 @@ impl ChangelogCommand {
|
|||||||
bail!("{}", messages.no_commits_found());
|
bail!("{}", messages.no_commits_found());
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("{}", messages.found_commits(commits.len()));
|
print_success(&messages.found_commits(commits.len()));
|
||||||
|
|
||||||
// Generate changelog
|
// Generate changelog
|
||||||
let changelog = if self.generate || (config.changelog.auto_generate && !self.yes) {
|
let changelog = if self.generate || (config.changelog.auto_generate && !self.no_generate) {
|
||||||
self.generate_with_ai(&version, &commits, &messages).await?
|
self.generate_with_ai(&version, &commits, &messages).await?
|
||||||
} else {
|
} else {
|
||||||
self.generate_with_template(format, &version, &commits, language)?
|
self.generate_with_template(format, &version, &commits, language)?
|
||||||
@@ -161,12 +163,12 @@ impl ChangelogCommand {
|
|||||||
println!("{}", "─".repeat(60));
|
println!("{}", "─".repeat(60));
|
||||||
|
|
||||||
let confirm = Confirm::new()
|
let confirm = Confirm::new()
|
||||||
.with_prompt(messages.write_to_file(&format!("{:?}", output_path)))
|
.with_prompt(messages.write_to_file(&output_path.display().to_string()))
|
||||||
.default(true)
|
.default(true)
|
||||||
.interact()?;
|
.interact()?;
|
||||||
|
|
||||||
if !confirm {
|
if !confirm {
|
||||||
println!("{}", messages.cancelled().yellow());
|
print_warning(messages.cancelled());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,7 +187,11 @@ impl ChangelogCommand {
|
|||||||
std::fs::write(&output_path, content)?;
|
std::fs::write(&output_path, content)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("{} {:?}", messages.changelog_written(), output_path);
|
print_success(&format!(
|
||||||
|
"{} {}",
|
||||||
|
messages.changelog_written(),
|
||||||
|
output_path.display()
|
||||||
|
));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -221,12 +227,13 @@ impl ChangelogCommand {
|
|||||||
let manager = ConfigManager::new()?;
|
let manager = ConfigManager::new()?;
|
||||||
let language = manager.get_language().unwrap_or(Language::English);
|
let language = manager.get_language().unwrap_or(Language::English);
|
||||||
|
|
||||||
println!("{}", messages.ai_generating_changelog());
|
|
||||||
|
|
||||||
let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?;
|
let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?;
|
||||||
generator
|
let spinner = crate::utils::Spinner::start(messages.ai_generating_changelog());
|
||||||
|
let result = generator
|
||||||
.generate_changelog_entry(version, commits, language)
|
.generate_changelog_entry(version, commits, language)
|
||||||
.await
|
.await;
|
||||||
|
spinner.finish_clear();
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_with_template(
|
fn generate_with_template(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use crate::git::commit::{CommitBuilder, create_date_commit_message};
|
|||||||
use crate::git::{GitRepo, find_repo};
|
use crate::git::{GitRepo, find_repo};
|
||||||
use crate::i18n::Messages;
|
use crate::i18n::Messages;
|
||||||
use crate::utils::validators::get_commit_types;
|
use crate::utils::validators::get_commit_types;
|
||||||
|
use crate::utils::{print_progress, print_success, print_warning};
|
||||||
|
|
||||||
/// Generate and execute conventional commits
|
/// Generate and execute conventional commits
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@@ -75,7 +76,7 @@ pub struct CommitCommand {
|
|||||||
#[arg(short = 't', long)]
|
#[arg(short = 't', long)]
|
||||||
think: bool,
|
think: bool,
|
||||||
|
|
||||||
/// Skip interactive prompts
|
/// Skip interactive prompts only (generation behavior unchanged)
|
||||||
#[arg(short = 'y', long)]
|
#[arg(short = 'y', long)]
|
||||||
yes: bool,
|
yes: bool,
|
||||||
|
|
||||||
@@ -121,13 +122,13 @@ impl CommitCommand {
|
|||||||
// Auto-add if no files are staged and there are unstaged/untracked changes
|
// Auto-add if no files are staged and there are unstaged/untracked changes
|
||||||
if status.staged == 0 && (status.unstaged > 0 || status.untracked > 0) && !self.all {
|
if status.staged == 0 && (status.unstaged > 0 || status.untracked > 0) && !self.all {
|
||||||
println!("{}", messages.auto_stage_changes().yellow());
|
println!("{}", messages.auto_stage_changes().yellow());
|
||||||
let removed = repo.stage_all()?;
|
let removed = repo.stage_all(&messages)?;
|
||||||
println!("{}", messages.staged_all().green());
|
println!("{}", messages.staged_all().green());
|
||||||
if !removed.is_empty() {
|
if !removed.is_empty() {
|
||||||
println!(
|
print_warning(&format!(
|
||||||
"{}",
|
"Removed {} ignored files from staging:",
|
||||||
format!("Removed {} ignored files from staging:", removed.len()).yellow()
|
removed.len()
|
||||||
);
|
));
|
||||||
for file in &removed {
|
for file in &removed {
|
||||||
println!(" • {}", file);
|
println!(" • {}", file);
|
||||||
}
|
}
|
||||||
@@ -136,19 +137,19 @@ impl CommitCommand {
|
|||||||
// Re-check status after staging to ensure changes are detected
|
// Re-check status after staging to ensure changes are detected
|
||||||
let new_status = repo.status_summary()?;
|
let new_status = repo.status_summary()?;
|
||||||
if new_status.staged == 0 {
|
if new_status.staged == 0 {
|
||||||
bail!("Failed to stage changes. Please try running 'git add -A' manually.");
|
bail!("{}", messages.failed_to_stage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage all if requested
|
// Stage all if requested
|
||||||
if self.all {
|
if self.all {
|
||||||
let removed = repo.stage_all()?;
|
let removed = repo.stage_all(&messages)?;
|
||||||
println!("{}", messages.staged_all().green());
|
println!("{}", messages.staged_all().green());
|
||||||
if !removed.is_empty() {
|
if !removed.is_empty() {
|
||||||
println!(
|
print_warning(&format!(
|
||||||
"{}",
|
"Removed {} ignored files from staging:",
|
||||||
format!("Removed {} ignored files from staging:", removed.len()).yellow()
|
removed.len()
|
||||||
);
|
));
|
||||||
for file in &removed {
|
for file in &removed {
|
||||||
println!(" • {}", file);
|
println!(" • {}", file);
|
||||||
}
|
}
|
||||||
@@ -194,29 +195,21 @@ impl CommitCommand {
|
|||||||
.interact()?;
|
.interact()?;
|
||||||
|
|
||||||
if !confirm {
|
if !confirm {
|
||||||
println!("{}", messages.commit_cancelled().yellow());
|
print_warning(messages.commit_cancelled());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = if self.amend {
|
let result = if self.amend {
|
||||||
if self.dry_run {
|
if self.dry_run {
|
||||||
println!(
|
println!("\n{}", messages.dry_run_commit_not_amended().yellow());
|
||||||
"\n{} {}",
|
|
||||||
messages.dry_run(),
|
|
||||||
"- commit not amended.".yellow()
|
|
||||||
);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
self.amend_commit(&repo, &commit_message)?;
|
self.amend_commit(&repo, &commit_message)?;
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
if self.dry_run {
|
if self.dry_run {
|
||||||
println!(
|
println!("\n{}", messages.dry_run_commit_not_created().yellow());
|
||||||
"\n{} {}",
|
|
||||||
messages.dry_run(),
|
|
||||||
"- commit not created.".yellow()
|
|
||||||
);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
CommitBuilder::new()
|
CommitBuilder::new()
|
||||||
@@ -226,30 +219,34 @@ impl CommitCommand {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Some(commit_oid) = result {
|
if let Some(commit_oid) = result {
|
||||||
println!(
|
print_success(&format!(
|
||||||
"{} {}",
|
"{} {}",
|
||||||
messages.commit_created().green().bold(),
|
messages.commit_created().green().bold(),
|
||||||
commit_oid.to_string()[..8].to_string().cyan()
|
commit_oid.to_string()[..8].to_string().cyan()
|
||||||
);
|
));
|
||||||
} else {
|
} else {
|
||||||
println!("{} successfully", messages.commit_amended().green().bold());
|
print_success(messages.commit_amended_successfully());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push after commit if requested or ask user
|
// Push after commit if requested or ask user
|
||||||
if self.push {
|
if self.push || (!self.yes && !self.dry_run) {
|
||||||
println!("\n{}", messages.pushing_commit(&self.remote));
|
let branch = repo
|
||||||
repo.push(&self.remote, "HEAD")?;
|
.current_branch()
|
||||||
println!("{}", messages.pushed_commit(&self.remote));
|
.unwrap_or_else(|_| "HEAD (detached)".to_string());
|
||||||
} else if !self.yes && !self.dry_run {
|
|
||||||
let should_push = Confirm::new()
|
let should_push = if self.push {
|
||||||
.with_prompt(messages.push_after_commit())
|
true
|
||||||
.default(false)
|
} else {
|
||||||
.interact()?;
|
Confirm::new()
|
||||||
|
.with_prompt(messages.push_after_commit(&branch))
|
||||||
|
.default(false)
|
||||||
|
.interact()?
|
||||||
|
};
|
||||||
|
|
||||||
if should_push {
|
if should_push {
|
||||||
println!("\n{}", messages.pushing_commit(&self.remote));
|
print_progress(&messages.pushing_commit(&self.remote, &branch));
|
||||||
repo.push(&self.remote, "HEAD")?;
|
repo.push(&self.remote, "HEAD")?;
|
||||||
println!("{}", messages.pushed_commit(&self.remote));
|
print_success(&messages.pushed_commit(&self.remote, &branch));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,21 +303,22 @@ impl CommitCommand {
|
|||||||
.await
|
.await
|
||||||
.context("Failed to initialize LLM. Use --manual for manual commit.")?;
|
.context("Failed to initialize LLM. Use --manual for manual commit.")?;
|
||||||
|
|
||||||
println!("{}", messages.ai_analyzing());
|
let spinner = crate::utils::Spinner::start(messages.ai_analyzing());
|
||||||
|
|
||||||
let language = manager.get_language().unwrap_or(Language::English);
|
let language = manager.get_language().unwrap_or(Language::English);
|
||||||
|
|
||||||
let generated = if self.yes {
|
let generated = if self.yes {
|
||||||
generator
|
generator
|
||||||
.generate_commit_from_repo(repo, format, language)
|
.generate_commit_from_repo(repo, format, language)
|
||||||
.await?
|
.await
|
||||||
} else {
|
} else {
|
||||||
generator
|
generator
|
||||||
.generate_commit_interactive(repo, format, language)
|
.generate_commit_interactive(repo, format, language, messages)
|
||||||
.await?
|
.await
|
||||||
};
|
};
|
||||||
|
spinner.finish_clear();
|
||||||
|
|
||||||
Ok(generated.to_conventional())
|
Ok(generated?.to_conventional())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_interactive_commit(
|
async fn create_interactive_commit(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,458 +1,457 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use std::io::{self, BufRead, Write};
|
use std::io::{self, BufRead, Write};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::config::manager::ConfigManager;
|
use crate::config::manager::ConfigManager;
|
||||||
use crate::config::{TokenConfig, TokenType};
|
use crate::config::{TokenConfig, TokenType};
|
||||||
|
|
||||||
/// Git credential helper command.
|
/// Git credential helper command.
|
||||||
///
|
///
|
||||||
/// Implements the git credential helper protocol
|
/// Implements the git credential helper protocol
|
||||||
/// (https://git-scm.com/docs/gitcredentials). Intended to be invoked by git
|
/// (https://git-scm.com/docs/gitcredentials). Intended to be invoked by git
|
||||||
/// via `credential.helper` configuration, not by end users. Hidden from the
|
/// via `credential.helper` configuration, not by end users. Hidden from the
|
||||||
/// main help output.
|
/// main help output.
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
pub struct CredentialCommand {
|
pub struct CredentialCommand {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: CredentialSubcommand,
|
command: CredentialSubcommand,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum CredentialSubcommand {
|
enum CredentialSubcommand {
|
||||||
/// Read attributes on stdin and output credentials on stdout.
|
/// Read attributes on stdin and output credentials on stdout.
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
Get,
|
Get,
|
||||||
/// Read attributes (including password) on stdin and store them.
|
/// Read attributes (including password) on stdin and store them.
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
Store,
|
Store,
|
||||||
/// Read attributes on stdin and erase any matching stored credentials.
|
/// Read attributes on stdin and erase any matching stored credentials.
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
Erase,
|
Erase,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CredentialCommand {
|
impl CredentialCommand {
|
||||||
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
|
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
|
||||||
match &self.command {
|
match &self.command {
|
||||||
CredentialSubcommand::Get => Self::get(config_path),
|
CredentialSubcommand::Get => Self::get(config_path),
|
||||||
CredentialSubcommand::Store => Self::store(config_path),
|
CredentialSubcommand::Store => Self::store(config_path),
|
||||||
CredentialSubcommand::Erase => Self::erase(config_path),
|
CredentialSubcommand::Erase => Self::erase(config_path),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_manager(config_path: &Option<PathBuf>) -> Result<ConfigManager> {
|
fn get_manager(config_path: &Option<PathBuf>) -> Result<ConfigManager> {
|
||||||
match config_path {
|
match config_path {
|
||||||
Some(path) => ConfigManager::with_path(path),
|
Some(path) => ConfigManager::with_path(path),
|
||||||
None => ConfigManager::new(),
|
None => ConfigManager::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `git credential get`: look up a PAT for the requested host and emit it
|
/// `git credential get`: look up a PAT for the requested host and emit it
|
||||||
/// on stdout following the git credential helper protocol.
|
/// on stdout following the git credential helper protocol.
|
||||||
fn get(config_path: Option<PathBuf>) -> Result<()> {
|
fn get(config_path: Option<PathBuf>) -> Result<()> {
|
||||||
let attrs = CredentialAttributes::from_stdin()?;
|
let attrs = CredentialAttributes::from_stdin()?;
|
||||||
|
|
||||||
let host = match attrs.host.as_deref() {
|
let host = match attrs.host.as_deref() {
|
||||||
Some(h) if !h.is_empty() => h,
|
Some(h) if !h.is_empty() => h,
|
||||||
_ => return Ok(()),
|
_ => return Ok(()),
|
||||||
};
|
};
|
||||||
let service = host_to_service(host);
|
let service = host_to_service(host);
|
||||||
|
|
||||||
let manager = match Self::get_manager(&config_path) {
|
let manager = match Self::get_manager(&config_path) {
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(_) => return Ok(()),
|
Err(_) => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (profile_name, pat) = match find_pat_for_service(&manager, &service) {
|
let (profile_name, pat) = match find_pat_for_service(&manager, &service) {
|
||||||
Some(tuple) => tuple,
|
Some(tuple) => tuple,
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prefer a git-supplied username; fall back to the profile's user_name.
|
// Prefer a git-supplied username; fall back to the profile's user_name.
|
||||||
let username = attrs.username.clone().or_else(|| {
|
let username = attrs.username.clone().or_else(|| {
|
||||||
manager
|
manager
|
||||||
.get_profile(&profile_name)
|
.get_profile(&profile_name)
|
||||||
.map(|p| p.user_name.clone())
|
.map(|p| p.user_name.clone())
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = CredentialAttributes {
|
let output = CredentialAttributes {
|
||||||
protocol: attrs.protocol.clone(),
|
protocol: attrs.protocol.clone(),
|
||||||
host: attrs.host.clone(),
|
host: attrs.host.clone(),
|
||||||
path: attrs.path.clone(),
|
path: attrs.path.clone(),
|
||||||
username,
|
username,
|
||||||
password: Some(pat),
|
password: Some(pat),
|
||||||
};
|
};
|
||||||
output.to_stdout()?;
|
output.to_stdout()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `git credential store`: persist the PAT provided by git using the
|
/// `git credential store`: persist the PAT provided by git using the
|
||||||
/// existing keyring-backed storage, associated with a matching profile.
|
/// existing keyring-backed storage, associated with a matching profile.
|
||||||
fn store(config_path: Option<PathBuf>) -> Result<()> {
|
fn store(config_path: Option<PathBuf>) -> Result<()> {
|
||||||
let attrs = CredentialAttributes::from_stdin()?;
|
let attrs = CredentialAttributes::from_stdin()?;
|
||||||
|
|
||||||
let host = match attrs.host.as_deref() {
|
let host = match attrs.host.as_deref() {
|
||||||
Some(h) if !h.is_empty() => h,
|
Some(h) if !h.is_empty() => h,
|
||||||
_ => return Ok(()),
|
_ => return Ok(()),
|
||||||
};
|
};
|
||||||
let service = host_to_service(host);
|
let service = host_to_service(host);
|
||||||
|
|
||||||
let password = match attrs.password.as_deref() {
|
let password = match attrs.password.as_deref() {
|
||||||
Some(p) if !p.is_empty() => p,
|
Some(p) if !p.is_empty() => p,
|
||||||
_ => return Ok(()),
|
_ => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut manager = Self::get_manager(&config_path)?;
|
let mut manager = Self::get_manager(&config_path)?;
|
||||||
|
|
||||||
let profile_name = match find_profile_for_store(&manager, attrs.username.as_deref()) {
|
let profile_name = match find_profile_for_store(&manager, attrs.username.as_deref()) {
|
||||||
Some(name) => name,
|
Some(name) => name,
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store PAT in keyring using the existing profile-bound logic.
|
// Store PAT in keyring using the existing profile-bound logic.
|
||||||
if let Err(e) = manager.store_pat_for_profile(&profile_name, &service, password) {
|
if let Err(e) = manager.store_pat_for_profile(&profile_name, &service, password) {
|
||||||
eprintln!("[quicommit credential] failed to store PAT: {}", e);
|
eprintln!("[quicommit credential] failed to store PAT: {}", e);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the token in the profile config if not already present.
|
// Register the token in the profile config if not already present.
|
||||||
let already_has = manager
|
let already_has = manager
|
||||||
.get_profile(&profile_name)
|
.get_profile(&profile_name)
|
||||||
.map(|p| p.tokens.contains_key(&service))
|
.map(|p| p.tokens.contains_key(&service))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !already_has {
|
if !already_has {
|
||||||
let _ = manager.add_token_to_profile(
|
let _ = manager.add_token_to_profile(
|
||||||
&profile_name,
|
&profile_name,
|
||||||
service.clone(),
|
service.clone(),
|
||||||
TokenConfig::new(TokenType::Personal),
|
TokenConfig::new(TokenType::Personal),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
manager.save()?;
|
manager.save()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `git credential erase`: remove any PAT stored for the requested host
|
/// `git credential erase`: remove any PAT stored for the requested host
|
||||||
/// from the keyring and the associated profile config entries.
|
/// from the keyring and the associated profile config entries.
|
||||||
fn erase(config_path: Option<PathBuf>) -> Result<()> {
|
fn erase(config_path: Option<PathBuf>) -> Result<()> {
|
||||||
let attrs = CredentialAttributes::from_stdin()?;
|
let attrs = CredentialAttributes::from_stdin()?;
|
||||||
|
|
||||||
let host = match attrs.host.as_deref() {
|
let host = match attrs.host.as_deref() {
|
||||||
Some(h) if !h.is_empty() => h,
|
Some(h) if !h.is_empty() => h,
|
||||||
_ => return Ok(()),
|
_ => return Ok(()),
|
||||||
};
|
};
|
||||||
let service = host_to_service(host);
|
let service = host_to_service(host);
|
||||||
|
|
||||||
let mut manager = Self::get_manager(&config_path)?;
|
let mut manager = Self::get_manager(&config_path)?;
|
||||||
|
|
||||||
let profile_names: Vec<String> = manager
|
let profile_names: Vec<String> = manager.list_profiles().into_iter().cloned().collect();
|
||||||
.list_profiles()
|
|
||||||
.into_iter()
|
for name in &profile_names {
|
||||||
.filter(|name| {
|
let registered = manager
|
||||||
manager
|
.get_profile(name)
|
||||||
.get_profile(name)
|
.map(|p| p.tokens.contains_key(&service))
|
||||||
.map(|p| p.tokens.contains_key(&service))
|
.unwrap_or(false);
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
if registered {
|
||||||
.cloned()
|
if let Err(e) = manager.remove_token_from_profile(name, &service) {
|
||||||
.collect();
|
eprintln!(
|
||||||
|
"[quicommit credential] failed to erase PAT for '{}': {}",
|
||||||
for name in &profile_names {
|
name, e
|
||||||
if let Err(e) = manager.remove_token_from_profile(name, &service) {
|
);
|
||||||
eprintln!(
|
}
|
||||||
"[quicommit credential] failed to erase PAT for '{}': {}",
|
} else if let Ok(true) = manager.delete_orphan_pat(name, &service) {
|
||||||
name, e
|
// Keyring PAT without a config token entry (issue 24).
|
||||||
);
|
eprintln!(
|
||||||
}
|
"[quicommit credential] erased unregistered PAT for '{}' (service {}) from keyring",
|
||||||
}
|
name, service
|
||||||
|
);
|
||||||
manager.save()?;
|
}
|
||||||
Ok(())
|
}
|
||||||
}
|
|
||||||
}
|
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 {
|
/// Credential attributes exchanged with git via stdin/stdout following the
|
||||||
pub protocol: Option<String>,
|
/// git credential helper protocol (`key=value`, one per line, blank line ends).
|
||||||
pub host: Option<String>,
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
pub path: Option<String>,
|
pub struct CredentialAttributes {
|
||||||
pub username: Option<String>,
|
pub protocol: Option<String>,
|
||||||
pub password: Option<String>,
|
pub host: Option<String>,
|
||||||
}
|
pub path: Option<String>,
|
||||||
|
pub username: Option<String>,
|
||||||
impl CredentialAttributes {
|
pub password: Option<String>,
|
||||||
/// Read attributes from stdin following the git credential helper protocol.
|
}
|
||||||
pub fn from_stdin() -> Result<Self> {
|
|
||||||
let stdin = io::stdin();
|
impl CredentialAttributes {
|
||||||
let mut attrs = Self::default();
|
/// Read attributes from stdin following the git credential helper protocol.
|
||||||
for line in stdin.lock().lines() {
|
pub fn from_stdin() -> Result<Self> {
|
||||||
let line =
|
let stdin = io::stdin();
|
||||||
line.context("Failed to read credential attributes from stdin")?;
|
let mut attrs = Self::default();
|
||||||
if line.is_empty() {
|
for line in stdin.lock().lines() {
|
||||||
break;
|
let line = line.context("Failed to read credential attributes from stdin")?;
|
||||||
}
|
if line.is_empty() {
|
||||||
if let Some((key, value)) = line.split_once('=') {
|
break;
|
||||||
match key {
|
}
|
||||||
"protocol" => attrs.protocol = Some(value.to_string()),
|
if let Some((key, value)) = line.split_once('=') {
|
||||||
"host" => attrs.host = Some(value.to_string()),
|
match key {
|
||||||
"path" => attrs.path = Some(value.to_string()),
|
"protocol" => attrs.protocol = Some(value.to_string()),
|
||||||
"username" => attrs.username = Some(value.to_string()),
|
"host" => attrs.host = Some(value.to_string()),
|
||||||
"password" => attrs.password = Some(value.to_string()),
|
"path" => attrs.path = Some(value.to_string()),
|
||||||
_ => {} // ignore unknown keys
|
"username" => attrs.username = Some(value.to_string()),
|
||||||
}
|
"password" => attrs.password = Some(value.to_string()),
|
||||||
}
|
_ => {} // ignore unknown keys
|
||||||
}
|
}
|
||||||
Ok(attrs)
|
}
|
||||||
}
|
}
|
||||||
|
Ok(attrs)
|
||||||
/// Write attributes to stdout following the git credential helper protocol.
|
}
|
||||||
pub fn to_stdout(&self) -> Result<()> {
|
|
||||||
let stdout = io::stdout();
|
/// Write attributes to stdout following the git credential helper protocol.
|
||||||
let mut handle = stdout.lock();
|
pub fn to_stdout(&self) -> Result<()> {
|
||||||
if let Some(ref v) = self.protocol {
|
let stdout = io::stdout();
|
||||||
writeln!(handle, "protocol={}", v)?;
|
let mut handle = stdout.lock();
|
||||||
}
|
if let Some(ref v) = self.protocol {
|
||||||
if let Some(ref v) = self.host {
|
writeln!(handle, "protocol={}", v)?;
|
||||||
writeln!(handle, "host={}", v)?;
|
}
|
||||||
}
|
if let Some(ref v) = self.host {
|
||||||
if let Some(ref v) = self.path {
|
writeln!(handle, "host={}", v)?;
|
||||||
writeln!(handle, "path={}", v)?;
|
}
|
||||||
}
|
if let Some(ref v) = self.path {
|
||||||
if let Some(ref v) = self.username {
|
writeln!(handle, "path={}", v)?;
|
||||||
writeln!(handle, "username={}", v)?;
|
}
|
||||||
}
|
if let Some(ref v) = self.username {
|
||||||
if let Some(ref v) = self.password {
|
writeln!(handle, "username={}", v)?;
|
||||||
writeln!(handle, "password={}", v)?;
|
}
|
||||||
}
|
if let Some(ref v) = self.password {
|
||||||
writeln!(handle)?; // blank line terminates the attribute list
|
writeln!(handle, "password={}", v)?;
|
||||||
handle.flush()?;
|
}
|
||||||
Ok(())
|
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 {
|
/// Parse attributes from a text block following the git credential helper
|
||||||
let mut attrs = Self::default();
|
/// protocol. Intended for testing and non-stdin input handling.
|
||||||
for line in input.lines() {
|
pub fn parse_str(input: &str) -> Self {
|
||||||
if line.is_empty() {
|
let mut attrs = Self::default();
|
||||||
break;
|
for line in input.lines() {
|
||||||
}
|
if line.is_empty() {
|
||||||
if let Some((key, value)) = line.split_once('=') {
|
break;
|
||||||
match key {
|
}
|
||||||
"protocol" => attrs.protocol = Some(value.to_string()),
|
if let Some((key, value)) = line.split_once('=') {
|
||||||
"host" => attrs.host = Some(value.to_string()),
|
match key {
|
||||||
"path" => attrs.path = Some(value.to_string()),
|
"protocol" => attrs.protocol = Some(value.to_string()),
|
||||||
"username" => attrs.username = Some(value.to_string()),
|
"host" => attrs.host = Some(value.to_string()),
|
||||||
"password" => attrs.password = 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
|
}
|
||||||
}
|
}
|
||||||
|
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 {
|
/// Serialize attributes to a string following the git credential helper
|
||||||
let mut out = String::new();
|
/// protocol. Intended for testing and non-stdout output handling.
|
||||||
if let Some(ref v) = self.protocol {
|
pub fn serialize(&self) -> String {
|
||||||
out.push_str(&format!("protocol={}\n", v));
|
let mut out = String::new();
|
||||||
}
|
if let Some(ref v) = self.protocol {
|
||||||
if let Some(ref v) = self.host {
|
out.push_str(&format!("protocol={}\n", v));
|
||||||
out.push_str(&format!("host={}\n", v));
|
}
|
||||||
}
|
if let Some(ref v) = self.host {
|
||||||
if let Some(ref v) = self.path {
|
out.push_str(&format!("host={}\n", v));
|
||||||
out.push_str(&format!("path={}\n", v));
|
}
|
||||||
}
|
if let Some(ref v) = self.path {
|
||||||
if let Some(ref v) = self.username {
|
out.push_str(&format!("path={}\n", v));
|
||||||
out.push_str(&format!("username={}\n", v));
|
}
|
||||||
}
|
if let Some(ref v) = self.username {
|
||||||
if let Some(ref v) = self.password {
|
out.push_str(&format!("username={}\n", v));
|
||||||
out.push_str(&format!("password={}\n", v));
|
}
|
||||||
}
|
if let Some(ref v) = self.password {
|
||||||
out.push('\n');
|
out.push_str(&format!("password={}\n", v));
|
||||||
out
|
}
|
||||||
}
|
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
|
/// Map a git host to a service name used by the keyring-backed PAT storage.
|
||||||
/// hosts are used as-is (lowercased, trailing slash trimmed).
|
///
|
||||||
pub fn host_to_service(host: &str) -> String {
|
/// Common git hosting services are mapped to short canonical names. Unknown
|
||||||
let host = host.to_lowercase();
|
/// hosts are used as-is (lowercased, trailing slash trimmed).
|
||||||
let host = host.trim_end_matches('/');
|
pub fn host_to_service(host: &str) -> String {
|
||||||
match host {
|
let host = host.to_lowercase();
|
||||||
"github.com" | "www.github.com" => "github".to_string(),
|
let host = host.trim_end_matches('/');
|
||||||
"gitlab.com" | "www.gitlab.com" => "gitlab".to_string(),
|
match host {
|
||||||
"bitbucket.org" | "www.bitbucket.org" => "bitbucket".to_string(),
|
"github.com" | "www.github.com" => "github".to_string(),
|
||||||
"codeberg.org" | "www.codeberg.org" => "codeberg".to_string(),
|
"gitlab.com" | "www.gitlab.com" => "gitlab".to_string(),
|
||||||
"gitea.com" | "www.gitea.com" => "gitea".to_string(),
|
"bitbucket.org" | "www.bitbucket.org" => "bitbucket".to_string(),
|
||||||
"gitee.com" | "www.gitee.com" => "gitee".to_string(),
|
"codeberg.org" | "www.codeberg.org" => "codeberg".to_string(),
|
||||||
other => other.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)> {
|
/// Search all profiles for one that has a PAT stored for the given service.
|
||||||
for profile_name in manager.list_profiles() {
|
/// Returns the profile name and the PAT value.
|
||||||
if manager.has_pat_for_profile(profile_name, service) {
|
fn find_pat_for_service(manager: &ConfigManager, service: &str) -> Option<(String, String)> {
|
||||||
if let Ok(Some(pat)) = manager.get_pat_for_profile(profile_name, service) {
|
for profile_name in manager.list_profiles() {
|
||||||
return Some((profile_name.clone(), pat));
|
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
|
}
|
||||||
}
|
}
|
||||||
|
None
|
||||||
/// Determine which profile to use when storing a credential.
|
}
|
||||||
///
|
|
||||||
/// Prefers a profile whose `user_name` or `user_email` matches the
|
/// Determine which profile to use when storing a credential.
|
||||||
/// git-supplied username; otherwise falls back to the default profile.
|
///
|
||||||
fn find_profile_for_store(
|
/// Prefers a profile whose `user_name` or `user_email` matches the
|
||||||
manager: &ConfigManager,
|
/// git-supplied username; otherwise falls back to the default profile.
|
||||||
username: Option<&str>,
|
fn find_profile_for_store(manager: &ConfigManager, username: Option<&str>) -> Option<String> {
|
||||||
) -> Option<String> {
|
if let Some(username) = username {
|
||||||
if let Some(username) = username {
|
for name in manager.list_profiles() {
|
||||||
for name in manager.list_profiles() {
|
if let Some(profile) = manager.get_profile(name) {
|
||||||
if let Some(profile) = manager.get_profile(name) {
|
if profile.user_name == username || profile.user_email == username {
|
||||||
if profile.user_name == username || profile.user_email == username {
|
return Some(name.clone());
|
||||||
return Some(name.clone());
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
manager.default_profile_name().cloned()
|
||||||
manager.default_profile_name().cloned()
|
}
|
||||||
}
|
|
||||||
|
/// Extract a PAT for the given host from saved credentials across all profiles.
|
||||||
/// 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
|
||||||
/// 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
|
||||||
/// verifying access to a git hosting service) and searches every configured
|
/// profile for a stored PAT matching the host.
|
||||||
/// profile for a stored PAT matching the host.
|
pub fn get_pat_for_host(host: &str) -> Result<Option<String>> {
|
||||||
pub fn get_pat_for_host(host: &str) -> Result<Option<String>> {
|
let manager = ConfigManager::new()?;
|
||||||
let manager = ConfigManager::new()?;
|
let service = host_to_service(host);
|
||||||
let service = host_to_service(host);
|
Ok(find_pat_for_service(&manager, &service).map(|(_, pat)| pat))
|
||||||
Ok(find_pat_for_service(&manager, &service).map(|(_, pat)| pat))
|
}
|
||||||
}
|
|
||||||
|
#[cfg(test)]
|
||||||
#[cfg(test)]
|
mod tests {
|
||||||
mod tests {
|
use super::*;
|
||||||
use super::*;
|
|
||||||
|
#[test]
|
||||||
#[test]
|
fn test_host_to_service_known_hosts() {
|
||||||
fn test_host_to_service_known_hosts() {
|
assert_eq!(host_to_service("github.com"), "github");
|
||||||
assert_eq!(host_to_service("github.com"), "github");
|
assert_eq!(host_to_service("www.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("gitlab.com"), "gitlab");
|
assert_eq!(host_to_service("bitbucket.org"), "bitbucket");
|
||||||
assert_eq!(host_to_service("bitbucket.org"), "bitbucket");
|
assert_eq!(host_to_service("codeberg.org"), "codeberg");
|
||||||
assert_eq!(host_to_service("codeberg.org"), "codeberg");
|
assert_eq!(host_to_service("gitea.com"), "gitea");
|
||||||
assert_eq!(host_to_service("gitea.com"), "gitea");
|
assert_eq!(host_to_service("gitee.com"), "gitee");
|
||||||
assert_eq!(host_to_service("gitee.com"), "gitee");
|
}
|
||||||
}
|
|
||||||
|
#[test]
|
||||||
#[test]
|
fn test_host_to_service_case_insensitive() {
|
||||||
fn test_host_to_service_case_insensitive() {
|
assert_eq!(host_to_service("GitHub.Com"), "github");
|
||||||
assert_eq!(host_to_service("GitHub.Com"), "github");
|
assert_eq!(host_to_service("GITLAB.COM"), "gitlab");
|
||||||
assert_eq!(host_to_service("GITLAB.COM"), "gitlab");
|
}
|
||||||
}
|
|
||||||
|
#[test]
|
||||||
#[test]
|
fn test_host_to_service_trailing_slash() {
|
||||||
fn test_host_to_service_trailing_slash() {
|
assert_eq!(host_to_service("github.com/"), "github");
|
||||||
assert_eq!(host_to_service("github.com/"), "github");
|
assert_eq!(host_to_service("gitlab.com//"), "gitlab");
|
||||||
assert_eq!(host_to_service("gitlab.com//"), "gitlab");
|
}
|
||||||
}
|
|
||||||
|
#[test]
|
||||||
#[test]
|
fn test_host_to_service_unknown_host() {
|
||||||
fn test_host_to_service_unknown_host() {
|
assert_eq!(host_to_service("example.com"), "example.com");
|
||||||
assert_eq!(host_to_service("example.com"), "example.com");
|
assert_eq!(host_to_service("git.internal.corp"), "git.internal.corp");
|
||||||
assert_eq!(host_to_service("git.internal.corp"), "git.internal.corp");
|
}
|
||||||
}
|
|
||||||
|
#[test]
|
||||||
#[test]
|
fn test_credential_attributes_parse_basic() {
|
||||||
fn test_credential_attributes_parse_basic() {
|
let input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_token123\n\n";
|
||||||
let input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_token123\n\n";
|
let attrs = CredentialAttributes::parse_str(input);
|
||||||
let attrs = CredentialAttributes::parse_str(input);
|
assert_eq!(attrs.protocol.as_deref(), Some("https"));
|
||||||
assert_eq!(attrs.protocol.as_deref(), Some("https"));
|
assert_eq!(attrs.host.as_deref(), Some("github.com"));
|
||||||
assert_eq!(attrs.host.as_deref(), Some("github.com"));
|
assert_eq!(attrs.username.as_deref(), Some("octocat"));
|
||||||
assert_eq!(attrs.username.as_deref(), Some("octocat"));
|
assert_eq!(attrs.password.as_deref(), Some("ghp_token123"));
|
||||||
assert_eq!(attrs.password.as_deref(), Some("ghp_token123"));
|
assert!(attrs.path.is_none());
|
||||||
assert!(attrs.path.is_none());
|
}
|
||||||
}
|
|
||||||
|
#[test]
|
||||||
#[test]
|
fn test_credential_attributes_parse_with_path() {
|
||||||
fn test_credential_attributes_parse_with_path() {
|
let input = "protocol=https\nhost=github.com\npath=owner/repo.git\n\n";
|
||||||
let input = "protocol=https\nhost=github.com\npath=owner/repo.git\n\n";
|
let attrs = CredentialAttributes::parse_str(input);
|
||||||
let attrs = CredentialAttributes::parse_str(input);
|
assert_eq!(attrs.path.as_deref(), Some("owner/repo.git"));
|
||||||
assert_eq!(attrs.path.as_deref(), Some("owner/repo.git"));
|
}
|
||||||
}
|
|
||||||
|
#[test]
|
||||||
#[test]
|
fn test_credential_attributes_parse_empty() {
|
||||||
fn test_credential_attributes_parse_empty() {
|
let attrs = CredentialAttributes::parse_str("");
|
||||||
let attrs = CredentialAttributes::parse_str("");
|
assert!(attrs.protocol.is_none());
|
||||||
assert!(attrs.protocol.is_none());
|
assert!(attrs.host.is_none());
|
||||||
assert!(attrs.host.is_none());
|
assert!(attrs.username.is_none());
|
||||||
assert!(attrs.username.is_none());
|
assert!(attrs.password.is_none());
|
||||||
assert!(attrs.password.is_none());
|
}
|
||||||
}
|
|
||||||
|
#[test]
|
||||||
#[test]
|
fn test_credential_attributes_parse_ignores_unknown_keys() {
|
||||||
fn test_credential_attributes_parse_ignores_unknown_keys() {
|
let input = "protocol=https\nhost=github.com\nunknown=value\nfoo=bar\n\n";
|
||||||
let input = "protocol=https\nhost=github.com\nunknown=value\nfoo=bar\n\n";
|
let attrs = CredentialAttributes::parse_str(input);
|
||||||
let attrs = CredentialAttributes::parse_str(input);
|
assert_eq!(attrs.protocol.as_deref(), Some("https"));
|
||||||
assert_eq!(attrs.protocol.as_deref(), Some("https"));
|
assert_eq!(attrs.host.as_deref(), Some("github.com"));
|
||||||
assert_eq!(attrs.host.as_deref(), Some("github.com"));
|
}
|
||||||
}
|
|
||||||
|
#[test]
|
||||||
#[test]
|
fn test_credential_attributes_parse_stops_at_blank_line() {
|
||||||
fn test_credential_attributes_parse_stops_at_blank_line() {
|
let input = "protocol=https\nhost=github.com\n\npassword=should_be_ignored\n";
|
||||||
let input = "protocol=https\nhost=github.com\n\npassword=should_be_ignored\n";
|
let attrs = CredentialAttributes::parse_str(input);
|
||||||
let attrs = CredentialAttributes::parse_str(input);
|
assert_eq!(attrs.protocol.as_deref(), Some("https"));
|
||||||
assert_eq!(attrs.protocol.as_deref(), Some("https"));
|
assert_eq!(attrs.host.as_deref(), Some("github.com"));
|
||||||
assert_eq!(attrs.host.as_deref(), Some("github.com"));
|
assert!(attrs.password.is_none());
|
||||||
assert!(attrs.password.is_none());
|
}
|
||||||
}
|
|
||||||
|
#[test]
|
||||||
#[test]
|
fn test_credential_attributes_serialize_roundtrip() {
|
||||||
fn test_credential_attributes_serialize_roundtrip() {
|
let attrs = CredentialAttributes {
|
||||||
let attrs = CredentialAttributes {
|
protocol: Some("https".to_string()),
|
||||||
protocol: Some("https".to_string()),
|
host: Some("github.com".to_string()),
|
||||||
host: Some("github.com".to_string()),
|
path: None,
|
||||||
path: None,
|
username: Some("octocat".to_string()),
|
||||||
username: Some("octocat".to_string()),
|
password: Some("ghp_token".to_string()),
|
||||||
password: Some("ghp_token".to_string()),
|
};
|
||||||
};
|
let serialized = attrs.serialize();
|
||||||
let serialized = attrs.serialize();
|
let reparsed = CredentialAttributes::parse_str(&serialized);
|
||||||
let reparsed = CredentialAttributes::parse_str(&serialized);
|
assert_eq!(attrs, reparsed);
|
||||||
assert_eq!(attrs, reparsed);
|
}
|
||||||
}
|
|
||||||
|
#[test]
|
||||||
#[test]
|
fn test_credential_attributes_serialize_includes_blank_line() {
|
||||||
fn test_credential_attributes_serialize_includes_blank_line() {
|
let attrs = CredentialAttributes {
|
||||||
let attrs = CredentialAttributes {
|
protocol: Some("https".to_string()),
|
||||||
protocol: Some("https".to_string()),
|
host: Some("github.com".to_string()),
|
||||||
host: Some("github.com".to_string()),
|
path: None,
|
||||||
path: None,
|
username: None,
|
||||||
username: None,
|
password: None,
|
||||||
password: None,
|
};
|
||||||
};
|
let serialized = attrs.serialize();
|
||||||
let serialized = attrs.serialize();
|
assert!(serialized.ends_with("\n\n"));
|
||||||
assert!(serialized.ends_with("\n\n"));
|
assert!(serialized.contains("protocol=https\n"));
|
||||||
assert!(serialized.contains("protocol=https\n"));
|
assert!(serialized.contains("host=github.com\n"));
|
||||||
assert!(serialized.contains("host=github.com\n"));
|
}
|
||||||
}
|
|
||||||
|
#[test]
|
||||||
#[test]
|
fn test_credential_attributes_default() {
|
||||||
fn test_credential_attributes_default() {
|
let attrs = CredentialAttributes::default();
|
||||||
let attrs = CredentialAttributes::default();
|
assert!(attrs.protocol.is_none());
|
||||||
assert!(attrs.protocol.is_none());
|
assert!(attrs.host.is_none());
|
||||||
assert!(attrs.host.is_none());
|
assert!(attrs.path.is_none());
|
||||||
assert!(attrs.path.is_none());
|
assert!(attrs.username.is_none());
|
||||||
assert!(attrs.username.is_none());
|
assert!(attrs.password.is_none());
|
||||||
assert!(attrs.password.is_none());
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use crate::config::{GitProfile, Language};
|
|||||||
use crate::i18n::Messages;
|
use crate::i18n::Messages;
|
||||||
use crate::utils::keyring::{get_default_model, get_supported_providers, provider_needs_api_key};
|
use crate::utils::keyring::{get_default_model, get_supported_providers, provider_needs_api_key};
|
||||||
use crate::utils::validators::validate_email;
|
use crate::utils::validators::validate_email;
|
||||||
|
use crate::utils::{print_success, print_warning};
|
||||||
|
|
||||||
/// Initialize quicommit configuration
|
/// Initialize quicommit configuration
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@@ -34,19 +35,16 @@ impl InitCommand {
|
|||||||
if config_path.exists() && !self.reset {
|
if config_path.exists() && !self.reset {
|
||||||
if !self.yes {
|
if !self.yes {
|
||||||
let overwrite = Confirm::new()
|
let overwrite = Confirm::new()
|
||||||
.with_prompt("Configuration already exists. Overwrite?")
|
.with_prompt(messages.config_exists_overwrite())
|
||||||
.default(false)
|
.default(false)
|
||||||
.interact()?;
|
.interact()?;
|
||||||
|
|
||||||
if !overwrite {
|
if !overwrite {
|
||||||
println!("{}", "Initialization cancelled.".yellow());
|
print_warning(messages.init_cancelled());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!(
|
print_warning(messages.config_exists_use_reset());
|
||||||
"{}",
|
|
||||||
"Configuration already exists. Use --reset to overwrite.".yellow()
|
|
||||||
);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +57,7 @@ impl InitCommand {
|
|||||||
let mut manager = ConfigManager::with_path_fresh(&config_path)?;
|
let mut manager = ConfigManager::with_path_fresh(&config_path)?;
|
||||||
|
|
||||||
if self.yes {
|
if self.yes {
|
||||||
self.quick_setup(&mut manager).await?;
|
self.quick_setup(&mut manager, &messages).await?;
|
||||||
} else {
|
} else {
|
||||||
self.interactive_setup(&mut manager).await?;
|
self.interactive_setup(&mut manager).await?;
|
||||||
}
|
}
|
||||||
@@ -69,27 +67,52 @@ impl InitCommand {
|
|||||||
let language = manager.get_language().unwrap_or(Language::English);
|
let language = manager.get_language().unwrap_or(Language::English);
|
||||||
let messages = Messages::new(language);
|
let messages = Messages::new(language);
|
||||||
|
|
||||||
println!("{}", messages.init_success().bold().green());
|
print_success(messages.init_success());
|
||||||
println!("\n{}: {}", messages.config_file(), config_path.display());
|
println!("\n{}: {}", messages.config_file(), config_path.display());
|
||||||
println!("\n{}:", messages.next_steps());
|
println!("\n{}:", messages.next_steps());
|
||||||
println!(" 1. Create a profile: {}", "quicommit profile add".cyan());
|
println!(
|
||||||
println!(" 2. Configure LLM: {}", "quicommit config set-llm".cyan());
|
" 1. {}: {}",
|
||||||
println!(" 3. Start committing: {}", "quicommit commit".cyan());
|
messages.next_steps_create_profile(),
|
||||||
|
"quicommit profile add".cyan()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" 2. {}: {}",
|
||||||
|
messages.next_steps_configure_llm(),
|
||||||
|
"quicommit config set-llm".cyan()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" 3. {}: {}",
|
||||||
|
messages.next_steps_start_committing(),
|
||||||
|
"quicommit commit".cyan()
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn quick_setup(&self, manager: &mut ConfigManager) -> Result<()> {
|
async fn quick_setup(&self, manager: &mut ConfigManager, messages: &Messages) -> Result<()> {
|
||||||
let git_config = git2::Config::open_default()?;
|
let git_config = git2::Config::open_default()?;
|
||||||
|
|
||||||
let user_name = git_config
|
let name_result = git_config.get_string("user.name");
|
||||||
.get_string("user.name")
|
let email_result = git_config.get_string("user.email");
|
||||||
.unwrap_or_else(|_| "User".to_string());
|
let name_missing = name_result.is_err();
|
||||||
let user_email = git_config
|
let email_missing = email_result.is_err();
|
||||||
.get_string("user.email")
|
let user_name = name_result.unwrap_or_else(|_| "User".to_string());
|
||||||
.unwrap_or_else(|_| "user@example.com".to_string());
|
let user_email = email_result.unwrap_or_else(|_| "user@example.com".to_string());
|
||||||
|
|
||||||
let profile = GitProfile::new("default".to_string(), user_name, user_email);
|
let profile = GitProfile::new("default".to_string(), user_name.clone(), user_email.clone());
|
||||||
|
|
||||||
|
println!("\n{}", messages.quick_setup_summary().bold());
|
||||||
|
println!(" {}: {}", messages.profile_label(), "default".cyan());
|
||||||
|
println!(
|
||||||
|
" {}: {} <{}>",
|
||||||
|
messages.identity_label(),
|
||||||
|
user_name,
|
||||||
|
user_email
|
||||||
|
);
|
||||||
|
if name_missing || email_missing {
|
||||||
|
print_warning(messages.identity_placeholder_warning());
|
||||||
|
}
|
||||||
|
println!(" {}: {}", messages.llm_provider_label(), "ollama".cyan());
|
||||||
|
|
||||||
manager.add_profile("default".to_string(), profile)?;
|
manager.add_profile("default".to_string(), profile)?;
|
||||||
manager.set_default_profile(Some("default".to_string()))?;
|
manager.set_default_profile(Some("default".to_string()))?;
|
||||||
@@ -231,11 +254,8 @@ impl InitCommand {
|
|||||||
let keyring_available = keyring.is_available();
|
let keyring_available = keyring.is_available();
|
||||||
|
|
||||||
if !keyring_available {
|
if !keyring_available {
|
||||||
println!(
|
print_warning(messages.keyring_unavailable());
|
||||||
"\n{}",
|
print_warning(&keyring.get_status_message());
|
||||||
"⚠ Keyring is not available on this system.".yellow()
|
|
||||||
);
|
|
||||||
println!("{}", keyring.get_status_message().yellow());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let api_key = if provider_needs_api_key(&provider) {
|
let api_key = if provider_needs_api_key(&provider) {
|
||||||
@@ -246,11 +266,7 @@ impl InitCommand {
|
|||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
if let Some(_key) = env_key {
|
if let Some(_key) = env_key {
|
||||||
println!(
|
print_success(messages.api_key_found_env());
|
||||||
"\n{} {}",
|
|
||||||
"✓".green(),
|
|
||||||
"Found API key in environment variable.".green()
|
|
||||||
);
|
|
||||||
None
|
None
|
||||||
} else if keyring_available {
|
} else if keyring_available {
|
||||||
let prompt = match provider.as_str() {
|
let prompt = match provider.as_str() {
|
||||||
@@ -259,16 +275,13 @@ impl InitCommand {
|
|||||||
"kimi" => messages.kimi_api_key(),
|
"kimi" => messages.kimi_api_key(),
|
||||||
"deepseek" => messages.deepseek_api_key(),
|
"deepseek" => messages.deepseek_api_key(),
|
||||||
"openrouter" => messages.openrouter_api_key(),
|
"openrouter" => messages.openrouter_api_key(),
|
||||||
_ => "API Key",
|
_ => messages.api_key_prompt_other(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let key: String = Input::new().with_prompt(prompt).interact_text()?;
|
let key: String = crate::utils::password_input(prompt)?;
|
||||||
Some(key)
|
Some(key)
|
||||||
} else {
|
} else {
|
||||||
println!(
|
print_warning(messages.please_set_api_key_env());
|
||||||
"\n{}",
|
|
||||||
"Please set the QUICOMMIT_API_KEY environment variable.".yellow()
|
|
||||||
);
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -277,24 +290,26 @@ impl InitCommand {
|
|||||||
|
|
||||||
let default_model = get_default_model(&provider);
|
let default_model = get_default_model(&provider);
|
||||||
let model: String = Input::new()
|
let model: String = Input::new()
|
||||||
.with_prompt("Model name")
|
.with_prompt(messages.model_name())
|
||||||
.default(default_model.to_string())
|
.default(default_model.to_string())
|
||||||
.interact_text()?;
|
.interact_text()?;
|
||||||
|
|
||||||
let base_url: Option<String> = if provider == "ollama" {
|
let base_url: Option<String> = if provider == "ollama" {
|
||||||
let url: String = Input::new()
|
let url: String = Input::new()
|
||||||
.with_prompt("Ollama server URL")
|
.with_prompt(messages.ollama_server_url())
|
||||||
.default("http://localhost:11434".to_string())
|
.default("http://localhost:11434".to_string())
|
||||||
.interact_text()?;
|
.interact_text()?;
|
||||||
Some(url)
|
Some(url)
|
||||||
} else {
|
} else {
|
||||||
let use_custom_url = Confirm::new()
|
let use_custom_url = Confirm::new()
|
||||||
.with_prompt("Use custom API base URL?")
|
.with_prompt(messages.use_custom_base_url())
|
||||||
.default(false)
|
.default(false)
|
||||||
.interact()?;
|
.interact()?;
|
||||||
|
|
||||||
if use_custom_url {
|
if use_custom_url {
|
||||||
let url: String = Input::new().with_prompt("Base URL").interact_text()?;
|
let url: String = Input::new()
|
||||||
|
.with_prompt(messages.base_url_plain())
|
||||||
|
.interact_text()?;
|
||||||
Some(url)
|
Some(url)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -309,11 +324,7 @@ impl InitCommand {
|
|||||||
&& provider_needs_api_key(&provider)
|
&& provider_needs_api_key(&provider)
|
||||||
{
|
{
|
||||||
manager.set_api_key(&key)?;
|
manager.set_api_key(&key)?;
|
||||||
println!(
|
print_success(messages.api_key_stored_keyring());
|
||||||
"\n{} {}",
|
|
||||||
"✓".green(),
|
|
||||||
"API key stored securely in system keyring.".green()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -332,7 +343,7 @@ impl InitCommand {
|
|||||||
.interact_text()?;
|
.interact_text()?;
|
||||||
|
|
||||||
let pub_key_path: String = Input::new()
|
let pub_key_path: String = Input::new()
|
||||||
.with_prompt("SSH public key path (optional, leave empty to auto-detect)")
|
.with_prompt(messages.ssh_public_key_path())
|
||||||
.default(ssh_dir.join("id_rsa.pub").display().to_string())
|
.default(ssh_dir.join("id_rsa.pub").display().to_string())
|
||||||
.allow_empty(true)
|
.allow_empty(true)
|
||||||
.interact_text()?;
|
.interact_text()?;
|
||||||
@@ -354,12 +365,12 @@ impl InitCommand {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let agent_forwarding = Confirm::new()
|
let agent_forwarding = Confirm::new()
|
||||||
.with_prompt("Enable SSH agent forwarding (-A)?")
|
.with_prompt(messages.ssh_agent_forwarding())
|
||||||
.default(false)
|
.default(false)
|
||||||
.interact()?;
|
.interact()?;
|
||||||
|
|
||||||
let known_hosts: String = Input::new()
|
let known_hosts: String = Input::new()
|
||||||
.with_prompt("Custom known_hosts file path (optional)")
|
.with_prompt(messages.known_hosts_path())
|
||||||
.allow_empty(true)
|
.allow_empty(true)
|
||||||
.interact_text()?;
|
.interact_text()?;
|
||||||
let known_hosts_file = if known_hosts.is_empty() {
|
let known_hosts_file = if known_hosts.is_empty() {
|
||||||
@@ -369,7 +380,7 @@ impl InitCommand {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let custom_cmd: String = Input::new()
|
let custom_cmd: String = Input::new()
|
||||||
.with_prompt("Custom SSH command (optional, overrides all other SSH settings)")
|
.with_prompt(messages.custom_ssh_command())
|
||||||
.allow_empty(true)
|
.allow_empty(true)
|
||||||
.interact_text()?;
|
.interact_text()?;
|
||||||
let ssh_command = if custom_cmd.is_empty() {
|
let ssh_command = if custom_cmd.is_empty() {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ use crate::git::tag::{
|
|||||||
};
|
};
|
||||||
use crate::git::{GitRepo, find_repo};
|
use crate::git::{GitRepo, find_repo};
|
||||||
use crate::i18n::Messages;
|
use crate::i18n::Messages;
|
||||||
|
use crate::utils::{print_progress, print_success, print_warning};
|
||||||
|
|
||||||
/// Generate and create Git tags
|
/// Generate and create Git tags
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@@ -61,7 +62,7 @@ pub struct TagCommand {
|
|||||||
#[arg(short = 't', long)]
|
#[arg(short = 't', long)]
|
||||||
think: bool,
|
think: bool,
|
||||||
|
|
||||||
/// Skip interactive prompts
|
/// Skip interactive prompts only (generation behavior unchanged)
|
||||||
#[arg(short = 'y', long)]
|
#[arg(short = 'y', long)]
|
||||||
yes: bool,
|
yes: bool,
|
||||||
|
|
||||||
@@ -115,11 +116,11 @@ impl TagCommand {
|
|||||||
{
|
{
|
||||||
let version_str = tag_name.trim_start_matches('v');
|
let version_str = tag_name.trim_start_matches('v');
|
||||||
if let Err(e) = crate::utils::validators::validate_semver(version_str) {
|
if let Err(e) = crate::utils::validators::validate_semver(version_str) {
|
||||||
println!("{}: {}", "Warning".yellow(), e);
|
print_warning(&format!("{}: {}", messages.warning(), e));
|
||||||
|
|
||||||
if !self.yes {
|
if !self.yes {
|
||||||
let proceed = Confirm::new()
|
let proceed = Confirm::new()
|
||||||
.with_prompt("Proceed with this tag name anyway?")
|
.with_prompt(messages.proceed_invalid_tag_name())
|
||||||
.default(true)
|
.default(true)
|
||||||
.interact()?;
|
.interact()?;
|
||||||
|
|
||||||
@@ -135,7 +136,7 @@ impl TagCommand {
|
|||||||
None
|
None
|
||||||
} else if let Some(msg) = &self.message {
|
} else if let Some(msg) = &self.message {
|
||||||
Some(msg.clone())
|
Some(msg.clone())
|
||||||
} else if self.generate || (config.tag.auto_generate && !self.yes) {
|
} else if self.generate || config.tag.auto_generate {
|
||||||
Some(
|
Some(
|
||||||
self.generate_tag_message(&repo, &tag_name, &messages)
|
self.generate_tag_message(&repo, &tag_name, &messages)
|
||||||
.await?,
|
.await?,
|
||||||
@@ -143,7 +144,7 @@ impl TagCommand {
|
|||||||
} else if !self.yes {
|
} else if !self.yes {
|
||||||
Some(self.input_message_interactive(&tag_name, &messages)?)
|
Some(self.input_message_interactive(&tag_name, &messages)?)
|
||||||
} else {
|
} else {
|
||||||
Some(format!("Release {}", tag_name))
|
Some(messages.release_default(&tag_name))
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show preview
|
// Show preview
|
||||||
@@ -165,13 +166,13 @@ impl TagCommand {
|
|||||||
.interact()?;
|
.interact()?;
|
||||||
|
|
||||||
if !confirm {
|
if !confirm {
|
||||||
println!("{}", messages.tag_cancelled().yellow());
|
print_warning(messages.tag_cancelled());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.dry_run {
|
if self.dry_run {
|
||||||
println!("\n{} {}", messages.dry_run(), "- tag not created.".yellow());
|
println!("\n{}", messages.dry_run_tag_not_created().yellow());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,13 +185,13 @@ impl TagCommand {
|
|||||||
|
|
||||||
builder.execute(&repo)?;
|
builder.execute(&repo)?;
|
||||||
|
|
||||||
println!("{} {}", messages.tag_created().green(), tag_name.cyan());
|
print_success(&format!("{} {}", messages.tag_created(), tag_name.cyan()));
|
||||||
|
|
||||||
// Push if requested or ask user
|
// Push if requested or ask user
|
||||||
if self.push {
|
if self.push {
|
||||||
println!("{}", messages.pushing_tag(&self.remote));
|
print_progress(&messages.pushing_tag(&self.remote));
|
||||||
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
|
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
|
||||||
println!("{}", messages.pushed_tag(&self.remote));
|
print_success(&messages.pushed_tag(&self.remote));
|
||||||
} else if !self.yes && !self.dry_run {
|
} else if !self.yes && !self.dry_run {
|
||||||
let should_push = Confirm::new()
|
let should_push = Confirm::new()
|
||||||
.with_prompt(messages.push_after_tag())
|
.with_prompt(messages.push_after_tag())
|
||||||
@@ -198,9 +199,9 @@ impl TagCommand {
|
|||||||
.interact()?;
|
.interact()?;
|
||||||
|
|
||||||
if should_push {
|
if should_push {
|
||||||
println!("{}", messages.pushing_tag(&self.remote));
|
print_progress(&messages.pushing_tag(&self.remote));
|
||||||
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
|
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
|
||||||
println!("{}", messages.pushed_tag(&self.remote));
|
print_success(&messages.pushed_tag(&self.remote));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,15 +324,16 @@ impl TagCommand {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if commits.is_empty() {
|
if commits.is_empty() {
|
||||||
return Ok(format!("Release {}", version));
|
return Ok(messages.release_default(version));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("{}", messages.ai_generating_tag(commits.len()));
|
|
||||||
|
|
||||||
let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?;
|
let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?;
|
||||||
generator
|
let spinner = crate::utils::Spinner::start(&messages.ai_generating_tag(commits.len()));
|
||||||
|
let result = generator
|
||||||
.generate_tag_message(version, &commits, language)
|
.generate_tag_message(version, &commits, language)
|
||||||
.await
|
.await;
|
||||||
|
spinner.finish_clear();
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn auto_detect_version(
|
async fn auto_detect_version(
|
||||||
@@ -418,7 +420,7 @@ impl TagCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result<String> {
|
fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result<String> {
|
||||||
let default_msg = format!("Release {}", version);
|
let default_msg = messages.release_default(version);
|
||||||
|
|
||||||
let use_editor = Confirm::new()
|
let use_editor = Confirm::new()
|
||||||
.with_prompt(messages.open_editor())
|
.with_prompt(messages.open_editor())
|
||||||
|
|||||||
@@ -278,6 +278,22 @@ impl ConfigManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete a PAT from the keyring even when no config token entry exists.
|
||||||
|
/// Returns true if a keyring entry was deleted (issue 24).
|
||||||
|
pub fn delete_orphan_pat(&self, profile_name: &str, service: &str) -> Result<bool> {
|
||||||
|
if let Some(profile) = self.get_profile(profile_name) {
|
||||||
|
let user_email = &profile.user_email;
|
||||||
|
if self.keyring.has_pat(profile_name, user_email, service) {
|
||||||
|
self.keyring.delete_pat(profile_name, user_email, service)?;
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete all PAT tokens for a profile (used when removing a profile)
|
/// Delete all PAT tokens for a profile (used when removing a profile)
|
||||||
pub fn delete_all_pats_for_profile(&self, profile_name: &str) -> Result<()> {
|
pub fn delete_all_pats_for_profile(&self, profile_name: &str) -> Result<()> {
|
||||||
if let Some(profile) = self.get_profile(profile_name) {
|
if let Some(profile) = self.get_profile(profile_name) {
|
||||||
@@ -658,6 +674,17 @@ impl ConfigManager {
|
|||||||
self.modified = true;
|
self.modified = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if emoji decorations are enabled
|
||||||
|
pub fn emoji_enabled(&self) -> bool {
|
||||||
|
self.config.output.emoji
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set emoji decorations flag
|
||||||
|
pub fn set_emoji_enabled(&mut self, enabled: bool) {
|
||||||
|
self.config.output.emoji = enabled;
|
||||||
|
self.modified = true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Export configuration to TOML string
|
/// Export configuration to TOML string
|
||||||
pub fn export(&self) -> Result<String> {
|
pub fn export(&self) -> Result<String> {
|
||||||
toml::to_string_pretty(&self.config).context("Failed to serialize config")
|
toml::to_string_pretty(&self.config).context("Failed to serialize config")
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ pub struct AppConfig {
|
|||||||
/// Language settings
|
/// Language settings
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub language: LanguageConfig,
|
pub language: LanguageConfig,
|
||||||
|
|
||||||
|
/// Output settings
|
||||||
|
#[serde(default)]
|
||||||
|
pub output: OutputConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
@@ -61,6 +65,7 @@ impl Default for AppConfig {
|
|||||||
changelog: ChangelogConfig::default(),
|
changelog: ChangelogConfig::default(),
|
||||||
repo_profiles: HashMap::new(),
|
repo_profiles: HashMap::new(),
|
||||||
language: LanguageConfig::default(),
|
language: LanguageConfig::default(),
|
||||||
|
output: OutputConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,6 +241,20 @@ impl Default for LanguageConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Output configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OutputConfig {
|
||||||
|
/// Show emoji/symbol decorations (✓/✗/⚠/→)
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub emoji: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OutputConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { emoji: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Supported languages
|
/// Supported languages
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Language {
|
pub enum Language {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
use crate::config::manager::ConfigManager;
|
use crate::config::manager::ConfigManager;
|
||||||
use crate::config::{CommitFormat, Language};
|
use crate::config::{CommitFormat, Language};
|
||||||
use crate::git::{CommitInfo, GitRepo};
|
use crate::git::{CommitInfo, GitRepo};
|
||||||
|
use crate::i18n::Messages;
|
||||||
use crate::llm::parsing::GeneratedCommit;
|
use crate::llm::parsing::GeneratedCommit;
|
||||||
use crate::llm::rig::LlmClient;
|
use crate::llm::rig::LlmClient;
|
||||||
|
use crate::utils::{eprint_warning, success_prefix};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
/// Content generator using LLM
|
/// Content generator using LLM
|
||||||
@@ -33,11 +35,9 @@ impl ContentGenerator {
|
|||||||
if thinking_enabled {
|
if thinking_enabled {
|
||||||
let provider = manager.llm_provider();
|
let provider = manager.llm_provider();
|
||||||
if !Self::supports_thinking(provider) {
|
if !Self::supports_thinking(provider) {
|
||||||
eprintln!(
|
let language = manager.get_language().unwrap_or(Language::English);
|
||||||
"Warning: Provider '{}' does not support thinking mode. \
|
let messages = Messages::new(language);
|
||||||
Disabling thinking for this invocation.",
|
eprint_warning(&messages.thinking_unsupported(provider));
|
||||||
provider
|
|
||||||
);
|
|
||||||
thinking_enabled = false;
|
thinking_enabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,6 +156,7 @@ impl ContentGenerator {
|
|||||||
repo: &GitRepo,
|
repo: &GitRepo,
|
||||||
format: CommitFormat,
|
format: CommitFormat,
|
||||||
language: Language,
|
language: Language,
|
||||||
|
messages: &Messages,
|
||||||
) -> Result<GeneratedCommit> {
|
) -> Result<GeneratedCommit> {
|
||||||
use dialoguer::Select;
|
use dialoguer::Select;
|
||||||
|
|
||||||
@@ -167,33 +168,33 @@ impl ContentGenerator {
|
|||||||
|
|
||||||
// Show diff summary
|
// Show diff summary
|
||||||
let files = repo.get_staged_files()?;
|
let files = repo.get_staged_files()?;
|
||||||
println!("\nStaged files ({}):", files.len());
|
println!("\n{}", messages.staged_files(files.len()));
|
||||||
for file in &files {
|
for file in &files {
|
||||||
println!(" • {}", file);
|
println!(" • {}", file);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate initial commit
|
// Generate initial commit
|
||||||
println!("\nGenerating commit message...");
|
println!("\n{}", messages.generating_commit_message());
|
||||||
let mut generated = self
|
let mut generated = self
|
||||||
.generate_commit_message(&diff, format, language)
|
.generate_commit_message(&diff, format, language)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
println!("\n{}", "─".repeat(60));
|
println!("\n{}", "─".repeat(60));
|
||||||
println!("Generated commit message:");
|
println!("{}", messages.generated_commit_message());
|
||||||
println!("{}", "─".repeat(60));
|
println!("{}", "─".repeat(60));
|
||||||
println!("{}", generated.to_conventional());
|
println!("{}", generated.to_conventional());
|
||||||
println!("{}", "─".repeat(60));
|
println!("{}", "─".repeat(60));
|
||||||
|
|
||||||
let options = vec![
|
let options = vec![
|
||||||
"✓ Accept and commit",
|
format!("{} {}", success_prefix(), messages.accept_and_commit()),
|
||||||
"🔄 Regenerate",
|
messages.regenerate().to_string(),
|
||||||
"✏️ Edit",
|
messages.edit().to_string(),
|
||||||
"❌ Cancel",
|
messages.cancel().to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
let selection = Select::new()
|
let selection = Select::new()
|
||||||
.with_prompt("What would you like to do?")
|
.with_prompt(messages.what_would_you_like_to_do())
|
||||||
.items(&options)
|
.items(&options)
|
||||||
.default(0)
|
.default(0)
|
||||||
.interact()?;
|
.interact()?;
|
||||||
@@ -201,7 +202,7 @@ impl ContentGenerator {
|
|||||||
match selection {
|
match selection {
|
||||||
0 => return Ok(generated),
|
0 => return Ok(generated),
|
||||||
1 => {
|
1 => {
|
||||||
println!("Regenerating...");
|
println!("{}", messages.regenerating());
|
||||||
generated = self
|
generated = self
|
||||||
.generate_commit_message(&diff, format, language)
|
.generate_commit_message(&diff, format, language)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -210,7 +211,7 @@ impl ContentGenerator {
|
|||||||
let edited = crate::utils::editor::edit_content(&generated.to_conventional())?;
|
let edited = crate::utils::editor::edit_content(&generated.to_conventional())?;
|
||||||
generated = self.parse_edited_commit(&edited, format)?;
|
generated = self.parse_edited_commit(&edited, format)?;
|
||||||
}
|
}
|
||||||
3 => anyhow::bail!("Cancelled by user"),
|
3 => anyhow::bail!("{}", messages.cancelled_by_user()),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ fn try_open_repo_with_git2(path: &Path) -> Result<Repository> {
|
|||||||
.or_else(|_| Repository::discover(&normalized))
|
.or_else(|_| Repository::discover(&normalized))
|
||||||
.or_else(|_| Repository::open(&normalized));
|
.or_else(|_| Repository::open(&normalized));
|
||||||
|
|
||||||
repo.map_err(|e| anyhow::anyhow!("git2 failed: {}", e))
|
repo.map_err(|e| anyhow::anyhow!("Failed to open repository: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_open_repo_with_git_cli(path: &Path) -> Result<Repository> {
|
fn try_open_repo_with_git_cli(path: &Path) -> Result<Repository> {
|
||||||
@@ -642,7 +642,7 @@ impl GitRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Stage all changes including subdirectories, then remove ignored tracked files
|
/// Stage all changes including subdirectories, then remove ignored tracked files
|
||||||
pub fn stage_all(&self) -> Result<Vec<String>> {
|
pub fn stage_all(&self, messages: &crate::i18n::Messages) -> Result<Vec<String>> {
|
||||||
// Use git command for reliable staging (handles all edge cases)
|
// Use git command for reliable staging (handles all edge cases)
|
||||||
let output = std::process::Command::new("git")
|
let output = std::process::Command::new("git")
|
||||||
.args(["add", "-A"])
|
.args(["add", "-A"])
|
||||||
@@ -662,7 +662,11 @@ impl GitRepo {
|
|||||||
match self.remove_ignored_from_index() {
|
match self.remove_ignored_from_index() {
|
||||||
Ok(removed) => Ok(removed),
|
Ok(removed) => Ok(removed),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Warning: failed to clean ignored files from index: {}", e);
|
crate::utils::eprint_warning(&format!(
|
||||||
|
"{}: {}",
|
||||||
|
messages.failed_clean_ignored(),
|
||||||
|
e
|
||||||
|
));
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -937,16 +941,6 @@ impl GitRepo {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create GPG signature for arbitrary content
|
|
||||||
fn create_gpg_signature_for_content(
|
|
||||||
&self,
|
|
||||||
_content: &str,
|
|
||||||
_gpg_program: &str,
|
|
||||||
_signing_key: &str,
|
|
||||||
) -> Result<String> {
|
|
||||||
Ok(String::new())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a tag
|
/// Delete a tag
|
||||||
pub fn delete_tag(&self, name: &str) -> Result<()> {
|
pub fn delete_tag(&self, name: &str) -> Result<()> {
|
||||||
self.repo.tag_delete(name)?;
|
self.repo.tag_delete(name)?;
|
||||||
@@ -1515,7 +1509,14 @@ mod tests {
|
|||||||
let head = repo.repo.head().unwrap().peel_to_commit().unwrap();
|
let head = repo.repo.head().unwrap().peel_to_commit().unwrap();
|
||||||
|
|
||||||
// Create a lightweight tag
|
// Create a lightweight tag
|
||||||
repo.repo.tag("v0.1.0", head.as_object(), &repo.repo.signature().unwrap(), "", false)
|
repo.repo
|
||||||
|
.tag(
|
||||||
|
"v0.1.0",
|
||||||
|
head.as_object(),
|
||||||
|
&repo.repo.signature().unwrap(),
|
||||||
|
"",
|
||||||
|
false,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let tags = repo.get_tags().unwrap();
|
let tags = repo.get_tags().unwrap();
|
||||||
@@ -1541,7 +1542,13 @@ mod tests {
|
|||||||
|
|
||||||
// Create lightweight tag
|
// Create lightweight tag
|
||||||
repo.repo
|
repo.repo
|
||||||
.tag("v0.1.0", head.as_object(), &repo.repo.signature().unwrap(), "", false)
|
.tag(
|
||||||
|
"v0.1.0",
|
||||||
|
head.as_object(),
|
||||||
|
&repo.repo.signature().unwrap(),
|
||||||
|
"",
|
||||||
|
false,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let tags = repo.get_tags().unwrap();
|
let tags = repo.get_tags().unwrap();
|
||||||
|
|||||||
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 commands;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod generator;
|
pub mod generator;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! 全部 provider 接入并切换后,旧实现将被删除。
|
//! 全部 provider 接入并切换后,旧实现将被删除。
|
||||||
|
|
||||||
use crate::config::manager::ConfigManager;
|
use crate::config::manager::ConfigManager;
|
||||||
use crate::llm::thinking::{create_console_thinking_state, ThinkingStateManager};
|
use crate::llm::thinking::{ThinkingStateManager, create_console_thinking_state};
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use rig_core::{
|
use rig_core::{
|
||||||
client::{CompletionClient, Nothing, VerifyClient},
|
client::{CompletionClient, Nothing, VerifyClient},
|
||||||
@@ -154,7 +154,8 @@ fn required_api_key(provider: &str, key: Option<String>) -> Result<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<H> LlmClient<H> {
|
impl<H> LlmClient<H> {
|
||||||
/// 内部与测试构造入口。
|
/// 内部与测试构造入口(仅测试使用)。
|
||||||
|
#[cfg(test)]
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
backend: Backend<H>,
|
backend: Backend<H>,
|
||||||
model: impl Into<String>,
|
model: impl Into<String>,
|
||||||
@@ -332,7 +333,9 @@ where
|
|||||||
|
|
||||||
let language_instruction = match language {
|
let language_instruction = match language {
|
||||||
crate::config::Language::Chinese => "\n\n请用中文生成提交消息。",
|
crate::config::Language::Chinese => "\n\n请用中文生成提交消息。",
|
||||||
crate::config::Language::Japanese => "\n\n日本語でコミットメッセージを生成してください。",
|
crate::config::Language::Japanese => {
|
||||||
|
"\n\n日本語でコミットメッセージを生成してください。"
|
||||||
|
}
|
||||||
crate::config::Language::Korean => "\n\n한국어로 커밋 메시지를 생성하세요.",
|
crate::config::Language::Korean => "\n\n한국어로 커밋 메시지를 생성하세요.",
|
||||||
crate::config::Language::Spanish => {
|
crate::config::Language::Spanish => {
|
||||||
"\n\nPor favor, genera el mensaje de commit en español."
|
"\n\nPor favor, genera el mensaje de commit en español."
|
||||||
@@ -465,7 +468,8 @@ where
|
|||||||
let event = event.map_err(|e| map_completion_error(&self.provider, e))?;
|
let event = event.map_err(|e| map_completion_error(&self.provider, e))?;
|
||||||
match event {
|
match event {
|
||||||
StreamedAssistantContent::Text(chunk) => {
|
StreamedAssistantContent::Text(chunk) => {
|
||||||
if has_reasoning && !has_content
|
if has_reasoning
|
||||||
|
&& !has_content
|
||||||
&& let Some(state) = state
|
&& let Some(state) = state
|
||||||
{
|
{
|
||||||
state.end_thinking();
|
state.end_thinking();
|
||||||
@@ -474,7 +478,9 @@ where
|
|||||||
text.push_str(&chunk.text);
|
text.push_str(&chunk.text);
|
||||||
}
|
}
|
||||||
StreamedAssistantContent::Reasoning(_)
|
StreamedAssistantContent::Reasoning(_)
|
||||||
| StreamedAssistantContent::ReasoningDelta { .. } if !has_reasoning => {
|
| StreamedAssistantContent::ReasoningDelta { .. }
|
||||||
|
if !has_reasoning =>
|
||||||
|
{
|
||||||
has_reasoning = true;
|
has_reasoning = true;
|
||||||
if let Some(state) = state {
|
if let Some(state) = state {
|
||||||
state.start_thinking();
|
state.start_thinking();
|
||||||
@@ -519,7 +525,11 @@ fn build_ollama_client(base_url: &str, http: ReqwestClient) -> Result<ollama::Cl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 构建 DeepSeek 客户端(OpenAI 兼容 API)。
|
/// 构建 DeepSeek 客户端(OpenAI 兼容 API)。
|
||||||
fn build_deepseek_client(key: &str, base_url: &str, http: ReqwestClient) -> Result<deepseek::Client> {
|
fn build_deepseek_client(
|
||||||
|
key: &str,
|
||||||
|
base_url: &str,
|
||||||
|
http: ReqwestClient,
|
||||||
|
) -> Result<deepseek::Client> {
|
||||||
deepseek::Client::builder()
|
deepseek::Client::builder()
|
||||||
.api_key(key)
|
.api_key(key)
|
||||||
.base_url(base_url)
|
.base_url(base_url)
|
||||||
@@ -539,7 +549,11 @@ fn build_kimi_client(key: &str, base_url: &str, http: ReqwestClient) -> Result<m
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 构建 Anthropic 客户端(rig 会自动规范化 base_url 的 /v1 等后缀)。
|
/// 构建 Anthropic 客户端(rig 会自动规范化 base_url 的 /v1 等后缀)。
|
||||||
fn build_anthropic_client(key: &str, base_url: &str, http: ReqwestClient) -> Result<anthropic::Client> {
|
fn build_anthropic_client(
|
||||||
|
key: &str,
|
||||||
|
base_url: &str,
|
||||||
|
http: ReqwestClient,
|
||||||
|
) -> Result<anthropic::Client> {
|
||||||
anthropic::Client::builder()
|
anthropic::Client::builder()
|
||||||
.api_key(key)
|
.api_key(key)
|
||||||
.base_url(base_url)
|
.base_url(base_url)
|
||||||
@@ -564,7 +578,11 @@ fn build_openai_client(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 构建 OpenRouter 客户端(携带 QuiCommit 应用标识头)。
|
/// 构建 OpenRouter 客户端(携带 QuiCommit 应用标识头)。
|
||||||
fn build_openrouter_client(key: &str, base_url: &str, http: ReqwestClient) -> Result<openrouter::Client> {
|
fn build_openrouter_client(
|
||||||
|
key: &str,
|
||||||
|
base_url: &str,
|
||||||
|
http: ReqwestClient,
|
||||||
|
) -> Result<openrouter::Client> {
|
||||||
openrouter::Client::builder()
|
openrouter::Client::builder()
|
||||||
.api_key(key)
|
.api_key(key)
|
||||||
.base_url(base_url)
|
.base_url(base_url)
|
||||||
@@ -713,8 +731,14 @@ mod tests {
|
|||||||
.build()
|
.build()
|
||||||
.expect("build deepseek client with mock backend"),
|
.expect("build deepseek client with mock backend"),
|
||||||
);
|
);
|
||||||
let client =
|
let client = LlmClient::new(
|
||||||
LlmClient::new(backend, "deepseek-v4-flash", "deepseek", test_config(), thinking, None);
|
backend,
|
||||||
|
"deepseek-v4-flash",
|
||||||
|
"deepseek",
|
||||||
|
test_config(),
|
||||||
|
thinking,
|
||||||
|
None,
|
||||||
|
);
|
||||||
(client, recorder)
|
(client, recorder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -817,7 +841,10 @@ mod tests {
|
|||||||
assert_eq!(text, "hello from deepseek");
|
assert_eq!(text, "hello from deepseek");
|
||||||
|
|
||||||
let captured = recorder.requests();
|
let captured = recorder.requests();
|
||||||
assert_eq!(captured[0].uri, "https://api.deepseek.com/v1/chat/completions");
|
assert_eq!(
|
||||||
|
captured[0].uri,
|
||||||
|
"https://api.deepseek.com/v1/chat/completions"
|
||||||
|
);
|
||||||
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap();
|
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap();
|
||||||
assert_eq!(body["model"], "deepseek-v4-flash");
|
assert_eq!(body["model"], "deepseek-v4-flash");
|
||||||
// 非流式请求省略 stream 字段(API 默认 false)
|
// 非流式请求省略 stream 字段(API 默认 false)
|
||||||
@@ -843,7 +870,10 @@ mod tests {
|
|||||||
client.generate(None, "hi").await.unwrap();
|
client.generate(None, "hi").await.unwrap();
|
||||||
|
|
||||||
let captured = recorder.requests();
|
let captured = recorder.requests();
|
||||||
assert_eq!(captured[0].uri, "https://api.moonshot.cn/v1/chat/completions");
|
assert_eq!(
|
||||||
|
captured[0].uri,
|
||||||
|
"https://api.moonshot.cn/v1/chat/completions"
|
||||||
|
);
|
||||||
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap();
|
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap();
|
||||||
assert_eq!(body["temperature"], 0.6);
|
assert_eq!(body["temperature"], 0.6);
|
||||||
assert_eq!(body["thinking"]["type"], "disabled");
|
assert_eq!(body["thinking"]["type"], "disabled");
|
||||||
@@ -882,7 +912,9 @@ mod tests {
|
|||||||
deepseek::Client::builder()
|
deepseek::Client::builder()
|
||||||
.api_key("sk-test")
|
.api_key("sk-test")
|
||||||
.base_url("https://api.deepseek.com/v1")
|
.base_url("https://api.deepseek.com/v1")
|
||||||
.http_client(MockStreamingClient { sse_bytes: sse.to_string().into() })
|
.http_client(MockStreamingClient {
|
||||||
|
sse_bytes: sse.to_string().into(),
|
||||||
|
})
|
||||||
.build()
|
.build()
|
||||||
.expect("build deepseek client with streaming mock"),
|
.expect("build deepseek client with streaming mock"),
|
||||||
);
|
);
|
||||||
@@ -962,9 +994,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn map_error_without_body_keeps_provider_prefix() {
|
fn map_error_without_body_keeps_provider_prefix() {
|
||||||
let err = map_completion_error("DeepSeek", CompletionError::ProviderError("boom".into()));
|
let err = map_completion_error("DeepSeek", CompletionError::ProviderError("boom".into()));
|
||||||
assert!(err
|
assert!(
|
||||||
.to_string()
|
err.to_string()
|
||||||
.contains("DeepSeek API request failed: ProviderError: boom"));
|
.contains("DeepSeek API request failed: ProviderError: boom")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -980,10 +1013,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn supports_thinking_whitelist_matches_legacy() {
|
fn supports_thinking_whitelist_matches_legacy() {
|
||||||
for provider in ["deepseek", "kimi", "anthropic", "openai"] {
|
for provider in ["deepseek", "kimi", "anthropic", "openai"] {
|
||||||
assert!(supports_thinking(provider), "{provider} should support thinking");
|
assert!(
|
||||||
|
supports_thinking(provider),
|
||||||
|
"{provider} should support thinking"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for provider in ["ollama", "openrouter"] {
|
for provider in ["ollama", "openrouter"] {
|
||||||
assert!(!supports_thinking(provider), "{provider} should not support thinking");
|
assert!(
|
||||||
|
!supports_thinking(provider),
|
||||||
|
"{provider} should not support thinking"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1015,7 +1054,14 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let mut config = test_config();
|
let mut config = test_config();
|
||||||
config.thinking_budget_tokens = budget;
|
config.thinking_budget_tokens = budget;
|
||||||
let client = LlmClient::new(backend, "claude-sonnet-4-6", "anthropic", config, thinking, None);
|
let client = LlmClient::new(
|
||||||
|
backend,
|
||||||
|
"claude-sonnet-4-6",
|
||||||
|
"anthropic",
|
||||||
|
config,
|
||||||
|
thinking,
|
||||||
|
None,
|
||||||
|
);
|
||||||
(client, recorder)
|
(client, recorder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1111,7 +1157,9 @@ mod tests {
|
|||||||
anthropic::Client::builder()
|
anthropic::Client::builder()
|
||||||
.api_key("sk-ant-test")
|
.api_key("sk-ant-test")
|
||||||
.base_url("https://api.anthropic.com/v1")
|
.base_url("https://api.anthropic.com/v1")
|
||||||
.http_client(MockStreamingClient { sse_bytes: sse.to_string().into() })
|
.http_client(MockStreamingClient {
|
||||||
|
sse_bytes: sse.to_string().into(),
|
||||||
|
})
|
||||||
.build()
|
.build()
|
||||||
.expect("build anthropic client with streaming mock"),
|
.expect("build anthropic client with streaming mock"),
|
||||||
);
|
);
|
||||||
@@ -1186,12 +1234,18 @@ mod tests {
|
|||||||
assert_eq!(text, "hello from openai");
|
assert_eq!(text, "hello from openai");
|
||||||
|
|
||||||
let captured = recorder.requests();
|
let captured = recorder.requests();
|
||||||
assert_eq!(captured[0].uri, "https://api.openai.com/v1/chat/completions");
|
assert_eq!(
|
||||||
|
captured[0].uri,
|
||||||
|
"https://api.openai.com/v1/chat/completions"
|
||||||
|
);
|
||||||
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap();
|
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap();
|
||||||
assert_eq!(body["model"], "gpt-4o");
|
assert_eq!(body["model"], "gpt-4o");
|
||||||
assert_eq!(body["temperature"], 0.5);
|
assert_eq!(body["temperature"], 0.5);
|
||||||
assert_eq!(body["max_tokens"], 123);
|
assert_eq!(body["max_tokens"], 123);
|
||||||
assert!(body.get("reasoning_effort").is_none(), "非 o 系列不应传 reasoning_effort");
|
assert!(
|
||||||
|
body.get("reasoning_effort").is_none(),
|
||||||
|
"非 o 系列不应传 reasoning_effort"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1200,7 +1254,9 @@ mod tests {
|
|||||||
let (client, _) = openai_client(recorder, "o3", false);
|
let (client, _) = openai_client(recorder, "o3", false);
|
||||||
|
|
||||||
let options = client.request_options();
|
let options = client.request_options();
|
||||||
let params = options.additional_params.expect("o 系列应传 reasoning_effort");
|
let params = options
|
||||||
|
.additional_params
|
||||||
|
.expect("o 系列应传 reasoning_effort");
|
||||||
assert_eq!(params["reasoning_effort"], "none");
|
assert_eq!(params["reasoning_effort"], "none");
|
||||||
assert!(!options.stream);
|
assert!(!options.stream);
|
||||||
}
|
}
|
||||||
@@ -1328,7 +1384,10 @@ mod tests {
|
|||||||
let captured = recorder.requests();
|
let captured = recorder.requests();
|
||||||
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap();
|
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap();
|
||||||
let system = body["messages"][0]["content"].as_str().unwrap();
|
let system = body["messages"][0]["content"].as_str().unwrap();
|
||||||
assert!(system.contains("Conventional Commits"), "system prompt 应包含规范说明");
|
assert!(
|
||||||
|
system.contains("Conventional Commits"),
|
||||||
|
"system prompt 应包含规范说明"
|
||||||
|
);
|
||||||
let user = body["messages"][1]["content"].as_str().unwrap();
|
let user = body["messages"][1]["content"].as_str().unwrap();
|
||||||
assert!(user.contains("diff --git"), "user prompt 应包含 diff");
|
assert!(user.contains("diff --git"), "user prompt 应包含 diff");
|
||||||
assert!(user.contains("请用中文生成提交消息"), "应包含语言指令");
|
assert!(user.contains("请用中文生成提交消息"), "应包含语言指令");
|
||||||
@@ -1336,12 +1395,15 @@ mod tests {
|
|||||||
|
|
||||||
// ---- 实网冒烟(#[ignore],需真实服务/API key)----
|
// ---- 实网冒烟(#[ignore],需真实服务/API key)----
|
||||||
|
|
||||||
fn smoke_client(
|
fn smoke_client(backend: Backend, model: &str, provider: &str) -> LlmClient {
|
||||||
backend: Backend,
|
LlmClient::new(
|
||||||
model: &str,
|
backend,
|
||||||
provider: &str,
|
model,
|
||||||
) -> LlmClient {
|
provider,
|
||||||
LlmClient::new(backend, model, provider, LlmClientConfig::default(), false, None)
|
LlmClientConfig::default(),
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn smoke_http() -> ReqwestClient {
|
fn smoke_http() -> ReqwestClient {
|
||||||
@@ -1370,7 +1432,9 @@ mod tests {
|
|||||||
#[ignore = "requires QUICOMMIT_API_KEY with a valid DeepSeek key"]
|
#[ignore = "requires QUICOMMIT_API_KEY with a valid DeepSeek key"]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn live_smoke_deepseek() {
|
async fn live_smoke_deepseek() {
|
||||||
let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return };
|
let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let backend = Backend::DeepSeek(
|
let backend = Backend::DeepSeek(
|
||||||
deepseek::Client::builder()
|
deepseek::Client::builder()
|
||||||
.api_key(&key)
|
.api_key(&key)
|
||||||
@@ -1387,7 +1451,9 @@ mod tests {
|
|||||||
#[ignore = "requires QUICOMMIT_API_KEY with a valid Moonshot key"]
|
#[ignore = "requires QUICOMMIT_API_KEY with a valid Moonshot key"]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn live_smoke_kimi() {
|
async fn live_smoke_kimi() {
|
||||||
let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return };
|
let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let backend = Backend::Kimi(
|
let backend = Backend::Kimi(
|
||||||
moonshot::Client::builder()
|
moonshot::Client::builder()
|
||||||
.api_key(&key)
|
.api_key(&key)
|
||||||
@@ -1404,7 +1470,9 @@ mod tests {
|
|||||||
#[ignore = "requires QUICOMMIT_API_KEY with a valid Anthropic key"]
|
#[ignore = "requires QUICOMMIT_API_KEY with a valid Anthropic key"]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn live_smoke_anthropic() {
|
async fn live_smoke_anthropic() {
|
||||||
let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return };
|
let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let backend = Backend::Anthropic(
|
let backend = Backend::Anthropic(
|
||||||
anthropic::Client::builder()
|
anthropic::Client::builder()
|
||||||
.api_key(&key)
|
.api_key(&key)
|
||||||
@@ -1421,7 +1489,9 @@ mod tests {
|
|||||||
#[ignore = "requires QUICOMMIT_API_KEY with a valid OpenAI key"]
|
#[ignore = "requires QUICOMMIT_API_KEY with a valid OpenAI key"]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn live_smoke_openai() {
|
async fn live_smoke_openai() {
|
||||||
let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return };
|
let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let backend = Backend::OpenAi(
|
let backend = Backend::OpenAi(
|
||||||
openai::Client::builder()
|
openai::Client::builder()
|
||||||
.api_key(&key)
|
.api_key(&key)
|
||||||
@@ -1439,7 +1509,9 @@ mod tests {
|
|||||||
#[ignore = "requires QUICOMMIT_API_KEY with a valid OpenRouter key"]
|
#[ignore = "requires QUICOMMIT_API_KEY with a valid OpenRouter key"]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn live_smoke_openrouter() {
|
async fn live_smoke_openrouter() {
|
||||||
let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return };
|
let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let backend = Backend::OpenRouter(
|
let backend = Backend::OpenRouter(
|
||||||
openrouter::Client::builder()
|
openrouter::Client::builder()
|
||||||
.api_key(&key)
|
.api_key(&key)
|
||||||
@@ -1453,4 +1525,4 @@ mod tests {
|
|||||||
let text = client.generate(None, "Say 'Hello, World!'").await.unwrap();
|
let text = client.generate(None, "Say 'Hello, World!'").await.unwrap();
|
||||||
assert!(!text.is_empty());
|
assert!(!text.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,18 +64,10 @@ impl Default for ThinkingStateManager {
|
|||||||
/// 线程安全的思考状态管理器引用
|
/// 线程安全的思考状态管理器引用
|
||||||
pub type SharedThinkingState = Arc<ThinkingStateManager>;
|
pub type SharedThinkingState = Arc<ThinkingStateManager>;
|
||||||
|
|
||||||
/// 创建带有默认控制台输出的思考状态管理器
|
/// 创建 LLM 流式使用的共享思考状态。
|
||||||
/// 在思考开始时打印 "thinking...",在思考结束时清除该标识
|
/// 进度显示由 AI 生成 spinner 负责(issue 21),此处不再附加控制台输出。
|
||||||
pub fn create_console_thinking_state() -> SharedThinkingState {
|
pub fn create_console_thinking_state() -> SharedThinkingState {
|
||||||
Arc::new(
|
Arc::new(ThinkingStateManager::new())
|
||||||
ThinkingStateManager::new()
|
|
||||||
.on_thinking_start(|| {
|
|
||||||
eprint!("\rthinking...");
|
|
||||||
})
|
|
||||||
.on_thinking_end(|| {
|
|
||||||
eprint!("\r \r");
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
44
src/main.rs
44
src/main.rs
@@ -1,5 +1,3 @@
|
|||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -22,7 +20,7 @@ use quicommit::commands::{
|
|||||||
#[command(propagate_version = true)]
|
#[command(propagate_version = true)]
|
||||||
#[command(arg_required_else_help = true)]
|
#[command(arg_required_else_help = true)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
/// Enable verbose output
|
/// Increase verbosity (-v: info, -vv: debug, -vvv: trace)
|
||||||
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
||||||
verbose: u8,
|
verbose: u8,
|
||||||
|
|
||||||
@@ -31,9 +29,17 @@ struct Cli {
|
|||||||
config: Option<String>,
|
config: Option<String>,
|
||||||
|
|
||||||
/// Disable colored output
|
/// Disable colored output
|
||||||
#[arg(long, global = true, env = "NO_COLOR")]
|
#[arg(long, global = true)]
|
||||||
no_color: bool,
|
no_color: bool,
|
||||||
|
|
||||||
|
/// Force emoji decorations on (overrides config)
|
||||||
|
#[arg(long, global = true, conflicts_with = "no_emoji")]
|
||||||
|
emoji: bool,
|
||||||
|
|
||||||
|
/// Disable emoji/symbol decorations
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
no_emoji: bool,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Commands,
|
command: Commands,
|
||||||
}
|
}
|
||||||
@@ -73,6 +79,31 @@ enum Commands {
|
|||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
// Apply the global color decision before any output is produced.
|
||||||
|
// --no-color disables colors; NO_COLOR follows the no-color.org spec
|
||||||
|
// (any presence, regardless of value, disables color).
|
||||||
|
let no_color = cli.no_color || std::env::var_os("NO_COLOR").is_some();
|
||||||
|
colored::control::set_override(!no_color);
|
||||||
|
|
||||||
|
// Resolve the decoration switch (issue 14): explicit flag > config >
|
||||||
|
// default; --no-color disables decorations too (ADR-0003).
|
||||||
|
let emoji_from_config = cli
|
||||||
|
.config
|
||||||
|
.as_deref()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| quicommit::config::AppConfig::default_path().ok())
|
||||||
|
.and_then(|path| quicommit::config::AppConfig::load(&path).ok())
|
||||||
|
.map(|config| config.output.emoji)
|
||||||
|
.unwrap_or(true);
|
||||||
|
let emoji_enabled = if no_color || cli.no_emoji {
|
||||||
|
false
|
||||||
|
} else if cli.emoji {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
emoji_from_config
|
||||||
|
};
|
||||||
|
quicommit::utils::set_emoji_enabled(emoji_enabled);
|
||||||
|
|
||||||
let log_level = match cli.verbose {
|
let log_level = match cli.verbose {
|
||||||
0 => "warn",
|
0 => "warn",
|
||||||
1 => "info",
|
1 => "info",
|
||||||
@@ -80,8 +111,11 @@ async fn main() -> Result<()> {
|
|||||||
_ => "trace",
|
_ => "trace",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// RUST_LOG takes precedence when set; otherwise -v decides (issue 23).
|
||||||
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(log_level));
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(log_level)
|
.with_env_filter(filter)
|
||||||
.with_target(false)
|
.with_target(false)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
|
|||||||
@@ -232,9 +232,7 @@ impl KeyringManager {
|
|||||||
"Keyring is available".to_string()
|
"Keyring is available".to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyringStatus::Unavailable => {
|
KeyringStatus::Unavailable => "Keyring is not available on this system.".to_string(),
|
||||||
"Keyring is not available. Set QUICOMMIT_API_KEY environment variable.".to_string()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
156
src/utils/mod.rs
156
src/utils/mod.rs
@@ -6,48 +6,144 @@ pub mod validators;
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use colored::Colorize;
|
use colored::Colorize;
|
||||||
use std::io::{self, Write};
|
use std::io;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Process-global decoration switch (issue 14).
|
||||||
|
static EMOJI_ENABLED: OnceLock<bool> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Set whether emoji/symbol decorations are shown. Called once from `main`
|
||||||
|
/// after resolving config + flags.
|
||||||
|
pub fn set_emoji_enabled(enabled: bool) {
|
||||||
|
let _ = EMOJI_ENABLED.set(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether emoji/symbol decorations are shown.
|
||||||
|
pub fn emoji_enabled() -> bool {
|
||||||
|
*EMOJI_ENABLED.get_or_init(|| true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Styled decoration prefix for status lines; empty when decorations are off
|
||||||
|
/// (ADR-0003: message text never embeds decorations).
|
||||||
|
pub fn success_prefix() -> colored::ColoredString {
|
||||||
|
if emoji_enabled() {
|
||||||
|
"✓".green().bold()
|
||||||
|
} else {
|
||||||
|
"".normal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error_prefix() -> colored::ColoredString {
|
||||||
|
if emoji_enabled() {
|
||||||
|
"✗".red().bold()
|
||||||
|
} else {
|
||||||
|
"".normal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn warning_prefix() -> colored::ColoredString {
|
||||||
|
if emoji_enabled() {
|
||||||
|
"⚠".yellow().bold()
|
||||||
|
} else {
|
||||||
|
"".normal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn info_prefix() -> colored::ColoredString {
|
||||||
|
if emoji_enabled() {
|
||||||
|
"ℹ".blue().bold()
|
||||||
|
} else {
|
||||||
|
"".normal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn progress_prefix() -> colored::ColoredString {
|
||||||
|
if emoji_enabled() {
|
||||||
|
"→".cyan()
|
||||||
|
} else {
|
||||||
|
"".normal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_decorated(prefix: colored::ColoredString, msg: &str, to_stderr: bool) {
|
||||||
|
if emoji_enabled() {
|
||||||
|
if to_stderr {
|
||||||
|
eprintln!("{} {}", prefix, msg);
|
||||||
|
} else {
|
||||||
|
println!("{} {}", prefix, msg);
|
||||||
|
}
|
||||||
|
} else if to_stderr {
|
||||||
|
eprintln!("{}", msg);
|
||||||
|
} else {
|
||||||
|
println!("{}", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Print success message
|
/// Print success message
|
||||||
pub fn print_success(msg: &str) {
|
pub fn print_success(msg: &str) {
|
||||||
println!("{} {}", "✓".green().bold(), msg);
|
print_decorated(success_prefix(), msg, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Print error message
|
/// Print error message to stderr
|
||||||
pub fn print_error(msg: &str) {
|
pub fn print_error(msg: &str) {
|
||||||
eprintln!("{} {}", "✗".red().bold(), msg);
|
print_decorated(error_prefix(), msg, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Print warning message
|
/// Print warning message
|
||||||
pub fn print_warning(msg: &str) {
|
pub fn print_warning(msg: &str) {
|
||||||
println!("{} {}", "⚠".yellow().bold(), msg);
|
print_decorated(warning_prefix(), msg, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print warning message to stderr (stdout must stay clean, e.g. exports)
|
||||||
|
pub fn eprint_warning(msg: &str) {
|
||||||
|
print_decorated(warning_prefix(), msg, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Print info message
|
/// Print info message
|
||||||
pub fn print_info(msg: &str) {
|
pub fn print_info(msg: &str) {
|
||||||
println!("{} {}", "ℹ".blue().bold(), msg);
|
print_decorated(info_prefix(), msg, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Confirm action with user
|
/// Print progress/status message (→ prefix when decorations are on)
|
||||||
pub fn confirm(prompt: &str) -> Result<bool> {
|
pub fn print_progress(msg: &str) {
|
||||||
print!("{} [y/N] ", prompt);
|
print_decorated(progress_prefix(), msg, false);
|
||||||
io::stdout().flush()?;
|
|
||||||
|
|
||||||
let mut input = String::new();
|
|
||||||
io::stdin().read_line(&mut input)?;
|
|
||||||
|
|
||||||
Ok(input.trim().to_lowercase().starts_with('y'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get user input
|
/// Progress spinner for long-running operations (issue 21).
|
||||||
pub fn input(prompt: &str) -> Result<String> {
|
/// Degrades to a static line when stdout is not a terminal or when
|
||||||
print!("{}: ", prompt);
|
/// decorations are disabled.
|
||||||
io::stdout().flush()?;
|
pub struct Spinner {
|
||||||
|
bar: Option<indicatif::ProgressBar>,
|
||||||
|
}
|
||||||
|
|
||||||
let mut input = String::new();
|
impl Spinner {
|
||||||
io::stdin().read_line(&mut input)?;
|
pub fn start(msg: &str) -> Self {
|
||||||
|
if emoji_enabled() && std::io::IsTerminal::is_terminal(&io::stdout()) {
|
||||||
|
let bar = indicatif::ProgressBar::new_spinner();
|
||||||
|
bar.set_message(msg.to_string());
|
||||||
|
bar.enable_steady_tick(Duration::from_millis(100));
|
||||||
|
Self { bar: Some(bar) }
|
||||||
|
} else {
|
||||||
|
println!("{}", msg);
|
||||||
|
Self { bar: None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(input.trim().to_string())
|
/// Stop the spinner, erasing it from the terminal (no completion line).
|
||||||
|
pub fn finish_clear(&self) {
|
||||||
|
if let Some(bar) = &self.bar {
|
||||||
|
bar.finish_and_clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the spinner and print a completion line.
|
||||||
|
pub fn finish_with(&self, msg: &str) {
|
||||||
|
if let Some(bar) = &self.bar {
|
||||||
|
bar.finish_and_clear();
|
||||||
|
}
|
||||||
|
println!("{}", msg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get password input (hidden)
|
/// Get password input (hidden)
|
||||||
@@ -59,19 +155,3 @@ pub fn password_input(prompt: &str) -> Result<String> {
|
|||||||
.interact()
|
.interact()
|
||||||
.context("Failed to read password")
|
.context("Failed to read password")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if running in a terminal
|
|
||||||
pub fn is_terminal() -> bool {
|
|
||||||
std::io::IsTerminal::is_terminal(&io::stdout())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format duration in human-readable format
|
|
||||||
pub fn format_duration(secs: u64) -> String {
|
|
||||||
if secs < 60 {
|
|
||||||
format!("{}s", secs)
|
|
||||||
} else if secs < 3600 {
|
|
||||||
format!("{}m {}s", secs / 60, secs % 60)
|
|
||||||
} else {
|
|
||||||
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ fn test_stage_all_removes_ignored_tracked_files() {
|
|||||||
write_file(repo_path, "__pycache__/foo.pyc", "modified bytecode");
|
write_file(repo_path, "__pycache__/foo.pyc", "modified bytecode");
|
||||||
|
|
||||||
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
|
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
|
||||||
let removed = repo.stage_all().expect("stage_all should succeed");
|
let messages = quicommit::i18n::Messages::new(quicommit::config::Language::English);
|
||||||
|
let removed = repo.stage_all(&messages).expect("stage_all should succeed");
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
removed.iter().any(|f| f == "__pycache__/foo.pyc"),
|
removed.iter().any(|f| f == "__pycache__/foo.pyc"),
|
||||||
@@ -142,7 +143,8 @@ fn test_stage_all_no_ignored_files_returns_empty() {
|
|||||||
write_file(repo_path, "src/main.rs", "fn main() {}");
|
write_file(repo_path, "src/main.rs", "fn main() {}");
|
||||||
|
|
||||||
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
|
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
|
||||||
let removed = repo.stage_all().expect("stage_all should succeed");
|
let messages = quicommit::i18n::Messages::new(quicommit::config::Language::English);
|
||||||
|
let removed = repo.stage_all(&messages).expect("stage_all should succeed");
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
removed.is_empty(),
|
removed.is_empty(),
|
||||||
|
|||||||
@@ -258,6 +258,190 @@ mod config_command {
|
|||||||
.success()
|
.success()
|
||||||
.stdout(predicate::str::contains("config.toml"));
|
.stdout(predicate::str::contains("config.toml"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_color_disables_ansi() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let config_path = temp_dir.path().join("config.toml");
|
||||||
|
|
||||||
|
let mut cmd = cargo_bin_cmd!("quicommit");
|
||||||
|
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||||
|
cmd.assert().success();
|
||||||
|
|
||||||
|
let mut cmd = cargo_bin_cmd!("quicommit");
|
||||||
|
cmd.args(&[
|
||||||
|
"config",
|
||||||
|
"show",
|
||||||
|
"--no-color",
|
||||||
|
"--config",
|
||||||
|
config_path.to_str().unwrap(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let output = cmd.output().unwrap();
|
||||||
|
assert!(output.status.success());
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(
|
||||||
|
!stdout.contains("\x1b["),
|
||||||
|
"ANSI color codes present despite --no-color"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_emoji_switch_precedence() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let config_path = temp_dir.path().join("config.toml");
|
||||||
|
|
||||||
|
let mut cmd = cargo_bin_cmd!("quicommit");
|
||||||
|
cmd.env_remove("NO_COLOR");
|
||||||
|
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||||
|
cmd.assert().success();
|
||||||
|
|
||||||
|
let set_format = |extra: &[&str], cfg: &std::path::Path| {
|
||||||
|
let mut c = cargo_bin_cmd!("quicommit");
|
||||||
|
c.env_remove("NO_COLOR");
|
||||||
|
c.args(&["config", "set", "commit.format", "conventional"])
|
||||||
|
.args(extra)
|
||||||
|
.args(&["--config", cfg.to_str().unwrap()]);
|
||||||
|
c.output().unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default (config true): decorations shown
|
||||||
|
let out = set_format(&[], &config_path);
|
||||||
|
assert!(out.status.success());
|
||||||
|
assert!(String::from_utf8_lossy(&out.stdout).contains("✓"));
|
||||||
|
|
||||||
|
// Config output.emoji=false: no decorations
|
||||||
|
let mut cmd = cargo_bin_cmd!("quicommit");
|
||||||
|
cmd.env_remove("NO_COLOR");
|
||||||
|
cmd.args(&[
|
||||||
|
"config",
|
||||||
|
"set",
|
||||||
|
"output.emoji",
|
||||||
|
"false",
|
||||||
|
"--config",
|
||||||
|
config_path.to_str().unwrap(),
|
||||||
|
]);
|
||||||
|
cmd.assert().success();
|
||||||
|
|
||||||
|
let out = set_format(&[], &config_path);
|
||||||
|
assert!(out.status.success());
|
||||||
|
assert!(!String::from_utf8_lossy(&out.stdout).contains("✓"));
|
||||||
|
|
||||||
|
// Explicit --emoji overrides config false
|
||||||
|
let out = set_format(&["--emoji"], &config_path);
|
||||||
|
assert!(out.status.success());
|
||||||
|
assert!(String::from_utf8_lossy(&out.stdout).contains("✓"));
|
||||||
|
|
||||||
|
// Config back to true, then --no-emoji overrides
|
||||||
|
let mut cmd = cargo_bin_cmd!("quicommit");
|
||||||
|
cmd.env_remove("NO_COLOR");
|
||||||
|
cmd.args(&[
|
||||||
|
"config",
|
||||||
|
"set",
|
||||||
|
"output.emoji",
|
||||||
|
"true",
|
||||||
|
"--config",
|
||||||
|
config_path.to_str().unwrap(),
|
||||||
|
]);
|
||||||
|
cmd.assert().success();
|
||||||
|
|
||||||
|
let out = set_format(&["--no-emoji"], &config_path);
|
||||||
|
assert!(out.status.success());
|
||||||
|
assert!(!String::from_utf8_lossy(&out.stdout).contains("✓"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_chinese_output_smoke() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let repo_path = temp_dir.path().to_path_buf();
|
||||||
|
setup_test_repo_with_file(&repo_path, "test.txt", "content");
|
||||||
|
|
||||||
|
let config_path = repo_path.join("config.toml");
|
||||||
|
init_quicommit(&repo_path, &config_path);
|
||||||
|
|
||||||
|
let mut cmd = cargo_bin_cmd!("quicommit");
|
||||||
|
cmd.env_remove("NO_COLOR");
|
||||||
|
cmd.args(&[
|
||||||
|
"config",
|
||||||
|
"set",
|
||||||
|
"language.output_language",
|
||||||
|
"zh",
|
||||||
|
"--config",
|
||||||
|
config_path.to_str().unwrap(),
|
||||||
|
]);
|
||||||
|
cmd.assert().success();
|
||||||
|
|
||||||
|
let mut cmd = cargo_bin_cmd!("quicommit");
|
||||||
|
cmd.env_remove("NO_COLOR");
|
||||||
|
cmd.args(&[
|
||||||
|
"commit",
|
||||||
|
"--manual",
|
||||||
|
"-m",
|
||||||
|
"feat: 中文冒烟",
|
||||||
|
"--dry-run",
|
||||||
|
"--yes",
|
||||||
|
"--config",
|
||||||
|
config_path.to_str().unwrap(),
|
||||||
|
])
|
||||||
|
.current_dir(&repo_path);
|
||||||
|
|
||||||
|
cmd.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("试运行"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rust_log_env() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let config_path = temp_dir.path().join("config.toml");
|
||||||
|
|
||||||
|
let mut cmd = cargo_bin_cmd!("quicommit");
|
||||||
|
cmd.env_remove("NO_COLOR");
|
||||||
|
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||||
|
cmd.assert().success();
|
||||||
|
|
||||||
|
let mut cmd = cargo_bin_cmd!("quicommit");
|
||||||
|
cmd.env_remove("NO_COLOR");
|
||||||
|
cmd.env("RUST_LOG", "debug");
|
||||||
|
cmd.args(&["config", "path", "--config", config_path.to_str().unwrap()]);
|
||||||
|
|
||||||
|
cmd.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("Starting quicommit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auto_generate_defaults_true() {
|
||||||
|
// Documents the unified --yes semantics (issue 05): with default
|
||||||
|
// config, --yes still AI-generates; deterministic output needs
|
||||||
|
// --no-generate (changelog) or -m (tag).
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let config_path = temp_dir.path().join("config.toml");
|
||||||
|
|
||||||
|
let mut cmd = cargo_bin_cmd!("quicommit");
|
||||||
|
cmd.env_remove("NO_COLOR");
|
||||||
|
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||||
|
cmd.assert().success();
|
||||||
|
|
||||||
|
for key in [
|
||||||
|
"commit.auto_generate",
|
||||||
|
"tag.auto_generate",
|
||||||
|
"changelog.auto_generate",
|
||||||
|
] {
|
||||||
|
let mut cmd = cargo_bin_cmd!("quicommit");
|
||||||
|
cmd.env_remove("NO_COLOR");
|
||||||
|
cmd.args(&[
|
||||||
|
"config",
|
||||||
|
"get",
|
||||||
|
key,
|
||||||
|
"--config",
|
||||||
|
config_path.to_str().unwrap(),
|
||||||
|
]);
|
||||||
|
cmd.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("true"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mod commit_command {
|
mod commit_command {
|
||||||
@@ -438,6 +622,8 @@ mod tag_command {
|
|||||||
"tag",
|
"tag",
|
||||||
"--name",
|
"--name",
|
||||||
"v0.1.0",
|
"v0.1.0",
|
||||||
|
"-m",
|
||||||
|
"Release v0.1.0",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
"--yes",
|
"--yes",
|
||||||
"--config",
|
"--config",
|
||||||
@@ -469,6 +655,8 @@ mod tag_command {
|
|||||||
"--think",
|
"--think",
|
||||||
"--name",
|
"--name",
|
||||||
"v0.2.0",
|
"v0.2.0",
|
||||||
|
"-m",
|
||||||
|
"Release v0.2.0",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
"--yes",
|
"--yes",
|
||||||
"--config",
|
"--config",
|
||||||
@@ -528,6 +716,7 @@ mod changelog_command {
|
|||||||
"changelog",
|
"changelog",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
"--yes",
|
"--yes",
|
||||||
|
"--no-generate",
|
||||||
"--config",
|
"--config",
|
||||||
config_path.to_str().unwrap(),
|
config_path.to_str().unwrap(),
|
||||||
])
|
])
|
||||||
|
|||||||
Reference in New Issue
Block a user