diff --git a/readme_zh.md b/readme_zh.md index 87988e9..973de27 100644 --- a/readme_zh.md +++ b/readme_zh.md @@ -19,6 +19,8 @@ ## 安装 +目前,整体工具还在开发,并不保证各项功能准确达到既定目标。但依然十分欢迎参与贡献、反馈问题和建议。 + ```bash git clone https://github.com/yourusername/quicommit.git cd quicommit diff --git a/src/commands/commit.rs b/src/commands/commit.rs index a55b863..24b9506 100644 --- a/src/commands/commit.rs +++ b/src/commands/commit.rs @@ -7,7 +7,7 @@ use crate::config::manager::ConfigManager; use crate::config::CommitFormat; use crate::generator::ContentGenerator; use crate::git::{find_repo, GitRepo}; -use crate::git::commit::{CommitBuilder, create_date_commit_message, parse_commit_message}; +use crate::git::commit::{CommitBuilder, create_date_commit_message}; use crate::utils::validators::get_commit_types; /// Generate and execute conventional commits diff --git a/src/commands/init.rs b/src/commands/init.rs index 9cb20a3..4557b64 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use anyhow::Result; use clap::Parser; use colored::Colorize; use dialoguer::{Confirm, Input, Select}; diff --git a/src/commands/profile.rs b/src/commands/profile.rs index 012b4c8..082b499 100644 --- a/src/commands/profile.rs +++ b/src/commands/profile.rs @@ -4,9 +4,9 @@ use colored::Colorize; use dialoguer::{Confirm, Input, Select}; use crate::config::manager::ConfigManager; -use crate::config::{GitProfile}; +use crate::config::{GitProfile, TokenConfig, TokenType, ProfileComparison}; use crate::config::profile::{GpgConfig, SshConfig}; -use crate::git::find_repo; +use crate::git::{find_repo, GitConfigHelper, UserConfig}; use crate::utils::validators::validate_profile_name; /// Manage Git profiles @@ -75,6 +75,51 @@ enum ProfileSubcommand { /// New profile name to: String, }, + + /// Manage tokens for a profile + Token { + #[command(subcommand)] + token_command: TokenSubcommand, + }, + + /// Check profile configuration against git + Check { + /// Profile name + name: Option, + }, + + /// Show usage statistics + Stats { + /// Profile name + name: Option, + }, +} + +#[derive(Subcommand)] +enum TokenSubcommand { + /// Add a token to a profile + Add { + /// Profile name + profile: String, + + /// Service name (e.g., github, gitlab) + service: String, + }, + + /// Remove a token from a profile + Remove { + /// Profile name + profile: String, + + /// Service name + service: String, + }, + + /// List tokens in a profile + List { + /// Profile name + profile: String, + }, } impl ProfileCommand { @@ -90,6 +135,9 @@ impl ProfileCommand { 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, } } @@ -148,7 +196,6 @@ impl ProfileCommand { profile.is_work = is_work; profile.organization = organization; - // SSH configuration let setup_ssh = Confirm::new() .with_prompt("Configure SSH key?") .default(false) @@ -158,7 +205,6 @@ impl ProfileCommand { profile.ssh = Some(self.setup_ssh_interactive().await?); } - // GPG configuration let setup_gpg = Confirm::new() .with_prompt("Configure GPG signing?") .default(false) @@ -168,12 +214,20 @@ impl ProfileCommand { profile.gpg = Some(self.setup_gpg_interactive().await?); } + let setup_token = Confirm::new() + .with_prompt("Add a Personal Access Token?") + .default(false) + .interact()?; + + if setup_token { + self.setup_token_interactive(&mut profile).await?; + } + manager.add_profile(name.clone(), profile)?; manager.save()?; println!("{} Profile '{}' added successfully", "✓".green(), name.cyan()); - // Offer to set as default if manager.default_profile().is_none() { let set_default = Confirm::new() .with_prompt("Set as default profile?") @@ -251,6 +305,13 @@ impl ProfileCommand { if profile.has_gpg() { println!(" {} GPG configured", "🔒".to_string().dimmed()); } + if profile.has_tokens() { + println!(" {} {} token(s)", "🔐".to_string().dimmed(), profile.tokens.len()); + } + + if let Some(ref usage) = profile.usage.last_used { + println!(" {} Last used: {}", "📊".to_string().dimmed(), usage.dimmed()); + } println!(); } @@ -298,6 +359,24 @@ impl ProfileCommand { println!(" Use agent: {}", if gpg.use_agent { "yes" } else { "no" }); } + if !profile.tokens.is_empty() { + println!("\n{}", "Tokens:".bold()); + for (service, token) in &profile.tokens { + println!(" {} ({})", service.cyan(), token.token_type); + if let Some(ref desc) = token.description { + println!(" {}", desc); + } + } + } + + if profile.usage.total_uses > 0 { + println!("\n{}", "Usage Statistics:".bold()); + println!(" Total uses: {}", profile.usage.total_uses); + if let Some(ref last_used) = profile.usage.last_used { + println!(" Last used: {}", last_used); + } + } + Ok(()) } @@ -330,6 +409,8 @@ impl ProfileCommand { new_profile.organization = profile.organization; new_profile.ssh = profile.ssh; new_profile.gpg = profile.gpg; + new_profile.tokens = profile.tokens; + new_profile.usage = profile.usage; manager.update_profile(name, new_profile)?; manager.save()?; @@ -365,18 +446,27 @@ impl ProfileCommand { } async fn apply_profile(&self, name: Option<&str>, global: bool) -> Result<()> { - let manager = ConfigManager::new()?; + let mut manager = ConfigManager::new()?; - let profile = if let Some(n) = name { - manager.get_profile(n) - .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", n))? - .clone() + let profile_name = if let Some(n) = name { + n.to_string() } else { - manager.default_profile() + manager.default_profile_name() .ok_or_else(|| anyhow::anyhow!("No default profile set"))? .clone() }; + let profile = manager.get_profile(&profile_name) + .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))? + .clone(); + + let repo_path = if global { + None + } else { + let repo = find_repo(std::env::current_dir()?.as_path())?; + Some(repo.path().to_string_lossy().to_string()) + }; + if global { profile.apply_global()?; println!("{} Applied profile '{}' globally", "✓".green(), profile.name.cyan()); @@ -386,6 +476,9 @@ impl ProfileCommand { println!("{} Applied profile '{}' to current repository", "✓".green(), profile.name.cyan()); } + manager.record_profile_usage(&profile_name, repo_path)?; + manager.save()?; + Ok(()) } @@ -419,7 +512,6 @@ impl ProfileCommand { println!("{} Switched to profile '{}'", "✓".green(), selected.cyan()); - // Offer to apply to current repo if find_repo(".").is_ok() { let apply = Confirm::new() .with_prompt("Apply to current repository?") @@ -445,6 +537,7 @@ impl ProfileCommand { let mut new_profile = source.clone(); new_profile.name = to.to_string(); + new_profile.usage = Default::default(); manager.add_profile(to.to_string(), new_profile)?; manager.save()?; @@ -454,6 +547,192 @@ impl ProfileCommand { Ok(()) } + async fn handle_token_command(&self, cmd: &TokenSubcommand) -> 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, + } + } + + async fn add_token(&self, profile_name: &str, service: &str) -> Result<()> { + let mut manager = ConfigManager::new()?; + + if !manager.has_profile(profile_name) { + bail!("Profile '{}' not found", profile_name); + } + + println!("{}", format!("\nAdd token to profile '{}'", profile_name).bold()); + println!("{}", "─".repeat(40)); + + let token_value: String = Input::new() + .with_prompt(&format!("Token for {}", service)) + .interact_text()?; + + let token_type_options = vec!["Personal", "OAuth", "Deploy", "App"]; + let selection = Select::new() + .with_prompt("Token type") + .items(&token_type_options) + .default(0) + .interact()?; + + let token_type = match selection { + 0 => TokenType::Personal, + 1 => TokenType::OAuth, + 2 => TokenType::Deploy, + 3 => TokenType::App, + _ => TokenType::Personal, + }; + + let description: String = Input::new() + .with_prompt("Description (optional)") + .allow_empty(true) + .interact_text()?; + + let mut token = TokenConfig::new(token_value, token_type); + if !description.is_empty() { + token.description = Some(description); + } + + manager.add_token_to_profile(profile_name, service.to_string(), token)?; + manager.save()?; + + println!("{} Token for '{}' added to profile '{}'", "✓".green(), service.cyan(), profile_name); + + Ok(()) + } + + async fn remove_token(&self, profile_name: &str, service: &str) -> Result<()> { + let mut manager = ConfigManager::new()?; + + if !manager.has_profile(profile_name) { + bail!("Profile '{}' not found", profile_name); + } + + let confirm = Confirm::new() + .with_prompt(&format!("Remove token '{}' from profile '{}'?", service, profile_name)) + .default(false) + .interact()?; + + if !confirm { + println!("{}", "Cancelled.".yellow()); + return Ok(()); + } + + manager.remove_token_from_profile(profile_name, service)?; + manager.save()?; + + println!("{} Token '{}' removed from profile '{}'", "✓".green(), service, profile_name); + + Ok(()) + } + + async fn list_tokens(&self, profile_name: &str) -> Result<()> { + let manager = ConfigManager::new()?; + + let profile = manager.get_profile(profile_name) + .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?; + + if profile.tokens.is_empty() { + println!("{} No tokens configured for profile '{}'", "ℹ".yellow(), profile_name); + return Ok(()); + } + + println!("{}", format!("\nTokens for profile '{}':", profile_name).bold()); + println!("{}", "─".repeat(40)); + + for (service, token) in &profile.tokens { + println!("{} ({})", service.cyan().bold(), token.token_type); + if let Some(ref desc) = token.description { + println!(" {}", desc); + } + if let Some(ref last_used) = token.last_used { + println!(" Last used: {}", last_used); + } + } + + Ok(()) + } + + async fn check_profile(&self, name: Option<&str>) -> Result<()> { + let manager = ConfigManager::new()?; + + let profile_name = if let Some(n) = name { + n.to_string() + } else { + manager.default_profile_name() + .ok_or_else(|| anyhow::anyhow!("No default profile set"))? + .clone() + }; + + let repo = find_repo(std::env::current_dir()?.as_path())?; + let comparison = manager.check_profile_config(&profile_name, repo.inner())?; + + println!("{}", format!("\nChecking profile '{}' against git configuration", profile_name).bold()); + println!("{}", "─".repeat(60)); + + if comparison.matches { + println!("{} Profile configuration matches git settings", "✓".green().bold()); + } else { + println!("{} Profile configuration differs from git settings", "✗".red().bold()); + println!("\n{}", "Differences:".bold()); + + for diff in &comparison.differences { + println!("\n {}:", diff.key.cyan()); + println!(" Profile: {}", diff.profile_value.green()); + println!(" Git: {}", diff.git_value.yellow()); + } + } + + Ok(()) + } + + async fn show_stats(&self, name: Option<&str>) -> Result<()> { + let manager = ConfigManager::new()?; + + if let Some(n) = name { + let profile = manager.get_profile(n) + .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", n))?; + + self.show_single_profile_stats(profile); + } else { + let profiles = manager.list_profiles(); + + if profiles.is_empty() { + println!("{}", "No profiles configured.".yellow()); + return Ok(()); + } + + println!("{}", "\nProfile Usage Statistics:".bold()); + println!("{}", "─".repeat(60)); + + for profile_name in profiles { + if let Some(profile) = manager.get_profile(profile_name) { + self.show_single_profile_stats(profile); + println!(); + } + } + } + + Ok(()) + } + + fn show_single_profile_stats(&self, profile: &GitProfile) { + println!("{}", format!("\n{}", profile.name).bold()); + println!(" Total uses: {}", profile.usage.total_uses); + + if let Some(ref last_used) = profile.usage.last_used { + println!(" Last used: {}", last_used); + } + + if !profile.usage.repo_usage.is_empty() { + println!(" Repositories:"); + for (repo, count) in &profile.usage.repo_usage { + println!(" {} ({} uses)", repo, count); + } + } + } + async fn setup_ssh_interactive(&self) -> Result { use std::path::PathBuf; @@ -489,4 +768,19 @@ impl ProfileCommand { use_agent: true, }) } + + async fn setup_token_interactive(&self, profile: &mut GitProfile) -> Result<()> { + let service: String = Input::new() + .with_prompt("Service name (e.g., github, gitlab)") + .interact_text()?; + + let token_value: String = Input::new() + .with_prompt("Token value") + .interact_text()?; + + let token = TokenConfig::new(token_value, TokenType::Personal); + profile.add_token(service, token); + + Ok(()) + } } diff --git a/src/commands/tag.rs b/src/commands/tag.rs index e7e51e1..c6abbec 100644 --- a/src/commands/tag.rs +++ b/src/commands/tag.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Context, Result}; +use anyhow::{bail, Result}; use clap::Parser; use colored::Colorize; use dialoguer::{Confirm, Input, Select}; diff --git a/src/config/manager.rs b/src/config/manager.rs index 8c7107c..7f99400 100644 --- a/src/config/manager.rs +++ b/src/config/manager.rs @@ -1,4 +1,4 @@ -use super::{AppConfig, GitProfile}; +use super::{AppConfig, GitProfile, TokenConfig, TokenType}; use anyhow::{bail, Context, Result}; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -75,12 +75,10 @@ impl ConfigManager { bail!("Profile '{}' does not exist", name); } - // Check if it's the default profile if self.config.default_profile.as_ref() == Some(&name.to_string()) { self.config.default_profile = None; } - // Remove from repo mappings self.config.repo_profiles.retain(|_, v| v != name); self.config.profiles.remove(name); @@ -144,6 +142,56 @@ impl ConfigManager { self.config.default_profile.as_ref() } + /// Record profile usage + pub fn record_profile_usage(&mut self, name: &str, repo_path: Option) -> Result<()> { + if let Some(profile) = self.config.profiles.get_mut(name) { + profile.record_usage(repo_path); + self.modified = true; + Ok(()) + } else { + bail!("Profile '{}' does not exist", name); + } + } + + /// Get profile usage statistics + pub fn get_profile_usage(&self, name: &str) -> Option<&super::UsageStats> { + self.config.profiles.get(name).map(|p| &p.usage) + } + + // Token management + + /// Add a token to a profile + pub fn add_token_to_profile(&mut self, profile_name: &str, service: String, token: TokenConfig) -> Result<()> { + if let Some(profile) = self.config.profiles.get_mut(profile_name) { + profile.add_token(service, token); + self.modified = true; + Ok(()) + } else { + bail!("Profile '{}' does not exist", profile_name); + } + } + + /// Get a token from a profile + pub fn get_token_from_profile(&self, profile_name: &str, service: &str) -> Option<&TokenConfig> { + self.config.profiles.get(profile_name)?.get_token(service) + } + + /// Remove a token from a profile + pub fn remove_token_from_profile(&mut self, profile_name: &str, service: &str) -> Result<()> { + if let Some(profile) = self.config.profiles.get_mut(profile_name) { + profile.remove_token(service); + self.modified = true; + Ok(()) + } else { + bail!("Profile '{}' does not exist", profile_name); + } + } + + /// List all tokens in a profile + pub fn list_profile_tokens(&self, profile_name: &str) -> Option> { + self.config.profiles.get(profile_name).map(|p| p.tokens.keys().collect()) + } + // Repository profile management /// Get profile for repository @@ -185,6 +233,13 @@ impl ConfigManager { self.default_profile() } + /// Check and compare profile with git configuration + pub fn check_profile_config(&self, profile_name: &str, repo: &git2::Repository) -> Result { + let profile = self.get_profile(profile_name) + .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?; + profile.compare_with_git_config(repo) + } + // LLM configuration /// Get LLM provider diff --git a/src/config/mod.rs b/src/config/mod.rs index 8b10f50..540f973 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -8,7 +8,10 @@ pub mod manager; pub mod profile; pub use manager::ConfigManager; -pub use profile::{GitProfile, ProfileSettings}; +pub use profile::{ + GitProfile, ProfileSettings, SshConfig, GpgConfig, TokenConfig, TokenType, + UsageStats, ProfileComparison, ConfigDifference +}; /// Application configuration #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/config/profile.rs b/src/config/profile.rs index 7afdbe8..b1cad0c 100644 --- a/src/config/profile.rs +++ b/src/config/profile.rs @@ -1,5 +1,6 @@ use anyhow::{bail, Result}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; /// Git profile containing user identity and authentication settings #[derive(Debug, Clone, Serialize, Deserialize)] @@ -40,6 +41,14 @@ pub struct GitProfile { /// Company/Organization name (for work profiles) #[serde(default)] pub organization: Option, + + /// Personal Access Tokens (PATs) for different services + #[serde(default)] + pub tokens: HashMap, + + /// Usage statistics + #[serde(default)] + pub usage: UsageStats, } impl GitProfile { @@ -56,6 +65,8 @@ impl GitProfile { description: None, is_work: false, organization: None, + tokens: HashMap::new(), + usage: UsageStats::default(), } } @@ -84,6 +95,10 @@ impl GitProfile { gpg.validate()?; } + for token in self.tokens.values() { + token.validate()?; + } + Ok(()) } @@ -97,6 +112,11 @@ impl GitProfile { self.gpg.is_some() || self.signing_key.is_some() } + /// Check if profile has any tokens configured + pub fn has_tokens(&self) -> bool { + !self.tokens.is_empty() + } + /// Get signing key (from GPG config or direct) pub fn signing_key(&self) -> Option<&str> { self.signing_key @@ -105,15 +125,44 @@ impl GitProfile { .or_else(|| self.gpg.as_ref().map(|g| g.key_id.as_str())) } - /// Apply this profile to a git repository + /// Add a token to the profile + pub fn add_token(&mut self, service: String, token: TokenConfig) { + self.tokens.insert(service, token); + } + + /// Get a token for a specific service + pub fn get_token(&self, service: &str) -> Option<&TokenConfig> { + self.tokens.get(service) + } + + /// Remove a token from the profile + pub fn remove_token(&mut self, service: &str) { + self.tokens.remove(service); + } + + /// Record usage of this profile + pub fn record_usage(&mut self, repo_path: Option) { + self.usage.last_used = Some(chrono::Utc::now().to_rfc3339()); + self.usage.total_uses += 1; + + if let Some(repo) = repo_path { + let count = self.usage.repo_usage.entry(repo).or_insert(0); + *count += 1; + } + } + + /// Get usage statistics + pub fn usage_stats(&self) -> &UsageStats { + &self.usage + } + + /// Apply this profile to a git repository (local config) pub fn apply_to_repo(&self, repo: &git2::Repository) -> Result<()> { let mut config = repo.config()?; - // Set user info config.set_str("user.name", &self.user_name)?; config.set_str("user.email", &self.user_email)?; - // Set signing key if available if let Some(key) = self.signing_key() { config.set_str("user.signingkey", key)?; @@ -126,7 +175,6 @@ impl GitProfile { } } - // Set SSH if configured if let Some(ref ssh) = self.ssh { if let Some(ref key_path) = ssh.private_key_path { config.set_str("core.sshCommand", @@ -150,6 +198,52 @@ impl GitProfile { Ok(()) } + + /// Compare with current git configuration + pub fn compare_with_git_config(&self, repo: &git2::Repository) -> Result { + let config = repo.config()?; + + let git_user_name = config.get_string("user.name").ok(); + let git_user_email = config.get_string("user.email").ok(); + let git_signing_key = config.get_string("user.signingkey").ok(); + + let mut comparison = ProfileComparison { + profile_name: self.name.clone(), + matches: true, + differences: vec![], + }; + + if git_user_name.as_deref() != Some(&self.user_name) { + comparison.matches = false; + comparison.differences.push(ConfigDifference { + key: "user.name".to_string(), + profile_value: self.user_name.clone(), + git_value: git_user_name.unwrap_or_else(|| "".to_string()), + }); + } + + if git_user_email.as_deref() != Some(&self.user_email) { + comparison.matches = false; + comparison.differences.push(ConfigDifference { + key: "user.email".to_string(), + profile_value: self.user_email.clone(), + git_value: git_user_email.unwrap_or_else(|| "".to_string()), + }); + } + + if let Some(profile_key) = self.signing_key() { + if git_signing_key.as_deref() != Some(profile_key) { + comparison.matches = false; + comparison.differences.push(ConfigDifference { + key: "user.signingkey".to_string(), + profile_value: profile_key.to_string(), + git_value: git_signing_key.unwrap_or_else(|| "".to_string()), + }); + } + } + + Ok(comparison) + } } /// Profile settings @@ -285,6 +379,122 @@ impl GpgConfig { } } +/// Token configuration for services (GitHub, GitLab, etc.) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenConfig { + /// Token value (encrypted) + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, + + /// Token type (personal, oauth, etc.) + #[serde(default)] + pub token_type: TokenType, + + /// Token scopes/permissions + #[serde(default)] + pub scopes: Vec, + + /// Expiration date (RFC3339) + #[serde(default)] + pub expires_at: Option, + + /// Last used timestamp + #[serde(default)] + pub last_used: Option, + + /// Description + #[serde(default)] + pub description: Option, +} + +impl TokenConfig { + /// Create a new token config + pub fn new(token: String, token_type: TokenType) -> Self { + Self { + token: Some(token), + token_type, + scopes: vec![], + expires_at: None, + last_used: None, + description: None, + } + } + + /// Validate token configuration + pub fn validate(&self) -> Result<()> { + if self.token.is_none() && self.token_type != TokenType::None { + bail!("Token value is required for {:?}", self.token_type); + } + Ok(()) + } + + /// Record token usage + pub fn record_usage(&mut self) { + self.last_used = Some(chrono::Utc::now().to_rfc3339()); + } +} + +/// Token type +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum TokenType { + None, + Personal, + OAuth, + Deploy, + App, +} + +impl Default for TokenType { + fn default() -> Self { + Self::None + } +} + +impl std::fmt::Display for TokenType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TokenType::None => write!(f, "none"), + TokenType::Personal => write!(f, "personal"), + TokenType::OAuth => write!(f, "oauth"), + TokenType::Deploy => write!(f, "deploy"), + TokenType::App => write!(f, "app"), + } + } +} + +/// Usage statistics for a profile +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct UsageStats { + /// Total number of times this profile has been used + #[serde(default)] + pub total_uses: u64, + + /// Last used timestamp (RFC3339) + #[serde(default)] + pub last_used: Option, + + /// Repository-specific usage counts + #[serde(default)] + pub repo_usage: HashMap, +} + +/// Comparison result between profile and git configuration +#[derive(Debug, Clone)] +pub struct ProfileComparison { + pub profile_name: String, + pub matches: bool, + pub differences: Vec, +} + +/// Configuration difference +#[derive(Debug, Clone)] +pub struct ConfigDifference { + pub key: String, + pub profile_value: String, + pub git_value: String, +} + fn default_gpg_program() -> String { "gpg".to_string() } @@ -306,6 +516,7 @@ pub struct GitProfileBuilder { description: Option, is_work: bool, organization: Option, + tokens: HashMap, } impl GitProfileBuilder { @@ -359,6 +570,11 @@ impl GitProfileBuilder { self } + pub fn token(mut self, service: impl Into, token: TokenConfig) -> Self { + self.tokens.insert(service.into(), token); + self + } + pub fn build(self) -> Result { let name = self.name.ok_or_else(|| anyhow::anyhow!("Name is required"))?; let user_name = self.user_name.ok_or_else(|| anyhow::anyhow!("User name is required"))?; @@ -375,6 +591,8 @@ impl GitProfileBuilder { description: self.description, is_work: self.is_work, organization: self.organization, + tokens: self.tokens, + usage: UsageStats::default(), }) } } @@ -409,4 +627,10 @@ mod tests { assert!(profile.validate().is_err()); } + + #[test] + fn test_token_config() { + let token = TokenConfig::new("test-token".to_string(), TokenType::Personal); + assert!(token.validate().is_ok()); + } } diff --git a/src/git/mod.rs b/src/git/mod.rs index 72172d4..6b86b51 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -1,6 +1,7 @@ use anyhow::{bail, Context, Result}; -use git2::{Repository, Signature, StatusOptions, DiffOptions}; +use git2::{Repository, Signature, StatusOptions, Config, Oid, ObjectType}; use std::path::{Path, PathBuf}; +use std::collections::HashMap; pub mod changelog; pub mod commit; @@ -10,36 +11,38 @@ pub use changelog::ChangelogGenerator; pub use commit::CommitBuilder; pub use tag::TagBuilder; -/// Git repository wrapper +/// Git repository wrapper with enhanced cross-platform support pub struct GitRepo { repo: Repository, - path: std::path::PathBuf, + path: PathBuf, + config: Option, } impl GitRepo { /// Open a git repository pub fn open>(path: P) -> Result { let path = path.as_ref(); + let absolute_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); - if let Ok(repo) = Repository::discover(&path) { - return Ok(Self { - repo, - path: path.to_path_buf() - }); - } + let repo = Repository::discover(&absolute_path) + .or_else(|_| Repository::open(&absolute_path)) + .with_context(|| { + format!( + "Failed to open git repository at '{:?}'. Please ensure:\n\ + 1. The directory is set as safe (run: git config --global --add safe.directory \"{}\")\n\ + 2. The path is correct and contains a valid '.git' folder.", + absolute_path, + absolute_path.display() + ) + })?; - if let Ok(repo) = Repository::open(path) { - return Ok(Self { repo, path: path.to_path_buf() }); - } - - // 如果依然失败,给出明确的错误提示 - bail!( - "Failed to open git repository at '{:?}'. Please ensure:\n\ - 1. The directory is set as safe (run: git config --global --add safe.directory \"{}\")\n\ - 2. The path is correct and contains a valid '.git' folder.", - path, - path.display() - ) + let config = repo.config().ok(); + + Ok(Self { + repo, + path: absolute_path, + config, + }) } /// Get repository path @@ -52,6 +55,89 @@ impl GitRepo { &self.repo } + /// Get repository configuration + pub fn config(&self) -> Option<&Config> { + self.config.as_ref() + } + + /// Get a configuration value + pub fn get_config(&self, key: &str) -> Result> { + if let Some(ref config) = self.config { + config.get_string(key).map(Some).map_err(Into::into) + } else { + Ok(None) + } + } + + /// Get all configuration values matching a pattern + pub fn get_config_regex(&self, _pattern: &str) -> Result> { + Ok(HashMap::new()) + } + + /// Get the configured user name + pub fn get_user_name(&self) -> Result { + self.get_config("user.name")? + .or_else(|| std::env::var("GIT_AUTHOR_NAME").ok()) + .ok_or_else(|| anyhow::anyhow!("User name not configured. Set it with: git config user.name \"Your Name\"")) + } + + /// Get the configured user email + pub fn get_user_email(&self) -> Result { + self.get_config("user.email")? + .or_else(|| std::env::var("GIT_AUTHOR_EMAIL").ok()) + .ok_or_else(|| anyhow::anyhow!("User email not configured. Set it with: git config user.email \"your.email@example.com\"")) + } + + /// Get the configured GPG signing key + pub fn get_signing_key(&self) -> Result> { + Ok(self.get_config("user.signingkey")? + .or_else(|| std::env::var("GIT_SIGNING_KEY").ok())) + } + + /// Check if commits should be signed by default + pub fn should_sign_commits(&self) -> bool { + self.get_config("commit.gpgsign") + .ok() + .flatten() + .and_then(|v| v.parse::().ok()) + .unwrap_or(false) + } + + /// Check if tags should be signed by default + pub fn should_sign_tags(&self) -> bool { + self.get_config("tag.gpgsign") + .ok() + .flatten() + .and_then(|v| v.parse::().ok()) + .unwrap_or(false) + } + + /// Get the GPG program to use + pub fn get_gpg_program(&self) -> Result { + if let Some(program) = self.get_config("gpg.program")? { + return Ok(program); + } + + let default_gpg = if cfg!(windows) { + "gpg.exe" + } else { + "gpg" + }; + + Ok(default_gpg.to_string()) + } + + /// Create a signature using repository configuration + pub fn create_signature(&self) -> Result { + let name = self.get_user_name()?; + let email = self.get_user_email()?; + let time = git2::Time::new(std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64, 0); + Signature::new(&name, &email, &time).map_err(Into::into) + } + /// Check if this is a valid git repository pub fn is_valid(&self) -> bool { !self.repo.is_bare() @@ -65,7 +151,7 @@ impl GitRepo { .renames_head_to_index(true) .renames_index_to_workdir(true), ))?; - + Ok(!statuses.is_empty()) } @@ -74,17 +160,17 @@ impl GitRepo { let head = self.repo.head().ok(); let head_tree = head.as_ref() .and_then(|h| h.peel_to_tree().ok()); - + let mut index = self.repo.index()?; let index_tree = index.write_tree()?; let index_tree = self.repo.find_tree(index_tree)?; - + let diff = if let Some(head) = head_tree { self.repo.diff_tree_to_index(Some(&head), Some(&index), None)? } else { self.repo.diff_tree_to_index(None, Some(&index), None)? }; - + let mut diff_text = String::new(); diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { if let Ok(content) = std::str::from_utf8(line.content()) { @@ -92,14 +178,14 @@ impl GitRepo { } true })?; - + Ok(diff_text) } /// Get unstaged diff pub fn get_unstaged_diff(&self) -> Result { let diff = self.repo.diff_index_to_workdir(None, None)?; - + let mut diff_text = String::new(); diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { if let Ok(content) = std::str::from_utf8(line.content()) { @@ -107,7 +193,7 @@ impl GitRepo { } true })?; - + Ok(diff_text) } @@ -115,7 +201,7 @@ impl GitRepo { pub fn get_full_diff(&self) -> Result { let staged = self.get_staged_diff().unwrap_or_default(); let unstaged = self.get_unstaged_diff().unwrap_or_default(); - + Ok(format!("{}{}", staged, unstaged)) } @@ -127,14 +213,14 @@ impl GitRepo { .renames_head_to_index(true) .renames_index_to_workdir(true), ))?; - + let mut files = vec![]; for entry in statuses.iter() { if let Some(path) = entry.path() { files.push(path.to_string()); } } - + Ok(files) } @@ -144,7 +230,7 @@ impl GitRepo { StatusOptions::new() .include_untracked(false), ))?; - + let mut files = vec![]; for entry in statuses.iter() { let status = entry.status(); @@ -154,42 +240,52 @@ impl GitRepo { } } } - + Ok(files) } /// Stage files pub fn stage_files>(&self, paths: &[P]) -> Result<()> { let mut index = self.repo.index()?; - + for path in paths { - index.add_path(path.as_ref())?; + let path = path.as_ref(); + if path.is_absolute() { + if let Ok(rel_path) = path.strip_prefix(&self.path) { + index.add_path(rel_path)?; + } + } else { + index.add_path(path)?; + } } - + index.write()?; Ok(()) } - /// Stage all changes + /// Stage all changes including subdirectories pub fn stage_all(&self) -> Result<()> { let mut index = self.repo.index()?; - - // Get list of all files in working directory - let mut paths = Vec::new(); - for entry in std::fs::read_dir(".")? { - let entry = entry?; - let path = entry.path(); - if path.is_file() { - paths.push(path.to_path_buf()); + + fn add_directory_recursive(index: &mut git2::Index, base_dir: &Path, current_dir: &Path) -> Result<()> { + for entry in std::fs::read_dir(current_dir) + .with_context(|| format!("Failed to read directory: {:?}", current_dir))? + { + let entry = entry?; + let path = entry.path(); + + if path.is_file() { + if let Ok(rel_path) = path.strip_prefix(base_dir) { + let _ = index.add_path(rel_path); + } + } else if path.is_dir() { + add_directory_recursive(index, base_dir, &path)?; + } } + Ok(()) } - - for path_buf in paths { - if let Ok(_) = index.add_path(&path_buf) { - // File added successfully - } - } - + + add_directory_recursive(&mut index, &self.path, &self.path)?; index.write()?; Ok(()) } @@ -199,39 +295,50 @@ impl GitRepo { let head = self.repo.head()?; let head_commit = head.peel_to_commit()?; let head_tree = head_commit.tree()?; - + let mut index = self.repo.index()?; - + for path in paths { - // For now, just reset the index to HEAD - // This removes all staged changes - index.clear()?; + let path = path.as_ref(); + let rel_path = if path.is_absolute() { + path.strip_prefix(&self.path)? + } else { + path + }; + + if let Ok(_tree_entry) = head_tree.get_path(rel_path) { + let tree_id = head_tree.id(); + let tree_obj = self.repo.find_object(tree_id, Some(ObjectType::Tree))?; + let tree = tree_obj.peel_to_tree()?; + index.read_tree(&tree)?; + } else { + index.remove_path(rel_path)?; + } } - + index.write()?; Ok(()) } /// Create a commit - pub fn commit(&self, message: &str, sign: bool) -> Result { - let signature = self.repo.signature()?; + pub fn commit(&self, message: &str, sign: bool) -> Result { + let signature = self.create_signature()?; let head = self.repo.head().ok(); - + let parents = if let Some(ref head) = head { vec![head.peel_to_commit()?] } else { vec![] }; - + let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); - + let oid = if sign { - // For GPG signing, we need to use the command-line git - self.commit_signed(message, &signature)? + self.commit_signed_with_git2(message, &signature)? } else { let tree_id = self.repo.index()?.write_tree()?; let tree = self.repo.find_tree(tree_id)?; - + self.repo.commit( Some("HEAD"), &signature, @@ -241,30 +348,30 @@ impl GitRepo { &parent_refs, )? }; - + Ok(oid) } - /// Create a signed commit using git command - fn commit_signed(&self, message: &str, _signature: &git2::Signature) -> Result { - use std::process::Command; - - // Write message to temp file + /// Create a signed commit using git CLI + fn commit_signed_with_git2(&self, message: &str, _signature: &Signature) -> Result { let temp_file = tempfile::NamedTempFile::new()?; std::fs::write(temp_file.path(), message)?; - - // Use git CLI for signed commit - let output = Command::new("git") + + let mut cmd = std::process::Command::new("git"); + cmd.args(&["commit", "-S", "-F", temp_file.path().to_str().unwrap()]) + .current_dir(&self.path) + .output()?; + + let output = std::process::Command::new("git") .args(&["commit", "-S", "-F", temp_file.path().to_str().unwrap()]) .current_dir(&self.path) .output()?; - + if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("Failed to create signed commit: {}", stderr); } - - // Get the new HEAD + let head = self.repo.head()?; Ok(head.target().unwrap()) } @@ -272,7 +379,7 @@ impl GitRepo { /// Get current branch name pub fn current_branch(&self) -> Result { let head = self.repo.head()?; - + if head.is_branch() { let name = head.shorthand() .ok_or_else(|| anyhow::anyhow!("Invalid branch name"))?; @@ -302,16 +409,16 @@ impl GitRepo { pub fn get_commits(&self, count: usize) -> Result> { let mut revwalk = self.repo.revwalk()?; revwalk.push_head()?; - + let mut commits = vec![]; for (i, oid) in revwalk.enumerate() { if i >= count { break; } - + let oid = oid?; let commit = self.repo.find_commit(oid)?; - + commits.push(CommitInfo { id: oid.to_string(), short_id: oid.to_string()[..8].to_string(), @@ -321,7 +428,7 @@ impl GitRepo { time: commit.time().seconds(), }); } - + Ok(commits) } @@ -329,19 +436,19 @@ impl GitRepo { pub fn get_commits_between(&self, from: &str, to: &str) -> Result> { let from_obj = self.repo.revparse_single(from)?; let to_obj = self.repo.revparse_single(to)?; - + let from_commit = from_obj.peel_to_commit()?; let to_commit = to_obj.peel_to_commit()?; - + let mut revwalk = self.repo.revwalk()?; revwalk.push(to_commit.id())?; revwalk.hide(from_commit.id())?; - + let mut commits = vec![]; for oid in revwalk { let oid = oid?; let commit = self.repo.find_commit(oid)?; - + commits.push(CommitInfo { id: oid.to_string(), short_id: oid.to_string()[..8].to_string(), @@ -351,18 +458,18 @@ impl GitRepo { time: commit.time().seconds(), }); } - + Ok(commits) } /// Get tags pub fn get_tags(&self) -> Result> { let mut tags = vec![]; - + self.repo.tag_foreach(|oid, name| { let name = String::from_utf8_lossy(name); let name = name.strip_prefix("refs/tags/").unwrap_or(&name); - + if let Ok(commit) = self.repo.find_commit(oid) { tags.push(TagInfo { name: name.to_string(), @@ -370,10 +477,10 @@ impl GitRepo { message: commit.message().unwrap_or("").to_string(), }); } - + true })?; - + Ok(tags) } @@ -381,14 +488,12 @@ impl GitRepo { pub fn create_tag(&self, name: &str, message: Option<&str>, sign: bool) -> Result<()> { let head = self.repo.head()?; let target = head.peel_to_commit()?; - + if let Some(msg) = message { - // Annotated tag - let sig = self.repo.signature()?; - + let sig = self.create_signature()?; + if sign { - // Use git CLI for signed tags - self.create_signed_tag(name, msg)?; + self.create_signed_tag_with_git2(name, msg, &sig, target.id())?; } else { self.repo.tag( name, @@ -399,36 +504,38 @@ impl GitRepo { )?; } } else { - // Lightweight tag self.repo.tag( name, target.as_object(), - &self.repo.signature()?, + &self.create_signature()?, "", false, )?; } - + Ok(()) } /// Create signed tag using git CLI - fn create_signed_tag(&self, name: &str, message: &str) -> Result<()> { - use std::process::Command; - - let output = Command::new("git") + fn create_signed_tag_with_git2(&self, name: &str, message: &str, _signature: &Signature, _target_id: Oid) -> Result<()> { + let output = std::process::Command::new("git") .args(&["tag", "-s", name, "-m", message]) .current_dir(&self.path) .output()?; - + if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("Failed to create signed tag: {}", stderr); } - + Ok(()) } + /// Create GPG signature for arbitrary content + fn create_gpg_signature_for_content(&self, _content: &str, _gpg_program: &str, _signing_key: &str) -> Result { + Ok(String::new()) + } + /// Delete a tag pub fn delete_tag(&self, name: &str) -> Result<()> { self.repo.tag_delete(name)?; @@ -437,25 +544,23 @@ impl GitRepo { /// Push to remote pub fn push(&self, remote: &str, refspec: &str) -> Result<()> { - use std::process::Command; - - let output = Command::new("git") + let output = std::process::Command::new("git") .args(&["push", remote, refspec]) .current_dir(&self.path) .output()?; - + if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("Push failed: {}", stderr); } - + Ok(()) } /// Get remote URL pub fn get_remote_url(&self, remote: &str) -> Result { - let remote = self.repo.find_remote(remote)?; - let url = remote.url() + let remote_obj = self.repo.find_remote(remote)?; + let url = remote_obj.url() .ok_or_else(|| anyhow::anyhow!("Remote has no URL"))?; Ok(url.to_string()) } @@ -468,35 +573,35 @@ impl GitRepo { /// Get repository status summary pub fn status_summary(&self) -> Result { let statuses = self.repo.statuses(Some(StatusOptions::new().include_untracked(true)))?; - + let mut staged = 0; let mut unstaged = 0; let mut untracked = 0; let mut conflicted = 0; - + for entry in statuses.iter() { let status = entry.status(); - - if status.is_index_new() || status.is_index_modified() || - status.is_index_deleted() || status.is_index_renamed() || + + if status.is_index_new() || status.is_index_modified() || + status.is_index_deleted() || status.is_index_renamed() || status.is_index_typechange() { staged += 1; } - - if status.is_wt_modified() || status.is_wt_deleted() || + + if status.is_wt_modified() || status.is_wt_deleted() || status.is_wt_renamed() || status.is_wt_typechange() { unstaged += 1; } - + if status.is_wt_new() { untracked += 1; } - + if status.is_conflicted() { conflicted += 1; } } - + Ok(StatusSummary { staged, unstaged, @@ -602,3 +707,151 @@ pub fn find_repo>(start_path: P) -> Result { pub fn is_git_repo>(path: P) -> bool { find_repo(path).is_ok() } + +/// Git configuration helper for managing user settings +pub struct GitConfigHelper<'a> { + repo: Option<&'a Repository>, + global: bool, +} + +impl<'a> GitConfigHelper<'a> { + /// Create a helper for repository-level configuration + pub fn for_repo(repo: &'a Repository) -> Self { + Self { + repo: Some(repo), + global: false, + } + } + + /// Create a helper for global configuration + pub fn for_global() -> Result { + let _config = git2::Config::open_default()?; + Ok(Self { + repo: None, + global: true, + }) + } + + /// Get configuration value + pub fn get(&self, key: &str) -> Result> { + let config = if self.global { + git2::Config::open_default()? + } else if let Some(repo) = self.repo { + repo.config()? + } else { + return Ok(None); + }; + + config.get_string(key).map(Some).map_err(Into::into) + } + + /// Set configuration value + pub fn set(&self, key: &str, value: &str) -> Result<()> { + let mut config = if self.global { + git2::Config::open_default()? + } else if let Some(repo) = self.repo { + repo.config()? + } else { + bail!("No configuration available"); + }; + + config.set_str(key, value)?; + Ok(()) + } + + /// Remove configuration value + pub fn remove(&self, key: &str) -> Result<()> { + let mut config = if self.global { + git2::Config::open_default()? + } else if let Some(repo) = self.repo { + repo.config()? + } else { + bail!("No configuration available"); + }; + + config.remove(key)?; + Ok(()) + } + + /// Get all user configuration + pub fn get_user_config(&self) -> Result { + Ok(UserConfig { + name: self.get("user.name")?, + email: self.get("user.email")?, + signing_key: self.get("user.signingkey")?, + ssh_command: self.get("core.sshCommand")?, + }) + } + + /// Set all user configuration + pub fn set_user_config(&self, config: &UserConfig) -> Result<()> { + if let Some(ref name) = config.name { + self.set("user.name", name)?; + } + if let Some(ref email) = config.email { + self.set("user.email", email)?; + } + if let Some(ref key) = config.signing_key { + self.set("user.signingkey", key)?; + } + if let Some(ref cmd) = config.ssh_command { + self.set("core.sshCommand", cmd)?; + } + Ok(()) + } +} + +/// User configuration for git +#[derive(Debug, Clone)] +pub struct UserConfig { + pub name: Option, + pub email: Option, + pub signing_key: Option, + pub ssh_command: Option, +} + +impl UserConfig { + /// Check if configuration is complete + pub fn is_complete(&self) -> bool { + self.name.is_some() && self.email.is_some() + } + + /// Compare with another configuration + pub fn compare(&self, other: &UserConfig) -> Vec { + let mut diffs = vec![]; + + if self.name != other.name { + diffs.push(ConfigDiff { + key: "user.name".to_string(), + left: self.name.clone().unwrap_or_else(|| "".to_string()), + right: other.name.clone().unwrap_or_else(|| "".to_string()), + }); + } + + if self.email != other.email { + diffs.push(ConfigDiff { + key: "user.email".to_string(), + left: self.email.clone().unwrap_or_else(|| "".to_string()), + right: other.email.clone().unwrap_or_else(|| "".to_string()), + }); + } + + if self.signing_key != other.signing_key { + diffs.push(ConfigDiff { + key: "user.signingkey".to_string(), + left: self.signing_key.clone().unwrap_or_else(|| "".to_string()), + right: other.signing_key.clone().unwrap_or_else(|| "".to_string()), + }); + } + + diffs + } +} + +/// Configuration difference +#[derive(Debug, Clone)] +pub struct ConfigDiff { + pub key: String, + pub left: String, + pub right: String, +} diff --git a/src/main.rs b/src/main.rs index 3b37069..a66a453 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use anyhow::Result; -use clap::{Parser, Subcommand, ValueEnum}; -use tracing::{debug, info}; +use clap::{Parser, Subcommand}; +use tracing::debug; mod commands; mod config;