Compare commits
5 Commits
88324c21c2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
04410ea9e7
|
|||
|
a514cdc69f
|
|||
|
e822ba1f54
|
|||
|
3c925d8268
|
|||
|
c9073ff4a7
|
10
CHANGELOG.md
10
CHANGELOG.md
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.7] - 2026-02-14
|
||||
|
||||
### 🐞 错误修复
|
||||
- 修复 `changelog` 命令默认覆盖文件的问题,现改为智能追加新版本条目到头部之后
|
||||
|
||||
### 🔧 其他变更
|
||||
- 清理 `formatter.rs` 中未使用的函数(`format_commit_date`、`format_changelog_date`、`format_tag_name`、`truncate`、`format_markdown_list`、`format_changelog_section`、`format_git_config_key`)
|
||||
- 清理 `validators.rs` 中未使用的函数(`validate_ssh_key`)
|
||||
- 移除 `changelog` 命令的 `--prepend` 参数(默认行为已改为追加)
|
||||
|
||||
## [0.1.4] - 2026-02-01
|
||||
|
||||
### ✨ 新功能
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "quicommit"
|
||||
version = "0.1.4"
|
||||
version = "0.1.7"
|
||||
edition = "2024"
|
||||
authors = ["Sidney Zhang <zly@lyzhang.me>"]
|
||||
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation(alpha version)"
|
||||
|
||||
@@ -13,13 +13,14 @@ use crate::i18n::{Messages, translate_changelog_category};
|
||||
|
||||
/// Generate changelog
|
||||
#[derive(Parser)]
|
||||
#[command(disable_version_flag = true, disable_help_flag = false)]
|
||||
pub struct ChangelogCommand {
|
||||
/// Output file path
|
||||
#[arg(short, long)]
|
||||
output: Option<PathBuf>,
|
||||
|
||||
/// Version to generate changelog for
|
||||
#[arg(short, long)]
|
||||
#[arg(long)]
|
||||
version: Option<String>,
|
||||
|
||||
/// Generate from specific tag
|
||||
@@ -38,10 +39,6 @@ pub struct ChangelogCommand {
|
||||
#[arg(short, long)]
|
||||
generate: bool,
|
||||
|
||||
/// Prepend to existing changelog
|
||||
#[arg(short, long)]
|
||||
prepend: bool,
|
||||
|
||||
/// Include commit hashes
|
||||
#[arg(long)]
|
||||
include_hashes: bool,
|
||||
@@ -51,7 +48,7 @@ pub struct ChangelogCommand {
|
||||
include_authors: bool,
|
||||
|
||||
/// Format (keep-a-changelog, github-releases)
|
||||
#[arg(short, long)]
|
||||
#[arg(long)]
|
||||
format: Option<String>,
|
||||
|
||||
/// Dry run (output to stdout)
|
||||
@@ -64,9 +61,13 @@ pub struct ChangelogCommand {
|
||||
}
|
||||
|
||||
impl ChangelogCommand {
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
|
||||
let repo = find_repo(std::env::current_dir()?.as_path())?;
|
||||
let manager = ConfigManager::new()?;
|
||||
let manager = if let Some(ref path) = config_path {
|
||||
ConfigManager::with_path(path)?
|
||||
} else {
|
||||
ConfigManager::new()?
|
||||
};
|
||||
let config = manager.config();
|
||||
let language = manager.get_language().unwrap_or(Language::English);
|
||||
let messages = Messages::new(language);
|
||||
@@ -157,13 +158,34 @@ impl ChangelogCommand {
|
||||
}
|
||||
}
|
||||
|
||||
// Write to file
|
||||
if self.prepend && output_path.exists() {
|
||||
// Write to file (always prepend to preserve history)
|
||||
if output_path.exists() {
|
||||
let existing = std::fs::read_to_string(&output_path)?;
|
||||
let new_content = format!("{}\n{}", changelog, existing);
|
||||
let new_content = if existing.is_empty() {
|
||||
format!("# Changelog\n\n{}", changelog)
|
||||
} else {
|
||||
let lines: Vec<&str> = existing.lines().collect();
|
||||
let mut header_end = 0;
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if i == 0 && line.starts_with('#') {
|
||||
header_end = i + 1;
|
||||
} else if line.trim().is_empty() {
|
||||
header_end = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let header = lines[..header_end].join("\n");
|
||||
let rest = lines[header_end..].join("\n");
|
||||
|
||||
format!("{}\n{}\n{}", header, changelog, rest)
|
||||
};
|
||||
std::fs::write(&output_path, new_content)?;
|
||||
} else {
|
||||
std::fs::write(&output_path, changelog)?;
|
||||
let content = format!("# Changelog\n\n{}", changelog);
|
||||
std::fs::write(&output_path, content)?;
|
||||
}
|
||||
|
||||
println!("{} {:?}", messages.changelog_written(), output_path);
|
||||
|
||||
@@ -2,6 +2,7 @@ use anyhow::{bail, Context, Result};
|
||||
use clap::Parser;
|
||||
use colored::Colorize;
|
||||
use dialoguer::{Confirm, Input, Select};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::{Language, manager::ConfigManager};
|
||||
use crate::config::CommitFormat;
|
||||
@@ -84,12 +85,16 @@ pub struct CommitCommand {
|
||||
}
|
||||
|
||||
impl CommitCommand {
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
|
||||
// Find git repository
|
||||
let repo = find_repo(std::env::current_dir()?.as_path())?;
|
||||
|
||||
// Load configuration
|
||||
let manager = ConfigManager::new()?;
|
||||
let manager = if let Some(ref path) = config_path {
|
||||
ConfigManager::with_path(path)?
|
||||
} else {
|
||||
ConfigManager::new()?
|
||||
};
|
||||
let config = manager.config();
|
||||
let language = manager.get_language().unwrap_or(Language::English);
|
||||
let messages = Messages::new(language);
|
||||
|
||||
@@ -2,6 +2,7 @@ use anyhow::{bail, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use colored::Colorize;
|
||||
use dialoguer::{Confirm, Input, Select};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::{Language, manager::ConfigManager};
|
||||
use crate::config::CommitFormat;
|
||||
@@ -191,43 +192,60 @@ enum ConfigSubcommand {
|
||||
|
||||
/// Test LLM connection
|
||||
TestLlm,
|
||||
|
||||
/// Show config file path
|
||||
Path,
|
||||
}
|
||||
|
||||
impl ConfigCommand {
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
|
||||
match &self.command {
|
||||
Some(ConfigSubcommand::Show) => self.show_config().await,
|
||||
Some(ConfigSubcommand::List) => self.list_config().await,
|
||||
Some(ConfigSubcommand::Edit) => self.edit_config().await,
|
||||
Some(ConfigSubcommand::Set { key, value }) => self.set_value(key, value).await,
|
||||
Some(ConfigSubcommand::Get { key }) => self.get_value(key).await,
|
||||
Some(ConfigSubcommand::SetLlm { provider }) => self.set_llm(provider.as_deref()).await,
|
||||
Some(ConfigSubcommand::SetOpenAiKey { key }) => self.set_openai_key(key).await,
|
||||
Some(ConfigSubcommand::SetAnthropicKey { key }) => self.set_anthropic_key(key).await,
|
||||
Some(ConfigSubcommand::SetKimiKey { key }) => self.set_kimi_key(key).await,
|
||||
Some(ConfigSubcommand::SetDeepSeekKey { key }) => self.set_deepseek_key(key).await,
|
||||
Some(ConfigSubcommand::SetOpenRouterKey { key }) => self.set_openrouter_key(key).await,
|
||||
Some(ConfigSubcommand::SetOllama { url, model }) => self.set_ollama(url.as_deref(), model.as_deref()).await,
|
||||
Some(ConfigSubcommand::SetKimi { base_url, model }) => self.set_kimi(base_url.as_deref(), model.as_deref()).await,
|
||||
Some(ConfigSubcommand::SetDeepSeek { base_url, model }) => self.set_deepseek(base_url.as_deref(), model.as_deref()).await,
|
||||
Some(ConfigSubcommand::SetOpenRouter { base_url, model }) => self.set_openrouter(base_url.as_deref(), model.as_deref()).await,
|
||||
Some(ConfigSubcommand::SetCommitFormat { format }) => self.set_commit_format(format).await,
|
||||
Some(ConfigSubcommand::SetVersionPrefix { prefix }) => self.set_version_prefix(prefix).await,
|
||||
Some(ConfigSubcommand::SetChangelogPath { path }) => self.set_changelog_path(path).await,
|
||||
Some(ConfigSubcommand::SetLanguage { language }) => self.set_language(language.as_deref()).await,
|
||||
Some(ConfigSubcommand::SetKeepTypesEnglish { keep }) => self.set_keep_types_english(*keep).await,
|
||||
Some(ConfigSubcommand::SetKeepChangelogTypesEnglish { keep }) => self.set_keep_changelog_types_english(*keep).await,
|
||||
Some(ConfigSubcommand::Reset { force }) => self.reset(*force).await,
|
||||
Some(ConfigSubcommand::Export { output }) => self.export_config(output.as_deref()).await,
|
||||
Some(ConfigSubcommand::Import { file }) => self.import_config(file).await,
|
||||
Some(ConfigSubcommand::ListModels) => self.list_models().await,
|
||||
Some(ConfigSubcommand::TestLlm) => self.test_llm().await,
|
||||
None => self.show_config().await,
|
||||
Some(ConfigSubcommand::Show) => self.show_config(&config_path).await,
|
||||
Some(ConfigSubcommand::List) => self.list_config(&config_path).await,
|
||||
Some(ConfigSubcommand::Edit) => self.edit_config(&config_path).await,
|
||||
Some(ConfigSubcommand::Set { key, value }) => self.set_value(key, value, &config_path).await,
|
||||
Some(ConfigSubcommand::Get { key }) => self.get_value(key, &config_path).await,
|
||||
Some(ConfigSubcommand::SetLlm { provider }) => self.set_llm(provider.as_deref(), &config_path).await,
|
||||
Some(ConfigSubcommand::SetOpenAiKey { key }) => self.set_openai_key(key, &config_path).await,
|
||||
Some(ConfigSubcommand::SetAnthropicKey { key }) => self.set_anthropic_key(key, &config_path).await,
|
||||
Some(ConfigSubcommand::SetKimiKey { key }) => self.set_kimi_key(key, &config_path).await,
|
||||
Some(ConfigSubcommand::SetDeepSeekKey { key }) => self.set_deepseek_key(key, &config_path).await,
|
||||
Some(ConfigSubcommand::SetOpenRouterKey { key }) => self.set_openrouter_key(key, &config_path).await,
|
||||
Some(ConfigSubcommand::SetOllama { url, model }) => self.set_ollama(url.as_deref(), model.as_deref(), &config_path).await,
|
||||
Some(ConfigSubcommand::SetKimi { base_url, model }) => self.set_kimi(base_url.as_deref(), model.as_deref(), &config_path).await,
|
||||
Some(ConfigSubcommand::SetDeepSeek { base_url, model }) => self.set_deepseek(base_url.as_deref(), model.as_deref(), &config_path).await,
|
||||
Some(ConfigSubcommand::SetOpenRouter { base_url, model }) => self.set_openrouter(base_url.as_deref(), model.as_deref(), &config_path).await,
|
||||
Some(ConfigSubcommand::SetCommitFormat { format }) => self.set_commit_format(format, &config_path).await,
|
||||
Some(ConfigSubcommand::SetVersionPrefix { prefix }) => self.set_version_prefix(prefix, &config_path).await,
|
||||
Some(ConfigSubcommand::SetChangelogPath { path }) => self.set_changelog_path(path, &config_path).await,
|
||||
Some(ConfigSubcommand::SetLanguage { language }) => self.set_language(language.as_deref(), &config_path).await,
|
||||
Some(ConfigSubcommand::SetKeepTypesEnglish { keep }) => self.set_keep_types_english(*keep, &config_path).await,
|
||||
Some(ConfigSubcommand::SetKeepChangelogTypesEnglish { keep }) => self.set_keep_changelog_types_english(*keep, &config_path).await,
|
||||
Some(ConfigSubcommand::Reset { force }) => self.reset(*force, &config_path).await,
|
||||
Some(ConfigSubcommand::Export { output }) => self.export_config(output.as_deref(), &config_path).await,
|
||||
Some(ConfigSubcommand::Import { file }) => self.import_config(file, &config_path).await,
|
||||
Some(ConfigSubcommand::ListModels) => self.list_models(&config_path).await,
|
||||
Some(ConfigSubcommand::TestLlm) => self.test_llm(&config_path).await,
|
||||
Some(ConfigSubcommand::Path) => self.show_path(&config_path).await,
|
||||
None => self.show_config(&config_path).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn show_config(&self) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
fn get_manager(&self, config_path: &Option<PathBuf>) -> Result<ConfigManager> {
|
||||
match config_path {
|
||||
Some(path) => ConfigManager::with_path(path),
|
||||
None => ConfigManager::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn show_path(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
println!("{}", manager.path().display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn show_config(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
let config = manager.config();
|
||||
|
||||
println!("{}", "\nQuiCommit Configuration".bold());
|
||||
@@ -306,8 +324,8 @@ impl ConfigCommand {
|
||||
}
|
||||
|
||||
/// List all configuration information with masked API keys
|
||||
async fn list_config(&self) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn list_config(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
let config = manager.config();
|
||||
|
||||
println!("{}", "\nQuiCommit Configuration".bold());
|
||||
@@ -404,15 +422,15 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn edit_config(&self) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn edit_config(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
crate::utils::editor::edit_file(manager.path())?;
|
||||
println!("{} Configuration updated", "✓".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_value(&self, key: &str, value: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_value(&self, key: &str, value: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
match key {
|
||||
"llm.provider" => manager.set_llm_provider(value.to_string()),
|
||||
@@ -450,8 +468,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_value(&self, key: &str) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn get_value(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
let config = manager.config();
|
||||
|
||||
let value = match key {
|
||||
@@ -469,8 +487,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_llm(&self, provider: Option<&str>) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_llm(&self, provider: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
let provider = if let Some(p) = provider {
|
||||
p.to_string()
|
||||
@@ -602,48 +620,48 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_openai_key(&self, key: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_openai_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.set_openai_api_key(key.to_string());
|
||||
manager.save()?;
|
||||
println!("{} OpenAI API key set", "✓".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_anthropic_key(&self, key: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_anthropic_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.set_anthropic_api_key(key.to_string());
|
||||
manager.save()?;
|
||||
println!("{} Anthropic API key set", "✓".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_kimi_key(&self, key: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_kimi_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.set_kimi_api_key(key.to_string());
|
||||
manager.save()?;
|
||||
println!("{} Kimi API key set", "✓".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_deepseek_key(&self, key: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_deepseek_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.set_deepseek_api_key(key.to_string());
|
||||
manager.save()?;
|
||||
println!("{} DeepSeek API key set", "✓".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_openrouter_key(&self, key: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_openrouter_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.set_openrouter_api_key(key.to_string());
|
||||
manager.save()?;
|
||||
println!("{} OpenRouter API key set", "✓".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_kimi(&self, base_url: Option<&str>, model: Option<&str>) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_kimi(&self, base_url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
if let Some(url) = base_url {
|
||||
manager.set_kimi_base_url(url.to_string());
|
||||
@@ -657,8 +675,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_deepseek(&self, base_url: Option<&str>, model: Option<&str>) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_deepseek(&self, base_url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
if let Some(url) = base_url {
|
||||
manager.set_deepseek_base_url(url.to_string());
|
||||
@@ -672,8 +690,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_openrouter(&self, base_url: Option<&str>, model: Option<&str>) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_openrouter(&self, base_url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
if let Some(url) = base_url {
|
||||
manager.set_openrouter_base_url(url.to_string());
|
||||
@@ -687,8 +705,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_ollama(&self, url: Option<&str>, model: Option<&str>) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_ollama(&self, url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
if let Some(u) = url {
|
||||
manager.config_mut().llm.ollama.url = u.to_string();
|
||||
@@ -702,8 +720,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_commit_format(&self, format: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_commit_format(&self, format: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
let format = match format {
|
||||
"conventional" => CommitFormat::Conventional,
|
||||
@@ -717,24 +735,24 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_version_prefix(&self, prefix: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_version_prefix(&self, prefix: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.set_version_prefix(prefix.to_string());
|
||||
manager.save()?;
|
||||
println!("{} Set version prefix to '{}'", "✓".green(), prefix);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_changelog_path(&self, path: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_changelog_path(&self, path: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.set_changelog_path(path.to_string());
|
||||
manager.save()?;
|
||||
println!("{} Set changelog path to {}", "✓".green(), path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_language(&self, language: Option<&str>) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_language(&self, language: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
let language_code = if let Some(lang) = language {
|
||||
lang.to_string()
|
||||
@@ -763,8 +781,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_keep_types_english(&self, keep: bool) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_keep_types_english(&self, keep: bool, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.set_keep_types_english(keep);
|
||||
manager.save()?;
|
||||
let status = if keep { "enabled" } else { "disabled" };
|
||||
@@ -772,8 +790,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_keep_changelog_types_english(&self, keep: bool) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_keep_changelog_types_english(&self, keep: bool, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.set_keep_changelog_types_english(keep);
|
||||
manager.save()?;
|
||||
let status = if keep { "enabled" } else { "disabled" };
|
||||
@@ -781,7 +799,7 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reset(&self, force: bool) -> Result<()> {
|
||||
async fn reset(&self, force: bool, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
if !force {
|
||||
let confirm = Confirm::new()
|
||||
.with_prompt("Are you sure you want to reset all configuration?")
|
||||
@@ -794,7 +812,7 @@ impl ConfigCommand {
|
||||
}
|
||||
}
|
||||
|
||||
let mut manager = ConfigManager::new()?;
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.reset();
|
||||
manager.save()?;
|
||||
|
||||
@@ -802,8 +820,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn export_config(&self, output: Option<&str>) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn export_config(&self, output: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
let toml = manager.export()?;
|
||||
|
||||
if let Some(path) = output {
|
||||
@@ -816,10 +834,10 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn import_config(&self, file: &str) -> Result<()> {
|
||||
async fn import_config(&self, file: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let toml = std::fs::read_to_string(file)?;
|
||||
|
||||
let mut manager = ConfigManager::new()?;
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.import(&toml)?;
|
||||
manager.save()?;
|
||||
|
||||
@@ -827,8 +845,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_models(&self) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn list_models(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
let config = manager.config();
|
||||
|
||||
match config.llm.provider.as_str() {
|
||||
@@ -984,8 +1002,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn test_llm(&self) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn test_llm(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
let config = manager.config();
|
||||
|
||||
println!("Testing LLM connection ({})...", config.llm.provider.cyan());
|
||||
|
||||
@@ -2,6 +2,7 @@ use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use colored::Colorize;
|
||||
use dialoguer::{Confirm, Input, Select};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::{GitProfile, Language};
|
||||
use crate::config::manager::ConfigManager;
|
||||
@@ -22,12 +23,13 @@ pub struct InitCommand {
|
||||
}
|
||||
|
||||
impl InitCommand {
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
// Start with English messages for initialization
|
||||
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
|
||||
let messages = Messages::new(Language::English);
|
||||
println!("{}", messages.initializing().bold().cyan());
|
||||
|
||||
let config_path = crate::config::AppConfig::default_path()?;
|
||||
let config_path = config_path.unwrap_or_else(|| {
|
||||
crate::config::AppConfig::default_path().unwrap()
|
||||
});
|
||||
|
||||
// Check if config already exists
|
||||
if config_path.exists() && !self.reset {
|
||||
@@ -41,20 +43,24 @@ impl InitCommand {
|
||||
println!("{}", "Initialization cancelled.".yellow());
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
println!("{}", "Configuration already exists. Use --reset to overwrite.".yellow());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let mut manager = if self.reset {
|
||||
ConfigManager::new()?
|
||||
} else {
|
||||
ConfigManager::new().or_else(|_| Ok::<_, anyhow::Error>(ConfigManager::default()))?
|
||||
};
|
||||
// Create parent directory if needed
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create config directory: {}", e))?;
|
||||
}
|
||||
|
||||
// Create new config manager with fresh config
|
||||
let mut manager = ConfigManager::with_path_fresh(&config_path)?;
|
||||
|
||||
if self.yes {
|
||||
// Quick setup with defaults
|
||||
self.quick_setup(&mut manager).await?;
|
||||
} else {
|
||||
// Interactive setup
|
||||
self.interactive_setup(&mut manager).await?;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use anyhow::{bail, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use colored::Colorize;
|
||||
use dialoguer::{Confirm, Input, Select};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::manager::ConfigManager;
|
||||
use crate::config::{GitProfile, TokenConfig, TokenType};
|
||||
@@ -123,27 +124,34 @@ enum TokenSubcommand {
|
||||
}
|
||||
|
||||
impl ProfileCommand {
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
|
||||
match &self.command {
|
||||
Some(ProfileSubcommand::Add) => self.add_profile().await,
|
||||
Some(ProfileSubcommand::Remove { name }) => self.remove_profile(name).await,
|
||||
Some(ProfileSubcommand::List) => self.list_profiles().await,
|
||||
Some(ProfileSubcommand::Show { name }) => self.show_profile(name.as_deref()).await,
|
||||
Some(ProfileSubcommand::Edit { name }) => self.edit_profile(name).await,
|
||||
Some(ProfileSubcommand::SetDefault { name }) => self.set_default(name).await,
|
||||
Some(ProfileSubcommand::SetRepo { name }) => self.set_repo(name).await,
|
||||
Some(ProfileSubcommand::Apply { name, global }) => self.apply_profile(name.as_deref(), *global).await,
|
||||
Some(ProfileSubcommand::Switch) => self.switch_profile().await,
|
||||
Some(ProfileSubcommand::Copy { from, to }) => self.copy_profile(from, to).await,
|
||||
Some(ProfileSubcommand::Token { token_command }) => self.handle_token_command(token_command).await,
|
||||
Some(ProfileSubcommand::Check { name }) => self.check_profile(name.as_deref()).await,
|
||||
Some(ProfileSubcommand::Stats { name }) => self.show_stats(name.as_deref()).await,
|
||||
None => self.list_profiles().await,
|
||||
Some(ProfileSubcommand::Add) => self.add_profile(&config_path).await,
|
||||
Some(ProfileSubcommand::Remove { name }) => self.remove_profile(name, &config_path).await,
|
||||
Some(ProfileSubcommand::List) => self.list_profiles(&config_path).await,
|
||||
Some(ProfileSubcommand::Show { name }) => self.show_profile(name.as_deref(), &config_path).await,
|
||||
Some(ProfileSubcommand::Edit { name }) => self.edit_profile(name, &config_path).await,
|
||||
Some(ProfileSubcommand::SetDefault { name }) => self.set_default(name, &config_path).await,
|
||||
Some(ProfileSubcommand::SetRepo { name }) => self.set_repo(name, &config_path).await,
|
||||
Some(ProfileSubcommand::Apply { name, global }) => self.apply_profile(name.as_deref(), *global, &config_path).await,
|
||||
Some(ProfileSubcommand::Switch) => self.switch_profile(&config_path).await,
|
||||
Some(ProfileSubcommand::Copy { from, to }) => self.copy_profile(from, to, &config_path).await,
|
||||
Some(ProfileSubcommand::Token { token_command }) => self.handle_token_command(token_command, &config_path).await,
|
||||
Some(ProfileSubcommand::Check { name }) => self.check_profile(name.as_deref(), &config_path).await,
|
||||
Some(ProfileSubcommand::Stats { name }) => self.show_stats(name.as_deref(), &config_path).await,
|
||||
None => self.list_profiles(&config_path).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_profile(&self) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
fn get_manager(&self, config_path: &Option<PathBuf>) -> Result<ConfigManager> {
|
||||
match config_path {
|
||||
Some(path) => ConfigManager::with_path(path),
|
||||
None => ConfigManager::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_profile(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
println!("{}", "\nAdd new profile".bold());
|
||||
println!("{}", "─".repeat(40));
|
||||
@@ -244,8 +252,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_profile(&self, name: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn remove_profile(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
if !manager.has_profile(name) {
|
||||
bail!("Profile '{}' not found", name);
|
||||
@@ -269,8 +277,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_profiles(&self) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn list_profiles(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
|
||||
let profiles = manager.list_profiles();
|
||||
|
||||
@@ -319,8 +327,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn show_profile(&self, name: Option<&str>) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn show_profile(&self, name: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
|
||||
let profile = if let Some(n) = name {
|
||||
manager.get_profile(n)
|
||||
@@ -380,8 +388,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn edit_profile(&self, name: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn edit_profile(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
let profile = manager.get_profile(name)
|
||||
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", name))?
|
||||
@@ -420,8 +428,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_default(&self, name: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_default(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
manager.set_default_profile(Some(name.to_string()))?;
|
||||
manager.save()?;
|
||||
@@ -431,22 +439,30 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_repo(&self, name: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_repo(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
let repo = find_repo(std::env::current_dir()?.as_path())?;
|
||||
|
||||
let repo_path = repo.path().to_string_lossy().to_string();
|
||||
|
||||
manager.set_repo_profile(repo_path, name.to_string())?;
|
||||
manager.set_repo_profile(repo_path.clone(), name.to_string())?;
|
||||
|
||||
// Get the profile and apply it to the repository
|
||||
let profile = manager.get_profile(name)
|
||||
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", name))?;
|
||||
|
||||
profile.apply_to_repo(repo.inner())?;
|
||||
manager.record_profile_usage(name, Some(repo_path))?;
|
||||
manager.save()?;
|
||||
|
||||
println!("{} Set '{}' for current repository", "✓".green(), name.cyan());
|
||||
println!("{} Applied profile '{}' to current repository", "✓".green(), name.cyan());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_profile(&self, name: Option<&str>, global: bool) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn apply_profile(&self, name: Option<&str>, global: bool, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
let profile_name = if let Some(n) = name {
|
||||
n.to_string()
|
||||
@@ -482,8 +498,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn switch_profile(&self) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn switch_profile(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
let profiles: Vec<String> = manager.list_profiles()
|
||||
.into_iter()
|
||||
@@ -519,15 +535,15 @@ impl ProfileCommand {
|
||||
.interact()?;
|
||||
|
||||
if apply {
|
||||
self.apply_profile(Some(selected), false).await?;
|
||||
self.apply_profile(Some(selected), false, config_path).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn copy_profile(&self, from: &str, to: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn copy_profile(&self, from: &str, to: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
let source = manager.get_profile(from)
|
||||
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", from))?
|
||||
@@ -547,16 +563,16 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_token_command(&self, cmd: &TokenSubcommand) -> Result<()> {
|
||||
async fn handle_token_command(&self, cmd: &TokenSubcommand, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
match cmd {
|
||||
TokenSubcommand::Add { profile, service } => self.add_token(profile, service).await,
|
||||
TokenSubcommand::Remove { profile, service } => self.remove_token(profile, service).await,
|
||||
TokenSubcommand::List { profile } => self.list_tokens(profile).await,
|
||||
TokenSubcommand::Add { profile, service } => self.add_token(profile, service, config_path).await,
|
||||
TokenSubcommand::Remove { profile, service } => self.remove_token(profile, service, config_path).await,
|
||||
TokenSubcommand::List { profile } => self.list_tokens(profile, config_path).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_token(&self, profile_name: &str, service: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn add_token(&self, profile_name: &str, service: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
if !manager.has_profile(profile_name) {
|
||||
bail!("Profile '{}' not found", profile_name);
|
||||
@@ -602,8 +618,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_token(&self, profile_name: &str, service: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn remove_token(&self, profile_name: &str, service: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
if !manager.has_profile(profile_name) {
|
||||
bail!("Profile '{}' not found", profile_name);
|
||||
@@ -627,8 +643,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_tokens(&self, profile_name: &str) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn list_tokens(&self, profile_name: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
|
||||
let profile = manager.get_profile(profile_name)
|
||||
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?;
|
||||
@@ -654,8 +670,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_profile(&self, name: Option<&str>) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn check_profile(&self, name: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
|
||||
let profile_name = if let Some(n) = name {
|
||||
n.to_string()
|
||||
@@ -687,8 +703,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn show_stats(&self, name: Option<&str>) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn show_stats(&self, name: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
|
||||
if let Some(n) = name {
|
||||
let profile = manager.get_profile(n)
|
||||
|
||||
@@ -3,6 +3,7 @@ use clap::Parser;
|
||||
use colored::Colorize;
|
||||
use dialoguer::{Confirm, Input, Select};
|
||||
use semver::Version;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::{Language, manager::ConfigManager};
|
||||
use crate::git::{find_repo, GitRepo};
|
||||
@@ -61,9 +62,13 @@ pub struct TagCommand {
|
||||
}
|
||||
|
||||
impl TagCommand {
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
|
||||
let repo = find_repo(std::env::current_dir()?.as_path())?;
|
||||
let manager = ConfigManager::new()?;
|
||||
let manager = if let Some(ref path) = config_path {
|
||||
ConfigManager::with_path(path)?
|
||||
} else {
|
||||
ConfigManager::new()?
|
||||
};
|
||||
let config = manager.config();
|
||||
let language = manager.get_language().unwrap_or(Language::English);
|
||||
let messages = Messages::new(language);
|
||||
|
||||
@@ -19,7 +19,11 @@ impl ConfigManager {
|
||||
|
||||
/// Create config manager with specific path
|
||||
pub fn with_path(path: &Path) -> Result<Self> {
|
||||
let config = AppConfig::load(path)?;
|
||||
let config = if path.exists() {
|
||||
AppConfig::load(path)?
|
||||
} else {
|
||||
AppConfig::default()
|
||||
};
|
||||
Ok(Self {
|
||||
config,
|
||||
config_path: path.to_path_buf(),
|
||||
@@ -27,6 +31,15 @@ impl ConfigManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Create config manager with fresh config (ignoring existing)
|
||||
pub fn with_path_fresh(path: &Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
config: AppConfig::default(),
|
||||
config_path: path.to_path_buf(),
|
||||
modified: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get configuration reference
|
||||
pub fn config(&self) -> &AppConfig {
|
||||
&self.config
|
||||
|
||||
@@ -177,8 +177,17 @@ impl GitProfile {
|
||||
|
||||
if let Some(ref ssh) = self.ssh {
|
||||
if let Some(ref key_path) = ssh.private_key_path {
|
||||
config.set_str("core.sshCommand",
|
||||
&format!("ssh -i {}", key_path.display()))?;
|
||||
let path_str = key_path.display().to_string();
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
config.set_str("core.sshCommand",
|
||||
&format!("ssh -i \"{}\"", path_str.replace('\\', "/")))?;
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
config.set_str("core.sshCommand",
|
||||
&format!("ssh -i '{}'", path_str))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +203,30 @@ impl GitProfile {
|
||||
|
||||
if let Some(key) = self.signing_key() {
|
||||
config.set_str("user.signingkey", key)?;
|
||||
|
||||
if self.settings.auto_sign_commits {
|
||||
config.set_bool("commit.gpgsign", true)?;
|
||||
}
|
||||
|
||||
if self.settings.auto_sign_tags {
|
||||
config.set_bool("tag.gpgsign", true)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref ssh) = self.ssh {
|
||||
if let Some(ref key_path) = ssh.private_key_path {
|
||||
let path_str = key_path.display().to_string();
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
config.set_str("core.sshCommand",
|
||||
&format!("ssh -i \"{}\"", path_str.replace('\\', "/")))?;
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
config.set_str("core.sshCommand",
|
||||
&format!("ssh -i '{}'", path_str))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -336,7 +369,15 @@ impl SshConfig {
|
||||
if let Some(ref cmd) = self.ssh_command {
|
||||
Some(cmd.clone())
|
||||
} else if let Some(ref key_path) = self.private_key_path {
|
||||
Some(format!("ssh -i '{}'", key_path.display()))
|
||||
let path_str = key_path.display().to_string();
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Some(format!("ssh -i \"{}\"", path_str.replace('\\', "/")))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Some(format!("ssh -i '{}'", path_str))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -496,7 +537,11 @@ pub struct ConfigDifference {
|
||||
}
|
||||
|
||||
fn default_gpg_program() -> String {
|
||||
"gpg".to_string()
|
||||
if cfg!(target_os = "windows") {
|
||||
"gpg.exe".to_string()
|
||||
} else {
|
||||
"gpg".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::config::{CommitFormat, LlmConfig, Language};
|
||||
use crate::git::{CommitInfo, GitRepo};
|
||||
use crate::llm::{GeneratedCommit, LlmClient};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
|
||||
/// Content generator using LLM
|
||||
pub struct ContentGenerator {
|
||||
@@ -114,8 +113,7 @@ impl ContentGenerator {
|
||||
format: CommitFormat,
|
||||
language: Language,
|
||||
) -> Result<GeneratedCommit> {
|
||||
use dialoguer::{Confirm, Select};
|
||||
use console::Term;
|
||||
use dialoguer::Select;
|
||||
|
||||
let diff = repo.get_staged_diff()?;
|
||||
|
||||
@@ -145,7 +143,6 @@ impl ContentGenerator {
|
||||
"✓ Accept and commit",
|
||||
"🔄 Regenerate",
|
||||
"✏️ Edit",
|
||||
"📋 Copy to clipboard",
|
||||
"❌ Cancel",
|
||||
];
|
||||
|
||||
@@ -165,24 +162,13 @@ impl ContentGenerator {
|
||||
let edited = crate::utils::editor::edit_content(&generated.to_conventional())?;
|
||||
generated = self.parse_edited_commit(&edited, format)?;
|
||||
}
|
||||
3 => {
|
||||
#[cfg(feature = "clipboard")]
|
||||
{
|
||||
arboard::Clipboard::new()?.set_text(generated.to_conventional())?;
|
||||
println!("Copied to clipboard!");
|
||||
}
|
||||
#[cfg(not(feature = "clipboard"))]
|
||||
{
|
||||
println!("Clipboard feature not enabled");
|
||||
}
|
||||
}
|
||||
4 => anyhow::bail!("Cancelled by user"),
|
||||
3 => anyhow::bail!("Cancelled by user"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_edited_commit(&self, edited: &str, format: CommitFormat) -> Result<GeneratedCommit> {
|
||||
fn parse_edited_commit(&self, edited: &str, _format: CommitFormat) -> Result<GeneratedCommit> {
|
||||
let parsed = crate::git::commit::parse_commit_message(edited);
|
||||
|
||||
Ok(GeneratedCommit {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{CommitInfo, GitRepo};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -232,8 +232,6 @@ impl ChangelogGenerator {
|
||||
let mut breaking = vec![];
|
||||
|
||||
for commit in commits {
|
||||
let msg = commit.subject();
|
||||
|
||||
if commit.message.contains("BREAKING CHANGE") {
|
||||
breaking.push(commit);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::GitRepo;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use anyhow::{bail, Result};
|
||||
use chrono::Local;
|
||||
|
||||
/// Commit builder for creating commits
|
||||
@@ -47,6 +47,12 @@ impl CommitBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set scope (optional)
|
||||
pub fn scope_opt(mut self, scope: Option<String>) -> Self {
|
||||
self.scope = scope;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set description
|
||||
pub fn description(mut self, description: impl Into<String>) -> Self {
|
||||
self.description = Some(description.into());
|
||||
@@ -59,6 +65,12 @@ impl CommitBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set body (optional)
|
||||
pub fn body_opt(mut self, body: Option<String>) -> Self {
|
||||
self.body = body;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set footer
|
||||
pub fn footer(mut self, footer: impl Into<String>) -> Self {
|
||||
self.footer = Some(footer.into());
|
||||
|
||||
296
src/git/mod.rs
296
src/git/mod.rs
@@ -1,6 +1,6 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use git2::{Repository, Signature, StatusOptions, Config, Oid, ObjectType};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Path, PathBuf, Component};
|
||||
use std::collections::HashMap;
|
||||
use tempfile;
|
||||
|
||||
@@ -8,11 +8,166 @@ pub mod changelog;
|
||||
pub mod commit;
|
||||
pub mod tag;
|
||||
|
||||
pub use changelog::ChangelogGenerator;
|
||||
pub use commit::CommitBuilder;
|
||||
pub use tag::TagBuilder;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
|
||||
fn normalize_path_for_git2(path: &Path) -> PathBuf {
|
||||
let mut normalized = path.to_path_buf();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let path_str = path.to_string_lossy();
|
||||
if path_str.starts_with(r"\\?\") {
|
||||
if let Some(stripped) = path_str.strip_prefix(r"\\?\") {
|
||||
normalized = PathBuf::from(stripped);
|
||||
}
|
||||
}
|
||||
if path_str.starts_with(r"\\?\UNC\") {
|
||||
if let Some(stripped) = path_str.strip_prefix(r"\\?\UNC\") {
|
||||
normalized = PathBuf::from(format!(r"\\{}", stripped));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
fn get_absolute_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
|
||||
let path = path.as_ref();
|
||||
|
||||
if path.is_absolute() {
|
||||
return Ok(normalize_path_for_git2(path));
|
||||
}
|
||||
|
||||
let current_dir = std::env::current_dir()
|
||||
.with_context(|| "Failed to get current directory")?;
|
||||
|
||||
let absolute = current_dir.join(path);
|
||||
Ok(normalize_path_for_git2(&absolute))
|
||||
}
|
||||
|
||||
fn resolve_path_without_canonicalize(path: &Path) -> PathBuf {
|
||||
let mut components = Vec::new();
|
||||
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::ParentDir => {
|
||||
if !components.is_empty() && components.last() != Some(&Component::ParentDir) {
|
||||
components.pop();
|
||||
} else {
|
||||
components.push(component);
|
||||
}
|
||||
}
|
||||
Component::CurDir => {}
|
||||
_ => components.push(component),
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = PathBuf::new();
|
||||
for component in components {
|
||||
result.push(component.as_os_str());
|
||||
}
|
||||
|
||||
normalize_path_for_git2(&result)
|
||||
}
|
||||
|
||||
fn try_open_repo_with_git2(path: &Path) -> Result<Repository> {
|
||||
let normalized = normalize_path_for_git2(path);
|
||||
|
||||
let discover_opts = git2::RepositoryOpenFlags::empty();
|
||||
let ceiling_dirs: [&str; 0] = [];
|
||||
|
||||
let repo = Repository::open_ext(&normalized, discover_opts, &ceiling_dirs)
|
||||
.or_else(|_| Repository::discover(&normalized))
|
||||
.or_else(|_| Repository::open(&normalized));
|
||||
|
||||
repo.map_err(|e| anyhow::anyhow!("git2 failed: {}", e))
|
||||
}
|
||||
|
||||
fn try_open_repo_with_git_cli(path: &Path) -> Result<Repository> {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(&["rev-parse", "--show-toplevel"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.context("Failed to execute git command")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!("git CLI failed to find repository");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let git_root = stdout.trim();
|
||||
|
||||
if git_root.is_empty() {
|
||||
bail!("git CLI returned empty path");
|
||||
}
|
||||
|
||||
let git_root_path = PathBuf::from(git_root);
|
||||
let normalized = normalize_path_for_git2(&git_root_path);
|
||||
|
||||
Repository::open(&normalized)
|
||||
.with_context(|| format!("Failed to open repo from git CLI path: {:?}", normalized))
|
||||
}
|
||||
|
||||
fn diagnose_repo_issue(path: &Path) -> String {
|
||||
let mut issues = Vec::new();
|
||||
|
||||
if !path.exists() {
|
||||
issues.push(format!("Path does not exist: {:?}", path));
|
||||
} else if !path.is_dir() {
|
||||
issues.push(format!("Path is not a directory: {:?}", path));
|
||||
}
|
||||
|
||||
let git_dir = path.join(".git");
|
||||
if git_dir.exists() {
|
||||
if git_dir.is_dir() {
|
||||
issues.push("Found .git directory".to_string());
|
||||
let config_file = git_dir.join("config");
|
||||
if config_file.exists() {
|
||||
issues.push("Git config file exists".to_string());
|
||||
} else {
|
||||
issues.push("WARNING: Git config file missing".to_string());
|
||||
}
|
||||
} else {
|
||||
issues.push("Found .git file (submodule or worktree)".to_string());
|
||||
}
|
||||
} else {
|
||||
issues.push("No .git found in current directory".to_string());
|
||||
|
||||
let mut current = path;
|
||||
let mut depth = 0;
|
||||
while let Some(parent) = current.parent() {
|
||||
depth += 1;
|
||||
if depth > 20 {
|
||||
break;
|
||||
}
|
||||
let parent_git = parent.join(".git");
|
||||
if parent_git.exists() {
|
||||
issues.push(format!("Found .git in parent directory: {:?}", parent));
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let path_str = path.to_string_lossy();
|
||||
if path_str.starts_with(r"\\?\") {
|
||||
issues.push("Path has Windows extended-length prefix (\\\\?\\)".to_string());
|
||||
}
|
||||
if path_str.contains('\\') && path_str.contains('/') {
|
||||
issues.push("WARNING: Path has mixed path separators".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(current_dir) = std::env::current_dir() {
|
||||
issues.push(format!("Current working directory: {:?}", current_dir));
|
||||
}
|
||||
|
||||
issues.join("\n ")
|
||||
}
|
||||
|
||||
/// Git repository wrapper with enhanced cross-platform support
|
||||
pub struct GitRepo {
|
||||
repo: Repository,
|
||||
path: PathBuf,
|
||||
@@ -20,54 +175,45 @@ pub struct GitRepo {
|
||||
}
|
||||
|
||||
impl GitRepo {
|
||||
/// Open a git repository
|
||||
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||
let path = path.as_ref();
|
||||
|
||||
// Enhanced cross-platform path handling
|
||||
let absolute_path = if let Ok(canonical) = path.canonicalize() {
|
||||
canonical
|
||||
} else {
|
||||
// Fallback: convert to absolute path without canonicalization
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()?.join(path)
|
||||
}
|
||||
};
|
||||
let absolute_path = get_absolute_path(path)?;
|
||||
let resolved_path = resolve_path_without_canonicalize(&absolute_path);
|
||||
|
||||
// Try multiple git repository discovery strategies for cross-platform compatibility
|
||||
let repo = Repository::discover(&absolute_path)
|
||||
.or_else(|discover_err| {
|
||||
// Try direct open as fallback
|
||||
Repository::open(&absolute_path).map_err(|open_err| {
|
||||
// Provide detailed error information for debugging
|
||||
anyhow::anyhow!(
|
||||
"Git repository discovery failed:\n\
|
||||
Discovery error: {}\n\
|
||||
Direct open error: {}\n\
|
||||
Path attempted: {:?}\n\
|
||||
Current directory: {:?}",
|
||||
discover_err, open_err, absolute_path, std::env::current_dir()
|
||||
)
|
||||
})
|
||||
})
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to open git repository at '{:?}'. Please ensure:\n\
|
||||
1. The directory contains a valid '.git' folder\n\
|
||||
2. The directory is set as safe (run: git config --global --add safe.directory \"{}\")\n\
|
||||
3. You have proper permissions to access the repository",
|
||||
absolute_path,
|
||||
absolute_path.display()
|
||||
)
|
||||
let repo = try_open_repo_with_git2(&resolved_path)
|
||||
.or_else(|git2_err| {
|
||||
try_open_repo_with_git_cli(&resolved_path)
|
||||
.map_err(|cli_err| {
|
||||
let diagnosis = diagnose_repo_issue(&resolved_path);
|
||||
anyhow::anyhow!(
|
||||
"Failed to open git repository:\n\
|
||||
\n\
|
||||
=== git2 Error ===\n {}\n\
|
||||
\n\
|
||||
=== git CLI Error ===\n {}\n\
|
||||
\n\
|
||||
=== Diagnosis ===\n {}\n\
|
||||
\n\
|
||||
=== Suggestions ===\n\
|
||||
1. Ensure you are inside a git repository\n\
|
||||
2. Run: git status (to verify git works)\n\
|
||||
3. Run: git config --global --add safe.directory \"*\"\n\
|
||||
4. Check file permissions",
|
||||
git2_err, cli_err, diagnosis
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
let repo_path = repo.workdir()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| resolved_path.clone());
|
||||
|
||||
let config = repo.config().ok();
|
||||
|
||||
Ok(Self {
|
||||
repo,
|
||||
path: absolute_path,
|
||||
path: normalize_path_for_git2(&repo_path),
|
||||
config,
|
||||
})
|
||||
}
|
||||
@@ -722,20 +868,28 @@ impl StatusSummary {
|
||||
}
|
||||
}
|
||||
|
||||
/// Find git repository starting from path and walking up
|
||||
pub fn find_repo<P: AsRef<Path>>(start_path: P) -> Result<GitRepo> {
|
||||
let start_path = start_path.as_ref();
|
||||
|
||||
// Try the starting path first
|
||||
if let Ok(repo) = GitRepo::open(start_path) {
|
||||
let absolute_start = get_absolute_path(start_path)?;
|
||||
let resolved_start = resolve_path_without_canonicalize(&absolute_start);
|
||||
|
||||
if let Ok(repo) = GitRepo::open(&resolved_start) {
|
||||
return Ok(repo);
|
||||
}
|
||||
|
||||
// Walk up the directory tree to find a git repository
|
||||
let mut current = start_path;
|
||||
let mut current = resolved_start.as_path();
|
||||
let mut attempted_paths = vec![current.to_string_lossy().to_string()];
|
||||
|
||||
let max_depth = 50;
|
||||
let mut depth = 0;
|
||||
|
||||
while let Some(parent) = current.parent() {
|
||||
depth += 1;
|
||||
if depth > max_depth {
|
||||
break;
|
||||
}
|
||||
|
||||
attempted_paths.push(parent.to_string_lossy().to_string());
|
||||
|
||||
if let Ok(repo) = GitRepo::open(parent) {
|
||||
@@ -744,18 +898,44 @@ pub fn find_repo<P: AsRef<Path>>(start_path: P) -> Result<GitRepo> {
|
||||
current = parent;
|
||||
}
|
||||
|
||||
// Provide detailed error information for debugging
|
||||
if let Ok(output) = std::process::Command::new("git")
|
||||
.args(&["rev-parse", "--show-toplevel"])
|
||||
.current_dir(&resolved_start)
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let git_root = stdout.trim();
|
||||
if !git_root.is_empty() {
|
||||
if let Ok(repo) = GitRepo::open(git_root) {
|
||||
return Ok(repo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let diagnosis = diagnose_repo_issue(&resolved_start);
|
||||
|
||||
bail!(
|
||||
"No git repository found starting from {:?}.\n\
|
||||
Paths attempted:\n {}\n\
|
||||
Current directory: {:?}\n\
|
||||
Please ensure:\n\
|
||||
1. You are in a git repository or its subdirectory\n\
|
||||
2. The repository has a valid .git folder\n\
|
||||
3. You have proper permissions to access the repository",
|
||||
start_path,
|
||||
"No git repository found.\n\
|
||||
\n\
|
||||
=== Starting Path ===\n {:?}\n\
|
||||
\n\
|
||||
=== Paths Attempted ===\n {}\n\
|
||||
\n\
|
||||
=== Current Directory ===\n {:?}\n\
|
||||
\n\
|
||||
=== Diagnosis ===\n {}\n\
|
||||
\n\
|
||||
=== Suggestions ===\n\
|
||||
1. Ensure you are inside a git repository (run: git status)\n\
|
||||
2. Initialize a new repo: git init\n\
|
||||
3. Clone an existing repo: git clone <url>\n\
|
||||
4. Check if .git directory exists and is accessible",
|
||||
resolved_start,
|
||||
attempted_paths.join("\n "),
|
||||
std::env::current_dir()
|
||||
std::env::current_dir().unwrap_or_default(),
|
||||
diagnosis
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::GitRepo;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use anyhow::{bail, Result};
|
||||
use semver::Version;
|
||||
|
||||
/// Tag builder for creating tags
|
||||
@@ -281,8 +281,9 @@ pub fn delete_tag(repo: &GitRepo, name: &str, remote: Option<&str>) -> Result<()
|
||||
if let Some(remote) = remote {
|
||||
use std::process::Command;
|
||||
|
||||
let refspec = format!(":refs/tags/{}", name);
|
||||
let output = Command::new("git")
|
||||
.args(&["push", remote, ":refs/tags/{}"])
|
||||
.args(&["push", remote, &refspec])
|
||||
.current_dir(repo.path())
|
||||
.output()?;
|
||||
|
||||
|
||||
@@ -2,6 +2,4 @@ pub mod messages;
|
||||
pub mod translator;
|
||||
|
||||
pub use messages::Messages;
|
||||
pub use translator::Translator;
|
||||
pub use translator::translate_commit_type;
|
||||
pub use translator::translate_changelog_category;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use crate::config::Language;
|
||||
|
||||
|
||||
@@ -239,7 +239,7 @@ pub const OPENROUTER_MODELS: &[&str] = &[
|
||||
];
|
||||
|
||||
/// Check if a model name is valid
|
||||
pub fn is_valid_model(model: &str) -> bool {
|
||||
pub fn is_valid_model(_model: &str) -> bool {
|
||||
// Since OpenRouter supports many models, we'll allow any model name
|
||||
// but provide some popular ones as suggestions
|
||||
true
|
||||
|
||||
17
src/main.rs
17
src/main.rs
@@ -1,5 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
use tracing::debug;
|
||||
|
||||
mod commands;
|
||||
@@ -74,7 +75,6 @@ enum Commands {
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Initialize logging
|
||||
let log_level = match cli.verbose {
|
||||
0 => "warn",
|
||||
1 => "info",
|
||||
@@ -89,13 +89,14 @@ async fn main() -> Result<()> {
|
||||
|
||||
debug!("Starting quicommit v{}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// Execute command
|
||||
let config_path: Option<PathBuf> = cli.config.map(PathBuf::from);
|
||||
|
||||
match cli.command {
|
||||
Commands::Init(cmd) => cmd.execute().await,
|
||||
Commands::Commit(cmd) => cmd.execute().await,
|
||||
Commands::Tag(cmd) => cmd.execute().await,
|
||||
Commands::Changelog(cmd) => cmd.execute().await,
|
||||
Commands::Profile(cmd) => cmd.execute().await,
|
||||
Commands::Config(cmd) => cmd.execute().await,
|
||||
Commands::Init(cmd) => cmd.execute(config_path).await,
|
||||
Commands::Commit(cmd) => cmd.execute(config_path).await,
|
||||
Commands::Tag(cmd) => cmd.execute(config_path).await,
|
||||
Commands::Changelog(cmd) => cmd.execute(config_path).await,
|
||||
Commands::Profile(cmd) => cmd.execute(config_path).await,
|
||||
Commands::Config(cmd) => cmd.execute(config_path).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,22 @@ pub fn get_editor() -> String {
|
||||
.or_else(|_| std::env::var("VISUAL"))
|
||||
.unwrap_or_else(|_| {
|
||||
if cfg!(target_os = "windows") {
|
||||
if let Ok(code) = which::which("code") {
|
||||
return "code --wait".to_string();
|
||||
}
|
||||
if let Ok(notepad) = which::which("notepad") {
|
||||
return "notepad".to_string();
|
||||
}
|
||||
"notepad".to_string()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
if which::which("code").is_ok() {
|
||||
return "code --wait".to_string();
|
||||
}
|
||||
"vi".to_string()
|
||||
} else {
|
||||
if which::which("nano").is_ok() {
|
||||
return "nano".to_string();
|
||||
}
|
||||
"vi".to_string()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
use regex::Regex;
|
||||
|
||||
/// Format commit message with conventional commit format
|
||||
@@ -12,7 +11,6 @@ pub fn format_conventional_commit(
|
||||
) -> String {
|
||||
let mut message = String::new();
|
||||
|
||||
// Type and scope
|
||||
message.push_str(commit_type);
|
||||
if let Some(s) = scope {
|
||||
message.push_str(&format!("({})", s));
|
||||
@@ -22,12 +20,10 @@ pub fn format_conventional_commit(
|
||||
}
|
||||
message.push_str(&format!(": {}", description));
|
||||
|
||||
// Body
|
||||
if let Some(b) = body {
|
||||
message.push_str(&format!("\n\n{}", b));
|
||||
}
|
||||
|
||||
// Footer
|
||||
if let Some(f) = footer {
|
||||
message.push_str(&format!("\n\n{}", f));
|
||||
}
|
||||
@@ -46,26 +42,22 @@ pub fn format_commitlint_commit(
|
||||
) -> String {
|
||||
let mut message = String::new();
|
||||
|
||||
// Header
|
||||
message.push_str(commit_type);
|
||||
if let Some(s) = scope {
|
||||
message.push_str(&format!("({})", s));
|
||||
}
|
||||
message.push_str(&format!(": {}", subject));
|
||||
|
||||
// References
|
||||
if let Some(refs) = references {
|
||||
for reference in refs {
|
||||
message.push_str(&format!(" #{}", reference));
|
||||
}
|
||||
}
|
||||
|
||||
// Body
|
||||
if let Some(b) = body {
|
||||
message.push_str(&format!("\n\n{}", b));
|
||||
}
|
||||
|
||||
// Footer
|
||||
if let Some(f) = footer {
|
||||
message.push_str(&format!("\n\n{}", f));
|
||||
}
|
||||
@@ -73,38 +65,11 @@ pub fn format_commitlint_commit(
|
||||
message
|
||||
}
|
||||
|
||||
/// Format date for commit message
|
||||
pub fn format_commit_date(date: &DateTime<Local>) -> String {
|
||||
date.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
/// Format date for changelog
|
||||
pub fn format_changelog_date(date: &DateTime<Utc>) -> String {
|
||||
date.format("%Y-%m-%d").to_string()
|
||||
}
|
||||
|
||||
/// Format tag name with version
|
||||
pub fn format_tag_name(version: &str, prefix: Option<&str>) -> String {
|
||||
match prefix {
|
||||
Some(p) => format!("{}{}", p, version),
|
||||
None => version.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap text at specified width
|
||||
pub fn wrap_text(text: &str, width: usize) -> String {
|
||||
textwrap::fill(text, width)
|
||||
}
|
||||
|
||||
/// Truncate text with ellipsis
|
||||
pub fn truncate(text: &str, max_len: usize) -> String {
|
||||
if text.len() <= max_len {
|
||||
text.to_string()
|
||||
} else {
|
||||
format!("{}...", &text[..max_len.saturating_sub(3)])
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean commit message (remove comments, extra whitespace)
|
||||
pub fn clean_message(message: &str) -> String {
|
||||
let comment_regex = Regex::new(r"^#.*$").unwrap();
|
||||
@@ -118,44 +83,6 @@ pub fn clean_message(message: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Format list as markdown bullet points
|
||||
pub fn format_markdown_list(items: &[String]) -> String {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| format!("- {}", item))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Format changelog section
|
||||
pub fn format_changelog_section(
|
||||
version: &str,
|
||||
date: &str,
|
||||
changes: &[(String, Vec<String>)],
|
||||
) -> String {
|
||||
let mut section = format!("## [{}] - {}\n\n", version, date);
|
||||
|
||||
for (category, items) in changes {
|
||||
if !items.is_empty() {
|
||||
section.push_str(&format!("### {}\n\n", category));
|
||||
for item in items {
|
||||
section.push_str(&format!("- {}\n", item));
|
||||
}
|
||||
section.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
section
|
||||
}
|
||||
|
||||
/// Format git config key
|
||||
pub fn format_git_config_key(section: &str, subsection: Option<&str>, key: &str) -> String {
|
||||
match subsection {
|
||||
Some(sub) => format!("{}.{}.{}", section, sub, key),
|
||||
None => format!("{}.{}", section, key),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -189,10 +116,4 @@ mod tests {
|
||||
|
||||
assert!(msg.starts_with("feat!: change API response format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate() {
|
||||
assert_eq!(truncate("hello", 10), "hello");
|
||||
assert_eq!(truncate("hello world", 8), "hello...");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,11 +58,6 @@ lazy_static! {
|
||||
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
||||
).unwrap();
|
||||
|
||||
/// Regex for SSH key validation (basic)
|
||||
static ref SSH_KEY_REGEX: Regex = Regex::new(
|
||||
r"^(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521)\s+[A-Za-z0-9+/]+={0,2}\s+.*$"
|
||||
).unwrap();
|
||||
|
||||
/// Regex for GPG key ID validation
|
||||
static ref GPG_KEY_ID_REGEX: Regex = Regex::new(
|
||||
r"^[A-F0-9]{16,40}$"
|
||||
@@ -81,7 +76,6 @@ pub fn validate_conventional_commit(message: &str) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
// Check description length (max 100 chars for first line)
|
||||
if first_line.len() > 100 {
|
||||
bail!("Commit subject too long (max 100 characters)");
|
||||
}
|
||||
@@ -93,7 +87,6 @@ pub fn validate_conventional_commit(message: &str) -> Result<()> {
|
||||
pub fn validate_commitlint_commit(message: &str) -> Result<()> {
|
||||
let first_line = message.lines().next().unwrap_or("");
|
||||
|
||||
// Commitlint is more lenient but still requires type prefix
|
||||
let parts: Vec<&str> = first_line.splitn(2, ':').collect();
|
||||
if parts.len() != 2 {
|
||||
bail!("Invalid commit format. Expected: <type>[optional scope]: <subject>");
|
||||
@@ -102,7 +95,6 @@ pub fn validate_commitlint_commit(message: &str) -> Result<()> {
|
||||
let type_part = parts[0];
|
||||
let subject = parts[1].trim();
|
||||
|
||||
// Extract type (handle scope and breaking indicator)
|
||||
let commit_type = type_part
|
||||
.split('(')
|
||||
.next()
|
||||
@@ -117,7 +109,6 @@ pub fn validate_commitlint_commit(message: &str) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate subject
|
||||
if subject.is_empty() {
|
||||
bail!("Commit subject cannot be empty");
|
||||
}
|
||||
@@ -130,12 +121,10 @@ pub fn validate_commitlint_commit(message: &str) -> Result<()> {
|
||||
bail!("Commit subject too long (max 100 characters)");
|
||||
}
|
||||
|
||||
// Subject should not start with uppercase
|
||||
if subject.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) {
|
||||
bail!("Commit subject should not start with uppercase letter");
|
||||
}
|
||||
|
||||
// Subject should not end with period
|
||||
if subject.ends_with('.') {
|
||||
bail!("Commit subject should not end with a period");
|
||||
}
|
||||
@@ -179,15 +168,6 @@ pub fn validate_email(email: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate SSH key format
|
||||
pub fn validate_ssh_key(key: &str) -> Result<()> {
|
||||
if !SSH_KEY_REGEX.is_match(key.trim()) {
|
||||
bail!("Invalid SSH public key format");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate GPG key ID
|
||||
pub fn validate_gpg_key_id(key_id: &str) -> Result<()> {
|
||||
if !GPG_KEY_ID_REGEX.is_match(key_id) {
|
||||
|
||||
@@ -1,64 +1,642 @@
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_cli_help() {
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.arg("--help");
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("QuiCommit"));
|
||||
fn create_git_repo(dir: &PathBuf) -> std::process::Output {
|
||||
std::process::Command::new("git")
|
||||
.args(&["init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("Failed to init git repo")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version() {
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.arg("--version");
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("0.1.0"));
|
||||
fn configure_git_user(dir: &PathBuf) {
|
||||
std::process::Command::new("git")
|
||||
.args(&["config", "user.name", "Test User"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("Failed to configure git user name");
|
||||
|
||||
std::process::Command::new("git")
|
||||
.args(&["config", "user.email", "test@example.com"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("Failed to configure git user email");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_show() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_dir = temp_dir.path().join("config");
|
||||
fs::create_dir(&config_dir).unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.env("QUICOMMIT_CONFIG", config_dir.join("config.toml"))
|
||||
.arg("config")
|
||||
.arg("show");
|
||||
|
||||
cmd.assert().success();
|
||||
fn create_test_file(dir: &PathBuf, name: &str, content: &str) {
|
||||
let file_path = dir.join(name);
|
||||
fs::write(&file_path, content).expect("Failed to create test file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_list_empty() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.env("QUICOMMIT_CONFIG", temp_dir.path().join("config.toml"))
|
||||
.arg("profile")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("No profiles configured"));
|
||||
fn stage_file(dir: &PathBuf, name: &str) {
|
||||
std::process::Command::new("git")
|
||||
.args(&["add", name])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("Failed to stage file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_quick() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.env("QUICOMMIT_CONFIG", temp_dir.path().join("config.toml"))
|
||||
.arg("init")
|
||||
.arg("--yes");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("initialized successfully"));
|
||||
fn create_commit(dir: &PathBuf, message: &str) {
|
||||
std::process::Command::new("git")
|
||||
.args(&["commit", "-m", message])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("Failed to create commit");
|
||||
}
|
||||
|
||||
mod cli_basic {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_help() {
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.arg("--help");
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("QuiCommit"))
|
||||
.stdout(predicate::str::contains("AI-powered Git assistant"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version() {
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.arg("--version");
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("quicommit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_args_shows_help() {
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Usage:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verbose_flag() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
let config_path = repo_path.join("config.toml");
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["-vv", "init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
}
|
||||
|
||||
mod init_command {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_init_quick() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("initialized successfully"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_creates_config_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert().success();
|
||||
|
||||
assert!(config_path.exists(), "Config file should be created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_in_git_repo() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
let config_path = repo_path.join("test_config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_reset() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--reset", "--config", config_path.to_str().unwrap()]);
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("initialized successfully"));
|
||||
}
|
||||
}
|
||||
|
||||
mod profile_command {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_profile_list_empty() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["profile", "list", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("No profiles"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_list_with_profile() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["profile", "list", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("default"));
|
||||
}
|
||||
}
|
||||
|
||||
mod config_command {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_config_show() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["config", "show", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Configuration"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_path() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["config", "path", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("config.toml"));
|
||||
}
|
||||
}
|
||||
|
||||
mod commit_command {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_commit_no_repo() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["commit", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(temp_dir.path());
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("git").or(predicate::str::contains("repository")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_no_changes() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
let config_path = repo_path.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["commit", "--manual", "-m", "test: empty", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Dry run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_with_staged_changes() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
create_test_file(&repo_path, "test.txt", "Hello, World!");
|
||||
stage_file(&repo_path, "test.txt");
|
||||
|
||||
let config_path = repo_path.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["commit", "--manual", "-m", "test: add test file", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Dry run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_date_mode() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
create_test_file(&repo_path, "daily.txt", "Daily update");
|
||||
stage_file(&repo_path, "daily.txt");
|
||||
|
||||
let config_path = repo_path.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["commit", "--date", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Dry run"));
|
||||
}
|
||||
}
|
||||
|
||||
mod tag_command {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_tag_no_repo() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["tag", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(temp_dir.path());
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("git").or(predicate::str::contains("repository")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tag_list_empty() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
create_test_file(&repo_path, "test.txt", "content");
|
||||
stage_file(&repo_path, "test.txt");
|
||||
create_commit(&repo_path, "feat: initial commit");
|
||||
|
||||
let config_path = repo_path.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["tag", "--name", "v0.1.0", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("v0.1.0"));
|
||||
}
|
||||
}
|
||||
|
||||
mod changelog_command {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_changelog_init() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
let config_path = repo_path.join("config.toml");
|
||||
let changelog_path = repo_path.join("CHANGELOG.md");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["changelog", "--init", "--output", changelog_path.to_str().unwrap(), "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert().success();
|
||||
|
||||
assert!(changelog_path.exists(), "Changelog file should be created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_changelog_dry_run() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
create_test_file(&repo_path, "test.txt", "content");
|
||||
stage_file(&repo_path, "test.txt");
|
||||
create_commit(&repo_path, "feat: add feature");
|
||||
|
||||
let config_path = repo_path.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["changelog", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert()
|
||||
.success();
|
||||
}
|
||||
}
|
||||
|
||||
mod cross_platform {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_path_handling_windows_style() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("subdir").join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert().success();
|
||||
assert!(config_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_with_spaces_in_path() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let space_dir = temp_dir.path().join("path with spaces");
|
||||
fs::create_dir_all(&space_dir).unwrap();
|
||||
let config_path = space_dir.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert().success();
|
||||
assert!(config_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_with_unicode_path() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let unicode_dir = temp_dir.path().join("路径测试");
|
||||
fs::create_dir_all(&unicode_dir).unwrap();
|
||||
let config_path = unicode_dir.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert().success();
|
||||
assert!(config_path.exists());
|
||||
}
|
||||
}
|
||||
|
||||
mod git_operations {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_git_repo_detection() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
let git_dir = repo_path.join(".git");
|
||||
assert!(git_dir.exists(), ".git directory should exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_status_clean() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
let output = std::process::Command::new("git")
|
||||
.args(&["status", "--porcelain"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.expect("Failed to run git status");
|
||||
|
||||
assert!(output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stdout).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_commit_creation() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
create_test_file(&repo_path, "test.txt", "content");
|
||||
stage_file(&repo_path, "test.txt");
|
||||
create_commit(&repo_path, "feat: initial commit");
|
||||
|
||||
let output = std::process::Command::new("git")
|
||||
.args(&["log", "--oneline"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.expect("Failed to run git log");
|
||||
|
||||
assert!(output.status.success());
|
||||
let log = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(log.contains("initial commit"));
|
||||
}
|
||||
}
|
||||
|
||||
mod validators {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_commit_message_validation() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
create_test_file(&repo_path, "test.txt", "content");
|
||||
stage_file(&repo_path, "test.txt");
|
||||
|
||||
let config_path = repo_path.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["commit", "--manual", "-m", "invalid commit message without type", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Invalid").or(predicate::str::contains("format")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_conventional_commit() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
create_test_file(&repo_path, "test.txt", "content");
|
||||
stage_file(&repo_path, "test.txt");
|
||||
|
||||
let config_path = repo_path.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["commit", "--manual", "-m", "feat: add new feature", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Dry run"));
|
||||
}
|
||||
}
|
||||
|
||||
mod subcommands {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_commit_alias() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
create_test_file(&repo_path, "test.txt", "content");
|
||||
stage_file(&repo_path, "test.txt");
|
||||
|
||||
let config_path = repo_path.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["c", "--manual", "-m", "fix: test", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Dry run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_alias() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["i", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("initialized successfully"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_alias() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["p", "list", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("default"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user