feat(commands):为所有命令添加config_path参数支持,实现自定义配置文件路径

♻️ refactor(config):重构ConfigManager,添加with_path_fresh方法用于初始化新配置
🔧 fix(git):改进跨平台路径处理,增强git仓库检测的鲁棒性
 test(tests):添加全面的集成测试,覆盖所有命令和跨平台场景
This commit is contained in:
2026-02-14 14:28:11 +08:00
parent 3c925d8268
commit e822ba1f54
14 changed files with 1152 additions and 272 deletions

View File

@@ -13,13 +13,14 @@ use crate::i18n::{Messages, translate_changelog_category};
/// Generate changelog /// Generate changelog
#[derive(Parser)] #[derive(Parser)]
#[command(disable_version_flag = true, disable_help_flag = false)]
pub struct ChangelogCommand { pub struct ChangelogCommand {
/// Output file path /// Output file path
#[arg(short, long)] #[arg(short, long)]
output: Option<PathBuf>, output: Option<PathBuf>,
/// Version to generate changelog for /// Version to generate changelog for
#[arg(short, long)] #[arg(long)]
version: Option<String>, version: Option<String>,
/// Generate from specific tag /// Generate from specific tag
@@ -51,7 +52,7 @@ pub struct ChangelogCommand {
include_authors: bool, include_authors: bool,
/// Format (keep-a-changelog, github-releases) /// Format (keep-a-changelog, github-releases)
#[arg(short, long)] #[arg(long)]
format: Option<String>, format: Option<String>,
/// Dry run (output to stdout) /// Dry run (output to stdout)
@@ -64,9 +65,13 @@ pub struct ChangelogCommand {
} }
impl 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 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 config = manager.config();
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);

View File

@@ -2,6 +2,7 @@ use anyhow::{bail, Context, Result};
use clap::Parser; use clap::Parser;
use colored::Colorize; use colored::Colorize;
use dialoguer::{Confirm, Input, Select}; use dialoguer::{Confirm, Input, Select};
use std::path::PathBuf;
use crate::config::{Language, manager::ConfigManager}; use crate::config::{Language, manager::ConfigManager};
use crate::config::CommitFormat; use crate::config::CommitFormat;
@@ -84,12 +85,16 @@ pub struct CommitCommand {
} }
impl CommitCommand { impl CommitCommand {
pub async fn execute(&self) -> Result<()> { pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
// Find git repository // Find git repository
let repo = find_repo(std::env::current_dir()?.as_path())?; let repo = find_repo(std::env::current_dir()?.as_path())?;
// Load configuration // 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 config = manager.config();
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);

View File

@@ -2,6 +2,7 @@ use anyhow::{bail, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use colored::Colorize; use colored::Colorize;
use dialoguer::{Confirm, Input, Select}; use dialoguer::{Confirm, Input, Select};
use std::path::PathBuf;
use crate::config::{Language, manager::ConfigManager}; use crate::config::{Language, manager::ConfigManager};
use crate::config::CommitFormat; use crate::config::CommitFormat;
@@ -191,43 +192,60 @@ enum ConfigSubcommand {
/// Test LLM connection /// Test LLM connection
TestLlm, TestLlm,
/// Show config file path
Path,
} }
impl ConfigCommand { impl ConfigCommand {
pub async fn execute(&self) -> Result<()> { pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
match &self.command { match &self.command {
Some(ConfigSubcommand::Show) => self.show_config().await, Some(ConfigSubcommand::Show) => self.show_config(&config_path).await,
Some(ConfigSubcommand::List) => self.list_config().await, Some(ConfigSubcommand::List) => self.list_config(&config_path).await,
Some(ConfigSubcommand::Edit) => self.edit_config().await, Some(ConfigSubcommand::Edit) => self.edit_config(&config_path).await,
Some(ConfigSubcommand::Set { key, value }) => self.set_value(key, value).await, Some(ConfigSubcommand::Set { key, value }) => self.set_value(key, value, &config_path).await,
Some(ConfigSubcommand::Get { key }) => self.get_value(key).await, Some(ConfigSubcommand::Get { key }) => self.get_value(key, &config_path).await,
Some(ConfigSubcommand::SetLlm { provider }) => self.set_llm(provider.as_deref()).await, Some(ConfigSubcommand::SetLlm { provider }) => self.set_llm(provider.as_deref(), &config_path).await,
Some(ConfigSubcommand::SetOpenAiKey { key }) => self.set_openai_key(key).await, Some(ConfigSubcommand::SetOpenAiKey { key }) => self.set_openai_key(key, &config_path).await,
Some(ConfigSubcommand::SetAnthropicKey { key }) => self.set_anthropic_key(key).await, Some(ConfigSubcommand::SetAnthropicKey { key }) => self.set_anthropic_key(key, &config_path).await,
Some(ConfigSubcommand::SetKimiKey { key }) => self.set_kimi_key(key).await, Some(ConfigSubcommand::SetKimiKey { key }) => self.set_kimi_key(key, &config_path).await,
Some(ConfigSubcommand::SetDeepSeekKey { key }) => self.set_deepseek_key(key).await, Some(ConfigSubcommand::SetDeepSeekKey { key }) => self.set_deepseek_key(key, &config_path).await,
Some(ConfigSubcommand::SetOpenRouterKey { key }) => self.set_openrouter_key(key).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()).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()).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()).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()).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).await, Some(ConfigSubcommand::SetCommitFormat { format }) => self.set_commit_format(format, &config_path).await,
Some(ConfigSubcommand::SetVersionPrefix { prefix }) => self.set_version_prefix(prefix).await, Some(ConfigSubcommand::SetVersionPrefix { prefix }) => self.set_version_prefix(prefix, &config_path).await,
Some(ConfigSubcommand::SetChangelogPath { path }) => self.set_changelog_path(path).await, Some(ConfigSubcommand::SetChangelogPath { path }) => self.set_changelog_path(path, &config_path).await,
Some(ConfigSubcommand::SetLanguage { language }) => self.set_language(language.as_deref()).await, Some(ConfigSubcommand::SetLanguage { language }) => self.set_language(language.as_deref(), &config_path).await,
Some(ConfigSubcommand::SetKeepTypesEnglish { keep }) => self.set_keep_types_english(*keep).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).await, Some(ConfigSubcommand::SetKeepChangelogTypesEnglish { keep }) => self.set_keep_changelog_types_english(*keep, &config_path).await,
Some(ConfigSubcommand::Reset { force }) => self.reset(*force).await, Some(ConfigSubcommand::Reset { force }) => self.reset(*force, &config_path).await,
Some(ConfigSubcommand::Export { output }) => self.export_config(output.as_deref()).await, Some(ConfigSubcommand::Export { output }) => self.export_config(output.as_deref(), &config_path).await,
Some(ConfigSubcommand::Import { file }) => self.import_config(file).await, Some(ConfigSubcommand::Import { file }) => self.import_config(file, &config_path).await,
Some(ConfigSubcommand::ListModels) => self.list_models().await, Some(ConfigSubcommand::ListModels) => self.list_models(&config_path).await,
Some(ConfigSubcommand::TestLlm) => self.test_llm().await, Some(ConfigSubcommand::TestLlm) => self.test_llm(&config_path).await,
None => self.show_config().await, Some(ConfigSubcommand::Path) => self.show_path(&config_path).await,
None => self.show_config(&config_path).await,
} }
} }
async fn show_config(&self) -> Result<()> { fn get_manager(&self, config_path: &Option<PathBuf>) -> Result<ConfigManager> {
let manager = ConfigManager::new()?; 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(); let config = manager.config();
println!("{}", "\nQuiCommit Configuration".bold()); println!("{}", "\nQuiCommit Configuration".bold());
@@ -306,8 +324,8 @@ impl ConfigCommand {
} }
/// List all configuration information with masked API keys /// List all configuration information with masked API keys
async fn list_config(&self) -> Result<()> { async fn list_config(&self, config_path: &Option<PathBuf>) -> Result<()> {
let manager = ConfigManager::new()?; let manager = self.get_manager(config_path)?;
let config = manager.config(); let config = manager.config();
println!("{}", "\nQuiCommit Configuration".bold()); println!("{}", "\nQuiCommit Configuration".bold());
@@ -404,15 +422,15 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn edit_config(&self) -> Result<()> { async fn edit_config(&self, config_path: &Option<PathBuf>) -> Result<()> {
let manager = ConfigManager::new()?; let manager = self.get_manager(config_path)?;
crate::utils::editor::edit_file(manager.path())?; crate::utils::editor::edit_file(manager.path())?;
println!("{} Configuration updated", "".green()); println!("{} Configuration updated", "".green());
Ok(()) Ok(())
} }
async fn set_value(&self, key: &str, value: &str) -> Result<()> { async fn set_value(&self, key: &str, value: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
match key { match key {
"llm.provider" => manager.set_llm_provider(value.to_string()), "llm.provider" => manager.set_llm_provider(value.to_string()),
@@ -450,8 +468,8 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn get_value(&self, key: &str) -> Result<()> { async fn get_value(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
let manager = ConfigManager::new()?; let manager = self.get_manager(config_path)?;
let config = manager.config(); let config = manager.config();
let value = match key { let value = match key {
@@ -469,8 +487,8 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn set_llm(&self, provider: Option<&str>) -> Result<()> { async fn set_llm(&self, provider: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
let provider = if let Some(p) = provider { let provider = if let Some(p) = provider {
p.to_string() p.to_string()
@@ -602,48 +620,48 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn set_openai_key(&self, key: &str) -> Result<()> { async fn set_openai_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.set_openai_api_key(key.to_string()); manager.set_openai_api_key(key.to_string());
manager.save()?; manager.save()?;
println!("{} OpenAI API key set", "".green()); println!("{} OpenAI API key set", "".green());
Ok(()) Ok(())
} }
async fn set_anthropic_key(&self, key: &str) -> Result<()> { async fn set_anthropic_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.set_anthropic_api_key(key.to_string()); manager.set_anthropic_api_key(key.to_string());
manager.save()?; manager.save()?;
println!("{} Anthropic API key set", "".green()); println!("{} Anthropic API key set", "".green());
Ok(()) Ok(())
} }
async fn set_kimi_key(&self, key: &str) -> Result<()> { async fn set_kimi_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.set_kimi_api_key(key.to_string()); manager.set_kimi_api_key(key.to_string());
manager.save()?; manager.save()?;
println!("{} Kimi API key set", "".green()); println!("{} Kimi API key set", "".green());
Ok(()) Ok(())
} }
async fn set_deepseek_key(&self, key: &str) -> Result<()> { async fn set_deepseek_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.set_deepseek_api_key(key.to_string()); manager.set_deepseek_api_key(key.to_string());
manager.save()?; manager.save()?;
println!("{} DeepSeek API key set", "".green()); println!("{} DeepSeek API key set", "".green());
Ok(()) Ok(())
} }
async fn set_openrouter_key(&self, key: &str) -> Result<()> { async fn set_openrouter_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.set_openrouter_api_key(key.to_string()); manager.set_openrouter_api_key(key.to_string());
manager.save()?; manager.save()?;
println!("{} OpenRouter API key set", "".green()); println!("{} OpenRouter API key set", "".green());
Ok(()) Ok(())
} }
async fn set_kimi(&self, base_url: Option<&str>, model: Option<&str>) -> Result<()> { async fn set_kimi(&self, base_url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
if let Some(url) = base_url { if let Some(url) = base_url {
manager.set_kimi_base_url(url.to_string()); manager.set_kimi_base_url(url.to_string());
@@ -657,8 +675,8 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn set_deepseek(&self, base_url: Option<&str>, model: Option<&str>) -> Result<()> { async fn set_deepseek(&self, base_url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
if let Some(url) = base_url { if let Some(url) = base_url {
manager.set_deepseek_base_url(url.to_string()); manager.set_deepseek_base_url(url.to_string());
@@ -672,8 +690,8 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn set_openrouter(&self, base_url: Option<&str>, model: Option<&str>) -> Result<()> { async fn set_openrouter(&self, base_url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
if let Some(url) = base_url { if let Some(url) = base_url {
manager.set_openrouter_base_url(url.to_string()); manager.set_openrouter_base_url(url.to_string());
@@ -687,8 +705,8 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn set_ollama(&self, url: Option<&str>, model: Option<&str>) -> Result<()> { async fn set_ollama(&self, url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
if let Some(u) = url { if let Some(u) = url {
manager.config_mut().llm.ollama.url = u.to_string(); manager.config_mut().llm.ollama.url = u.to_string();
@@ -702,8 +720,8 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn set_commit_format(&self, format: &str) -> Result<()> { async fn set_commit_format(&self, format: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
let format = match format { let format = match format {
"conventional" => CommitFormat::Conventional, "conventional" => CommitFormat::Conventional,
@@ -717,24 +735,24 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn set_version_prefix(&self, prefix: &str) -> Result<()> { async fn set_version_prefix(&self, prefix: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.set_version_prefix(prefix.to_string()); manager.set_version_prefix(prefix.to_string());
manager.save()?; manager.save()?;
println!("{} Set version prefix to '{}'", "".green(), prefix); println!("{} Set version prefix to '{}'", "".green(), prefix);
Ok(()) Ok(())
} }
async fn set_changelog_path(&self, path: &str) -> Result<()> { async fn set_changelog_path(&self, path: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.set_changelog_path(path.to_string()); manager.set_changelog_path(path.to_string());
manager.save()?; manager.save()?;
println!("{} Set changelog path to {}", "".green(), path); println!("{} Set changelog path to {}", "".green(), path);
Ok(()) Ok(())
} }
async fn set_language(&self, language: Option<&str>) -> Result<()> { async fn set_language(&self, language: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
let language_code = if let Some(lang) = language { let language_code = if let Some(lang) = language {
lang.to_string() lang.to_string()
@@ -763,8 +781,8 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn set_keep_types_english(&self, keep: bool) -> Result<()> { async fn set_keep_types_english(&self, keep: bool, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.set_keep_types_english(keep); manager.set_keep_types_english(keep);
manager.save()?; manager.save()?;
let status = if keep { "enabled" } else { "disabled" }; let status = if keep { "enabled" } else { "disabled" };
@@ -772,8 +790,8 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn set_keep_changelog_types_english(&self, keep: bool) -> Result<()> { async fn set_keep_changelog_types_english(&self, keep: bool, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.set_keep_changelog_types_english(keep); manager.set_keep_changelog_types_english(keep);
manager.save()?; manager.save()?;
let status = if keep { "enabled" } else { "disabled" }; let status = if keep { "enabled" } else { "disabled" };
@@ -781,7 +799,7 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn reset(&self, force: bool) -> Result<()> { async fn reset(&self, force: bool, config_path: &Option<PathBuf>) -> Result<()> {
if !force { if !force {
let confirm = Confirm::new() let confirm = Confirm::new()
.with_prompt("Are you sure you want to reset all configuration?") .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.reset();
manager.save()?; manager.save()?;
@@ -802,8 +820,8 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn export_config(&self, output: Option<&str>) -> Result<()> { async fn export_config(&self, output: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let manager = ConfigManager::new()?; let manager = self.get_manager(config_path)?;
let toml = manager.export()?; let toml = manager.export()?;
if let Some(path) = output { if let Some(path) = output {
@@ -816,10 +834,10 @@ impl ConfigCommand {
Ok(()) 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 toml = std::fs::read_to_string(file)?;
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.import(&toml)?; manager.import(&toml)?;
manager.save()?; manager.save()?;
@@ -827,8 +845,8 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn list_models(&self) -> Result<()> { async fn list_models(&self, config_path: &Option<PathBuf>) -> Result<()> {
let manager = ConfigManager::new()?; let manager = self.get_manager(config_path)?;
let config = manager.config(); let config = manager.config();
match config.llm.provider.as_str() { match config.llm.provider.as_str() {
@@ -984,8 +1002,8 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn test_llm(&self) -> Result<()> { async fn test_llm(&self, config_path: &Option<PathBuf>) -> Result<()> {
let manager = ConfigManager::new()?; let manager = self.get_manager(config_path)?;
let config = manager.config(); let config = manager.config();
println!("Testing LLM connection ({})...", config.llm.provider.cyan()); println!("Testing LLM connection ({})...", config.llm.provider.cyan());

View File

@@ -2,6 +2,7 @@ use anyhow::Result;
use clap::Parser; use clap::Parser;
use colored::Colorize; use colored::Colorize;
use dialoguer::{Confirm, Input, Select}; use dialoguer::{Confirm, Input, Select};
use std::path::PathBuf;
use crate::config::{GitProfile, Language}; use crate::config::{GitProfile, Language};
use crate::config::manager::ConfigManager; use crate::config::manager::ConfigManager;
@@ -22,12 +23,13 @@ pub struct InitCommand {
} }
impl InitCommand { impl InitCommand {
pub async fn execute(&self) -> Result<()> { pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
// Start with English messages for initialization
let messages = Messages::new(Language::English); let messages = Messages::new(Language::English);
println!("{}", messages.initializing().bold().cyan()); 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 // Check if config already exists
if config_path.exists() && !self.reset { if config_path.exists() && !self.reset {
@@ -41,20 +43,24 @@ impl InitCommand {
println!("{}", "Initialization cancelled.".yellow()); println!("{}", "Initialization cancelled.".yellow());
return Ok(()); return Ok(());
} }
} else {
println!("{}", "Configuration already exists. Use --reset to overwrite.".yellow());
return Ok(());
} }
} }
let mut manager = if self.reset { // Create parent directory if needed
ConfigManager::new()? if let Some(parent) = config_path.parent() {
} else { std::fs::create_dir_all(parent)
ConfigManager::new().or_else(|_| Ok::<_, anyhow::Error>(ConfigManager::default()))? .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 { if self.yes {
// Quick setup with defaults
self.quick_setup(&mut manager).await?; self.quick_setup(&mut manager).await?;
} else { } else {
// Interactive setup
self.interactive_setup(&mut manager).await?; self.interactive_setup(&mut manager).await?;
} }

View File

@@ -2,6 +2,7 @@ use anyhow::{bail, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use colored::Colorize; use colored::Colorize;
use dialoguer::{Confirm, Input, Select}; use dialoguer::{Confirm, Input, Select};
use std::path::PathBuf;
use crate::config::manager::ConfigManager; use crate::config::manager::ConfigManager;
use crate::config::{GitProfile, TokenConfig, TokenType}; use crate::config::{GitProfile, TokenConfig, TokenType};
@@ -123,27 +124,34 @@ enum TokenSubcommand {
} }
impl ProfileCommand { impl ProfileCommand {
pub async fn execute(&self) -> Result<()> { pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
match &self.command { match &self.command {
Some(ProfileSubcommand::Add) => self.add_profile().await, Some(ProfileSubcommand::Add) => self.add_profile(&config_path).await,
Some(ProfileSubcommand::Remove { name }) => self.remove_profile(name).await, Some(ProfileSubcommand::Remove { name }) => self.remove_profile(name, &config_path).await,
Some(ProfileSubcommand::List) => self.list_profiles().await, Some(ProfileSubcommand::List) => self.list_profiles(&config_path).await,
Some(ProfileSubcommand::Show { name }) => self.show_profile(name.as_deref()).await, Some(ProfileSubcommand::Show { name }) => self.show_profile(name.as_deref(), &config_path).await,
Some(ProfileSubcommand::Edit { name }) => self.edit_profile(name).await, Some(ProfileSubcommand::Edit { name }) => self.edit_profile(name, &config_path).await,
Some(ProfileSubcommand::SetDefault { name }) => self.set_default(name).await, Some(ProfileSubcommand::SetDefault { name }) => self.set_default(name, &config_path).await,
Some(ProfileSubcommand::SetRepo { name }) => self.set_repo(name).await, Some(ProfileSubcommand::SetRepo { name }) => self.set_repo(name, &config_path).await,
Some(ProfileSubcommand::Apply { name, global }) => self.apply_profile(name.as_deref(), *global).await, Some(ProfileSubcommand::Apply { name, global }) => self.apply_profile(name.as_deref(), *global, &config_path).await,
Some(ProfileSubcommand::Switch) => self.switch_profile().await, Some(ProfileSubcommand::Switch) => self.switch_profile(&config_path).await,
Some(ProfileSubcommand::Copy { from, to }) => self.copy_profile(from, to).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).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()).await, Some(ProfileSubcommand::Check { name }) => self.check_profile(name.as_deref(), &config_path).await,
Some(ProfileSubcommand::Stats { name }) => self.show_stats(name.as_deref()).await, Some(ProfileSubcommand::Stats { name }) => self.show_stats(name.as_deref(), &config_path).await,
None => self.list_profiles().await, None => self.list_profiles(&config_path).await,
} }
} }
async fn add_profile(&self) -> Result<()> { fn get_manager(&self, config_path: &Option<PathBuf>) -> Result<ConfigManager> {
let mut manager = ConfigManager::new()?; 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!("{}", "\nAdd new profile".bold());
println!("{}", "".repeat(40)); println!("{}", "".repeat(40));
@@ -244,8 +252,8 @@ impl ProfileCommand {
Ok(()) Ok(())
} }
async fn remove_profile(&self, name: &str) -> Result<()> { async fn remove_profile(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
if !manager.has_profile(name) { if !manager.has_profile(name) {
bail!("Profile '{}' not found", name); bail!("Profile '{}' not found", name);
@@ -269,8 +277,8 @@ impl ProfileCommand {
Ok(()) Ok(())
} }
async fn list_profiles(&self) -> Result<()> { async fn list_profiles(&self, config_path: &Option<PathBuf>) -> Result<()> {
let manager = ConfigManager::new()?; let manager = self.get_manager(config_path)?;
let profiles = manager.list_profiles(); let profiles = manager.list_profiles();
@@ -319,8 +327,8 @@ impl ProfileCommand {
Ok(()) Ok(())
} }
async fn show_profile(&self, name: Option<&str>) -> Result<()> { async fn show_profile(&self, name: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let manager = ConfigManager::new()?; let manager = self.get_manager(config_path)?;
let profile = if let Some(n) = name { let profile = if let Some(n) = name {
manager.get_profile(n) manager.get_profile(n)
@@ -380,8 +388,8 @@ impl ProfileCommand {
Ok(()) Ok(())
} }
async fn edit_profile(&self, name: &str) -> Result<()> { async fn edit_profile(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
let profile = manager.get_profile(name) let profile = manager.get_profile(name)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", name))? .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", name))?
@@ -420,8 +428,8 @@ impl ProfileCommand {
Ok(()) Ok(())
} }
async fn set_default(&self, name: &str) -> Result<()> { async fn set_default(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.set_default_profile(Some(name.to_string()))?; manager.set_default_profile(Some(name.to_string()))?;
manager.save()?; manager.save()?;
@@ -431,8 +439,8 @@ impl ProfileCommand {
Ok(()) Ok(())
} }
async fn set_repo(&self, name: &str) -> Result<()> { async fn set_repo(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
let repo = find_repo(std::env::current_dir()?.as_path())?; let repo = find_repo(std::env::current_dir()?.as_path())?;
let repo_path = repo.path().to_string_lossy().to_string(); let repo_path = repo.path().to_string_lossy().to_string();
@@ -453,8 +461,8 @@ impl ProfileCommand {
Ok(()) Ok(())
} }
async fn apply_profile(&self, name: Option<&str>, global: bool) -> Result<()> { async fn apply_profile(&self, name: Option<&str>, global: bool, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
let profile_name = if let Some(n) = name { let profile_name = if let Some(n) = name {
n.to_string() n.to_string()
@@ -490,8 +498,8 @@ impl ProfileCommand {
Ok(()) Ok(())
} }
async fn switch_profile(&self) -> Result<()> { async fn switch_profile(&self, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
let profiles: Vec<String> = manager.list_profiles() let profiles: Vec<String> = manager.list_profiles()
.into_iter() .into_iter()
@@ -527,15 +535,15 @@ impl ProfileCommand {
.interact()?; .interact()?;
if apply { if apply {
self.apply_profile(Some(selected), false).await?; self.apply_profile(Some(selected), false, config_path).await?;
} }
} }
Ok(()) Ok(())
} }
async fn copy_profile(&self, from: &str, to: &str) -> Result<()> { async fn copy_profile(&self, from: &str, to: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
let source = manager.get_profile(from) let source = manager.get_profile(from)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", from))? .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", from))?
@@ -555,16 +563,16 @@ impl ProfileCommand {
Ok(()) 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 { match cmd {
TokenSubcommand::Add { profile, service } => self.add_token(profile, service).await, TokenSubcommand::Add { profile, service } => self.add_token(profile, service, config_path).await,
TokenSubcommand::Remove { profile, service } => self.remove_token(profile, service).await, TokenSubcommand::Remove { profile, service } => self.remove_token(profile, service, config_path).await,
TokenSubcommand::List { profile } => self.list_tokens(profile).await, TokenSubcommand::List { profile } => self.list_tokens(profile, config_path).await,
} }
} }
async fn add_token(&self, profile_name: &str, service: &str) -> Result<()> { async fn add_token(&self, profile_name: &str, service: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
if !manager.has_profile(profile_name) { if !manager.has_profile(profile_name) {
bail!("Profile '{}' not found", profile_name); bail!("Profile '{}' not found", profile_name);
@@ -610,8 +618,8 @@ impl ProfileCommand {
Ok(()) Ok(())
} }
async fn remove_token(&self, profile_name: &str, service: &str) -> Result<()> { async fn remove_token(&self, profile_name: &str, service: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
if !manager.has_profile(profile_name) { if !manager.has_profile(profile_name) {
bail!("Profile '{}' not found", profile_name); bail!("Profile '{}' not found", profile_name);
@@ -635,8 +643,8 @@ impl ProfileCommand {
Ok(()) Ok(())
} }
async fn list_tokens(&self, profile_name: &str) -> Result<()> { async fn list_tokens(&self, profile_name: &str, config_path: &Option<PathBuf>) -> Result<()> {
let manager = ConfigManager::new()?; let manager = self.get_manager(config_path)?;
let profile = manager.get_profile(profile_name) let profile = manager.get_profile(profile_name)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?; .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?;
@@ -662,8 +670,8 @@ impl ProfileCommand {
Ok(()) Ok(())
} }
async fn check_profile(&self, name: Option<&str>) -> Result<()> { async fn check_profile(&self, name: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let manager = ConfigManager::new()?; let manager = self.get_manager(config_path)?;
let profile_name = if let Some(n) = name { let profile_name = if let Some(n) = name {
n.to_string() n.to_string()
@@ -695,8 +703,8 @@ impl ProfileCommand {
Ok(()) Ok(())
} }
async fn show_stats(&self, name: Option<&str>) -> Result<()> { async fn show_stats(&self, name: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let manager = ConfigManager::new()?; let manager = self.get_manager(config_path)?;
if let Some(n) = name { if let Some(n) = name {
let profile = manager.get_profile(n) let profile = manager.get_profile(n)

View File

@@ -3,6 +3,7 @@ use clap::Parser;
use colored::Colorize; use colored::Colorize;
use dialoguer::{Confirm, Input, Select}; use dialoguer::{Confirm, Input, Select};
use semver::Version; use semver::Version;
use std::path::PathBuf;
use crate::config::{Language, manager::ConfigManager}; use crate::config::{Language, manager::ConfigManager};
use crate::git::{find_repo, GitRepo}; use crate::git::{find_repo, GitRepo};
@@ -61,9 +62,13 @@ pub struct TagCommand {
} }
impl 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 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 config = manager.config();
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);

View File

@@ -19,7 +19,11 @@ impl ConfigManager {
/// Create config manager with specific path /// Create config manager with specific path
pub fn with_path(path: &Path) -> Result<Self> { 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 { Ok(Self {
config, config,
config_path: path.to_path_buf(), 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 /// Get configuration reference
pub fn config(&self) -> &AppConfig { pub fn config(&self) -> &AppConfig {
&self.config &self.config

View File

@@ -177,8 +177,17 @@ impl GitProfile {
if let Some(ref ssh) = self.ssh { if let Some(ref ssh) = self.ssh {
if let Some(ref key_path) = ssh.private_key_path { if let Some(ref key_path) = ssh.private_key_path {
config.set_str("core.sshCommand", let path_str = key_path.display().to_string();
&format!("ssh -i {}", key_path.display()))?; #[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))?;
}
} }
} }
@@ -206,8 +215,17 @@ impl GitProfile {
if let Some(ref ssh) = self.ssh { if let Some(ref ssh) = self.ssh {
if let Some(ref key_path) = ssh.private_key_path { if let Some(ref key_path) = ssh.private_key_path {
config.set_str("core.sshCommand", let path_str = key_path.display().to_string();
&format!("ssh -i {}", key_path.display()))?; #[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))?;
}
} }
} }
@@ -351,7 +369,15 @@ impl SshConfig {
if let Some(ref cmd) = self.ssh_command { if let Some(ref cmd) = self.ssh_command {
Some(cmd.clone()) Some(cmd.clone())
} else if let Some(ref key_path) = self.private_key_path { } 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 { } else {
None None
} }
@@ -511,7 +537,11 @@ pub struct ConfigDifference {
} }
fn default_gpg_program() -> String { 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 { fn default_true() -> bool {

View File

@@ -47,6 +47,12 @@ impl CommitBuilder {
self self
} }
/// Set scope (optional)
pub fn scope_opt(mut self, scope: Option<String>) -> Self {
self.scope = scope;
self
}
/// Set description /// Set description
pub fn description(mut self, description: impl Into<String>) -> Self { pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into()); self.description = Some(description.into());
@@ -59,6 +65,12 @@ impl CommitBuilder {
self self
} }
/// Set body (optional)
pub fn body_opt(mut self, body: Option<String>) -> Self {
self.body = body;
self
}
/// Set footer /// Set footer
pub fn footer(mut self, footer: impl Into<String>) -> Self { pub fn footer(mut self, footer: impl Into<String>) -> Self {
self.footer = Some(footer.into()); self.footer = Some(footer.into());

View File

@@ -1,6 +1,6 @@
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use git2::{Repository, Signature, StatusOptions, Config, Oid, ObjectType}; use git2::{Repository, Signature, StatusOptions, Config, Oid, ObjectType};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf, Component};
use std::collections::HashMap; use std::collections::HashMap;
use tempfile; use tempfile;
@@ -8,7 +8,166 @@ pub mod changelog;
pub mod commit; pub mod commit;
pub mod tag; pub mod tag;
/// Git repository wrapper with enhanced cross-platform support #[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 ")
}
pub struct GitRepo { pub struct GitRepo {
repo: Repository, repo: Repository,
path: PathBuf, path: PathBuf,
@@ -16,54 +175,45 @@ pub struct GitRepo {
} }
impl GitRepo { impl GitRepo {
/// Open a git repository
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> { pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref(); let path = path.as_ref();
// Enhanced cross-platform path handling let absolute_path = get_absolute_path(path)?;
let absolute_path = if let Ok(canonical) = path.canonicalize() { let resolved_path = resolve_path_without_canonicalize(&absolute_path);
canonical
} else { let repo = try_open_repo_with_git2(&resolved_path)
// Fallback: convert to absolute path without canonicalization .or_else(|git2_err| {
if path.is_absolute() { try_open_repo_with_git_cli(&resolved_path)
path.to_path_buf() .map_err(|cli_err| {
} else { let diagnosis = diagnose_repo_issue(&resolved_path);
std::env::current_dir()?.join(path) anyhow::anyhow!(
} "Failed to open git repository:\n\
}; \n\
=== git2 Error ===\n {}\n\
// Try multiple git repository discovery strategies for cross-platform compatibility \n\
let repo = Repository::discover(&absolute_path) === git CLI Error ===\n {}\n\
.or_else(|discover_err| { \n\
// Try direct open as fallback === Diagnosis ===\n {}\n\
Repository::open(&absolute_path).map_err(|open_err| { \n\
// Provide detailed error information for debugging === Suggestions ===\n\
anyhow::anyhow!( 1. Ensure you are inside a git repository\n\
"Git repository discovery failed:\n\ 2. Run: git status (to verify git works)\n\
Discovery error: {}\n\ 3. Run: git config --global --add safe.directory \"*\"\n\
Direct open error: {}\n\ 4. Check file permissions",
Path attempted: {:?}\n\ git2_err, cli_err, diagnosis
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_path = repo.workdir()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| resolved_path.clone());
let config = repo.config().ok(); let config = repo.config().ok();
Ok(Self { Ok(Self {
repo, repo,
path: absolute_path, path: normalize_path_for_git2(&repo_path),
config, config,
}) })
} }
@@ -718,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> { pub fn find_repo<P: AsRef<Path>>(start_path: P) -> Result<GitRepo> {
let start_path = start_path.as_ref(); let start_path = start_path.as_ref();
// Try the starting path first let absolute_start = get_absolute_path(start_path)?;
if let Ok(repo) = GitRepo::open(start_path) { let resolved_start = resolve_path_without_canonicalize(&absolute_start);
if let Ok(repo) = GitRepo::open(&resolved_start) {
return Ok(repo); return Ok(repo);
} }
// Walk up the directory tree to find a git repository let mut current = resolved_start.as_path();
let mut current = start_path;
let mut attempted_paths = vec![current.to_string_lossy().to_string()]; 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() { while let Some(parent) = current.parent() {
depth += 1;
if depth > max_depth {
break;
}
attempted_paths.push(parent.to_string_lossy().to_string()); attempted_paths.push(parent.to_string_lossy().to_string());
if let Ok(repo) = GitRepo::open(parent) { if let Ok(repo) = GitRepo::open(parent) {
@@ -740,18 +898,44 @@ pub fn find_repo<P: AsRef<Path>>(start_path: P) -> Result<GitRepo> {
current = parent; 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!( bail!(
"No git repository found starting from {:?}.\n\ "No git repository found.\n\
Paths attempted:\n {}\n\ \n\
Current directory: {:?}\n\ === Starting Path ===\n {:?}\n\
Please ensure:\n\ \n\
1. You are in a git repository or its subdirectory\n\ === Paths Attempted ===\n {}\n\
2. The repository has a valid .git folder\n\ \n\
3. You have proper permissions to access the repository", === Current Directory ===\n {:?}\n\
start_path, \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 "), attempted_paths.join("\n "),
std::env::current_dir() std::env::current_dir().unwrap_or_default(),
diagnosis
) )
} }

View File

@@ -281,8 +281,9 @@ pub fn delete_tag(repo: &GitRepo, name: &str, remote: Option<&str>) -> Result<()
if let Some(remote) = remote { if let Some(remote) = remote {
use std::process::Command; use std::process::Command;
let refspec = format!(":refs/tags/{}", name);
let output = Command::new("git") let output = Command::new("git")
.args(&["push", remote, ":refs/tags/{}"]) .args(&["push", remote, &refspec])
.current_dir(repo.path()) .current_dir(repo.path())
.output()?; .output()?;

View File

@@ -1,5 +1,6 @@
use anyhow::Result; use anyhow::Result;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use std::path::PathBuf;
use tracing::debug; use tracing::debug;
mod commands; mod commands;
@@ -74,7 +75,6 @@ enum Commands {
async fn main() -> Result<()> { async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
// Initialize logging
let log_level = match cli.verbose { let log_level = match cli.verbose {
0 => "warn", 0 => "warn",
1 => "info", 1 => "info",
@@ -89,13 +89,14 @@ async fn main() -> Result<()> {
debug!("Starting quicommit v{}", env!("CARGO_PKG_VERSION")); debug!("Starting quicommit v{}", env!("CARGO_PKG_VERSION"));
// Execute command let config_path: Option<PathBuf> = cli.config.map(PathBuf::from);
match cli.command { match cli.command {
Commands::Init(cmd) => cmd.execute().await, Commands::Init(cmd) => cmd.execute(config_path).await,
Commands::Commit(cmd) => cmd.execute().await, Commands::Commit(cmd) => cmd.execute(config_path).await,
Commands::Tag(cmd) => cmd.execute().await, Commands::Tag(cmd) => cmd.execute(config_path).await,
Commands::Changelog(cmd) => cmd.execute().await, Commands::Changelog(cmd) => cmd.execute(config_path).await,
Commands::Profile(cmd) => cmd.execute().await, Commands::Profile(cmd) => cmd.execute(config_path).await,
Commands::Config(cmd) => cmd.execute().await, Commands::Config(cmd) => cmd.execute(config_path).await,
} }
} }

View File

@@ -41,8 +41,22 @@ pub fn get_editor() -> String {
.or_else(|_| std::env::var("VISUAL")) .or_else(|_| std::env::var("VISUAL"))
.unwrap_or_else(|_| { .unwrap_or_else(|_| {
if cfg!(target_os = "windows") { 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() "notepad".to_string()
} else if cfg!(target_os = "macos") {
if which::which("code").is_ok() {
return "code --wait".to_string();
}
"vi".to_string()
} else { } else {
if which::which("nano").is_ok() {
return "nano".to_string();
}
"vi".to_string() "vi".to_string()
} }
}) })

View File

@@ -1,64 +1,642 @@
use assert_cmd::Command; use assert_cmd::Command;
use predicates::prelude::*; use predicates::prelude::*;
use std::fs; use std::fs;
use std::path::PathBuf;
use tempfile::TempDir; use tempfile::TempDir;
#[test] fn create_git_repo(dir: &PathBuf) -> std::process::Output {
fn test_cli_help() { std::process::Command::new("git")
let mut cmd = Command::cargo_bin("quicommit").unwrap(); .args(&["init"])
cmd.arg("--help"); .current_dir(dir)
cmd.assert() .output()
.success() .expect("Failed to init git repo")
.stdout(predicate::str::contains("QuiCommit"));
} }
#[test] fn configure_git_user(dir: &PathBuf) {
fn test_version() { std::process::Command::new("git")
let mut cmd = Command::cargo_bin("quicommit").unwrap(); .args(&["config", "user.name", "Test User"])
cmd.arg("--version"); .current_dir(dir)
cmd.assert() .output()
.success() .expect("Failed to configure git user name");
.stdout(predicate::str::contains("0.1.0"));
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 create_test_file(dir: &PathBuf, name: &str, content: &str) {
fn test_config_show() { let file_path = dir.join(name);
let temp_dir = TempDir::new().unwrap(); fs::write(&file_path, content).expect("Failed to create test file");
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();
} }
#[test] fn stage_file(dir: &PathBuf, name: &str) {
fn test_profile_list_empty() { std::process::Command::new("git")
let temp_dir = TempDir::new().unwrap(); .args(&["add", name])
.current_dir(dir)
let mut cmd = Command::cargo_bin("quicommit").unwrap(); .output()
cmd.env("QUICOMMIT_CONFIG", temp_dir.path().join("config.toml")) .expect("Failed to stage file");
.arg("profile")
.arg("list");
cmd.assert()
.success()
.stdout(predicate::str::contains("No profiles configured"));
} }
#[test] fn create_commit(dir: &PathBuf, message: &str) {
fn test_init_quick() { std::process::Command::new("git")
let temp_dir = TempDir::new().unwrap(); .args(&["commit", "-m", message])
.current_dir(dir)
let mut cmd = Command::cargo_bin("quicommit").unwrap(); .output()
cmd.env("QUICOMMIT_CONFIG", temp_dir.path().join("config.toml")) .expect("Failed to create commit");
.arg("init") }
.arg("--yes");
mod cli_basic {
cmd.assert() use super::*;
.success()
.stdout(predicate::str::contains("initialized successfully")); #[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"));
}
} }